diff --git a/src/main/java/se/su/dsv/seshat/controllers/FileController.java b/src/main/java/se/su/dsv/seshat/controllers/FileController.java index bd61b10..a7dd87a 100644 --- a/src/main/java/se/su/dsv/seshat/controllers/FileController.java +++ b/src/main/java/se/su/dsv/seshat/controllers/FileController.java @@ -1,5 +1,7 @@ package se.su.dsv.seshat.controllers; +import io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxResponse; +import io.github.wimdeblauwe.htmx.spring.boot.mvc.HxRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.io.FileSystemResource; @@ -15,19 +17,19 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.View; +import org.springframework.web.servlet.view.FragmentsRendering; import se.su.dsv.seshat.entities.AppUser; import se.su.dsv.seshat.entities.FileMetadata; +import se.su.dsv.seshat.entities.JobStatus; import se.su.dsv.seshat.services.JobProcessorService; import se.su.dsv.seshat.services.StorageService; import se.su.dsv.seshat.services.UserService; import java.io.File; import java.time.LocalDate; -import java.util.Comparator; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; @Controller @@ -44,42 +46,53 @@ public class FileController { this.jobProcessorService = jobProcessorService; } + @HxRequest + @GetMapping("/files/transcribed-files") + public String getTranscribedFiles(Authentication authentication, Model model) { + AppUser user = userService.getUserByUsername(authentication.getName()); + Map<FileMetadata, List<FileMetadata>> filesByDirectory = storageService.getTranscribedFilesByDirectory(user); + model.addAttribute("filesByDirectory", filesByDirectory); + return "file-management :: transcribed-files"; + } + + + @HxRequest + @GetMapping("/files/file-upload-statuses") + public View getFileUploadStatuses(Authentication authentication, Model model, HtmxResponse htmxResponse) { + AppUser user = userService.getUserByUsername(authentication.getName()); + List<FileMetadata> uploaded = storageService.getUserUplaods(user); + + List<FileMetadata> fileUploadStatuses = getFileUploadStatuses(uploaded); + boolean hasJobInProgress = hasJobInProgress(fileUploadStatuses); + + model.addAttribute("fileUploadStatuses", fileUploadStatuses); + model.addAttribute("hasJobInProgress", hasJobInProgress); + + if(hasJobInProgress) { + // return "file-management :: file-upload-statuses"; + return FragmentsRendering.with("file-management :: file-upload-statuses").build(); + + } + + htmxResponse.addTrigger("transcription-finished"); + return FragmentsRendering.with("file-management :: file-upload-statuses-no-jobs").build(); + } + @GetMapping("/files/manage") public String showFileManagementPage(Authentication authentication, Model model) { AppUser user = userService.getUserByUsername(authentication.getName()); - List<FileMetadata> files = storageService.getUserTranscriptons(user); List<FileMetadata> uploaded = storageService.getUserUplaods(user); - List<FileMetadata> statuses = uploaded.stream() + List<FileMetadata> fileUploadStatuses = uploaded.stream() .filter(file -> file.getJobStatus() != null) .toList(); - // Map<String, List<FileMetadata>> filesByDirectory = files.stream().collect(Collectors.groupingBy(FileMetadata::getOutputDirectory)); - - Map<FileMetadata, List<FileMetadata>> filesByDirectory = files.stream() - .collect(Collectors.groupingBy(outputFile -> { - return uploaded.stream() - .filter(uploadedFile -> uploadedFile.getOutputDirectory().equals(outputFile.getOutputDirectory())) - .filter(uploadedFile -> outputFile.getSourceFile().equals(uploadedFile.getFilePath())) - .findFirst() - .orElseThrow(/* Will never happen */); - - })); - - Map<FileMetadata, List<FileMetadata>> sortedFilesByDirectory = filesByDirectory.entrySet().stream() - .sorted(Map.Entry.comparingByKey(Comparator.comparing(FileMetadata::getUploadedAt).reversed())) - .collect(Collectors.toMap( - Map.Entry::getKey, - Map.Entry::getValue, - (e1, e2) -> { - e1.addAll(e2); - return e1; - }, - LinkedHashMap::new - )); + boolean hasJobInProgress = hasJobInProgress(fileUploadStatuses); + Map<FileMetadata, List<FileMetadata>> sortedFilesByDirectory = storageService.getTranscribedFilesByDirectory(user); model.addAttribute("filesByDirectory", sortedFilesByDirectory); - model.addAttribute("statuses", statuses); + model.addAttribute("fileUploadStatuses", fileUploadStatuses); + model.addAttribute("hasJobInProgress", hasJobInProgress); return "file-management"; } @@ -100,6 +113,7 @@ public class FileController { model.addAttribute("message", "File uploaded successfully. Transcription will start shortly."); } catch (Exception e) { + logger.error("Error uploading file", e); model.addAttribute("error", "File upload failed: " + e.getMessage()); } @@ -169,4 +183,16 @@ public class FileController { return "redirect:/files/manage"; } + private static List<FileMetadata> getFileUploadStatuses(List<FileMetadata> uploaded) { + return uploaded.stream() + .filter(file -> file.getJobStatus() != null) + .toList(); + } + + private static boolean hasJobInProgress(List<FileMetadata> fileUploadStatuses) { + return fileUploadStatuses.stream() + .anyMatch(file -> file.getJobStatus().equals(JobStatus.PROCESSING) || + file.getJobStatus().equals(JobStatus.PENDING)); + } + } diff --git a/src/main/java/se/su/dsv/seshat/services/StorageService.java b/src/main/java/se/su/dsv/seshat/services/StorageService.java index 4971a38..7f67d7f 100644 --- a/src/main/java/se/su/dsv/seshat/services/StorageService.java +++ b/src/main/java/se/su/dsv/seshat/services/StorageService.java @@ -22,7 +22,10 @@ import java.nio.file.StandardCopyOption; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; +import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.stream.Collectors; import java.util.zip.ZipEntry; @@ -176,7 +179,7 @@ public class StorageService { } catch (IllegalArgumentException e) { return false; } catch (IOException e) { - logger.info("Failed to delete file or directory: " + e.getMessage()); + logger.info("Failed to delete file or directory: {}", e.getMessage()); } } else { return false; @@ -227,15 +230,38 @@ public class StorageService { return zipFile; } + public Map<FileMetadata, List<FileMetadata>> getTranscribedFilesByDirectory(AppUser user) { + List<FileMetadata> files = this.getUserTranscriptons(user); + List<FileMetadata> uploaded = this.getUserUplaods(user); + + Map<FileMetadata, List<FileMetadata>> filesByDirectory = files.stream() + .collect(Collectors.groupingBy(outputFile -> uploaded.stream() + .filter(uploadedFile -> uploadedFile.getOutputDirectory().equals(outputFile.getOutputDirectory())) + .filter(uploadedFile -> outputFile.getSourceFile().equals(uploadedFile.getFilePath())) + .findFirst() + .orElseThrow(/* Will never happen */))); + + return filesByDirectory.entrySet().stream() + .sorted(Map.Entry.comparingByKey(Comparator.comparing(FileMetadata::getUploadedAt).reversed())) + .collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue, + (e1, e2) -> { + e1.addAll(e2); + return e1; + }, + LinkedHashMap::new + )); + } + private String sanitizeFilename(String filename) { return filename.replaceAll("[^a-zA-Z0-9.-]", "_"); } private String fileNameAndUploadedTime(FileMetadata file) { - String fileName = file.getFileName(); LocalDateTime uploadedAt = file.getUploadedAt(); String uploadedToSeconds = uploadedAt.truncatedTo(ChronoUnit.SECONDS).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); - return fileNameWithoutExtension(file.getFileName()) + "_" + uploadedToSeconds.replaceAll(":|-", ""); + return fileNameWithoutExtension(file.getFileName()) + "_" + uploadedToSeconds.replaceAll("[:\\-]", ""); } private String fileNameWithoutExtension(String fileName) { diff --git a/src/main/java/se/su/dsv/seshat/services/Transcriber.java b/src/main/java/se/su/dsv/seshat/services/Transcriber.java index 3d1fc5f..e8011a2 100644 --- a/src/main/java/se/su/dsv/seshat/services/Transcriber.java +++ b/src/main/java/se/su/dsv/seshat/services/Transcriber.java @@ -11,11 +11,11 @@ import java.io.IOException; public class Transcriber { private static final Logger logger = LoggerFactory.getLogger(Transcriber.class); - private String modelPath; - private String selectedModel; - private String workingDirectory; + private final String modelPath; + private final String selectedModel; + private final String workingDirectory; ProcessBuilder startVirtualEnv; - private String selectedDevice; + private final String selectedDevice; public Transcriber(WhisperProperties whisperProperties) { modelPath = whisperProperties.modelPath(); @@ -32,7 +32,7 @@ public class Transcriber { Process p = startVirtualEnv.start(); p.waitFor(); } catch (Exception e) { - logger.error("Failed to activate virtual environment: " + e.getMessage()); + logger.error("Failed to activate virtual environment: {}", e.getMessage()); } } diff --git a/src/main/resources/templates/file-management.html b/src/main/resources/templates/file-management.html index 5b75a20..dda4084 100644 --- a/src/main/resources/templates/file-management.html +++ b/src/main/resources/templates/file-management.html @@ -1,5 +1,5 @@ <!DOCTYPE html> -<html xmlns:th="http://www.thymeleaf.org" xmlns:hx-get="http://www.w3.org/1999/xhtml" lang="en"> +<html xmlns:th="http://www.thymeleaf.org" lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> @@ -28,7 +28,6 @@ <main class="container mt-4"> <h2>File Management</h2> - <!-- File Upload Section --> <section> <h3>Upload File</h3> @@ -53,10 +52,9 @@ <button type="submit" class="btn btn-primary">Upload</button> </form> </section> - <!-- File Status Section --> - <section th:if="${statuses != null && !statuses.isEmpty()}" class="mt-5"> - <h3>File Upload Statuses</h3> + <section th:if="${fileUploadStatuses != null && !fileUploadStatuses.isEmpty()}" class="mt-5"> + <h3>File Upload History</h3> <div class="table-wrapper"> <table class="table"> <thead> @@ -67,15 +65,34 @@ <th>Status</th> </tr> </thead> - <tbody> - <tr th:each="status : ${statuses}"> - <td th:text="${status.fileName}">File Name</td> - <td th:text="${#temporals.format(status.getUploadedAt(), 'yyyy-MM-dd')}">Upload Date</td> - <td th:text="${#temporals.format(status.getUploadedAt(), 'HH:mm:ss')}">Upload time</td> - <td> - <span class="badge bg-primary" th:text="${status.jobStatus}">Status</span> - </td> - </tr> + <tbody th:if="${hasJobInProgress == true}" + th:fragment="file-upload-statuses" + hx-get="/files/file-upload-statuses" + hx-trigger="load delay:10s" + hx-swap="outerHTML"> + <tr th:each="uploadStatus : ${fileUploadStatuses}"> + <td th:text="${uploadStatus.fileName}">File Name</td> + <td th:text="${#temporals.format(uploadStatus.getUploadedAt(), 'yyyy-MM-dd')}">Upload Date</td> + <td th:text="${#temporals.format(uploadStatus.getUploadedAt(), 'HH:mm:ss')}">Upload time</td> + <td> + <span class="badge bg-primary" th:text="${uploadStatus.jobStatus}">Status</span> + <span th:if="${uploadStatus.jobStatus == uploadStatus.jobStatus.PENDING || + uploadStatus.jobStatus == uploadStatus.jobStatus.PROCESSING}"> + <span class="spinner-border spinner-border-sm text-primary" role="status"></span> + </span> + </td> + </tr> + </tbody> + <tbody th:unless="${hasJobInProgress == true}" + th:fragment="file-upload-statuses-no-jobs"> + <tr th:each="uploadStatus : ${fileUploadStatuses}"> + <td th:text="${uploadStatus.fileName}">File Name</td> + <td th:text="${#temporals.format(uploadStatus.getUploadedAt(), 'yyyy-MM-dd')}">Upload Date</td> + <td th:text="${#temporals.format(uploadStatus.getUploadedAt(), 'HH:mm:ss')}">Upload time</td> + <td> + <span class="badge bg-primary" th:text="${uploadStatus.jobStatus}">Status</span> + </td> + </tr> </tbody> </table> </div> @@ -86,7 +103,13 @@ <!-- File Browsing Section --> <section> <h3>Your Transcribed Files</h3> - <form id="bulk-actions-form" method="post"> + <form th:fragment="transcribed-files" + id="bulk-actions-form" + hx-trigger="transcription-finished from:body" + hx-get="/files/transcribed-files" + hx-swap="outerHTML" + method="post" + class="transcribed-files"> <div th:each="entry : ${filesByDirectory}"> <a data-bs-toggle="collapse" @@ -132,8 +155,8 @@ <footer class="bg-primary text-white text-center py-3"> <p>© 2024 Seshat App</p> </footer> -<script type="text/javascript" th:src="@{/js/script.js}"></script> <script type="text/javascript" th:src="@{/3p/bootstrap-5.3.3-dist/js/bootstrap.bundle.min.js}"></script> <script type="text/javascript" th:src="@{/3p/htmx/2.0.4/dist/htmx.min.js}"></script> +<script type="text/javascript" th:src="@{/js/script.js}"></script> </body> </html>