diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/FileMoveRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/FileMoveRequest.java index 7013f3a8b..52a4d5e3c 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/FileMoveRequest.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/FileMoveRequest.java @@ -2,9 +2,17 @@ package com.adityachandel.booklore.model.dto.request; import lombok.Data; +import java.util.List; import java.util.Set; @Data public class FileMoveRequest { private Set bookIds; + private List moves; + + @Data + public static class Move { + private Long bookId; + private Long targetLibraryId; + } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java index 32756c6f8..a5ecc0f44 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java @@ -174,5 +174,8 @@ public interface BookRepository extends JpaRepository, JpaSpec @Query("DELETE FROM BookEntity b WHERE b.deletedAt IS NOT NULL AND b.deletedAt < :cutoff") int deleteAllByDeletedAtBefore(Instant cutoff); - + @Modifying + @Transactional + @Query("UPDATE BookEntity b SET b.library.id = :libraryId WHERE b.id = :bookId") + void updateLibraryId(@Param("bookId") Long bookId, @Param("libraryId") Long libraryId); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/LibraryPathRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/LibraryPathRepository.java index c210f9a55..0fb0c2644 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/LibraryPathRepository.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/LibraryPathRepository.java @@ -4,7 +4,9 @@ import com.adityachandel.booklore.model.entity.LibraryPathEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface LibraryPathRepository extends JpaRepository { - + Optional findByLibraryIdAndPath(Long libraryId, String path); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMoveService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMoveService.java index 80fa8e616..d078b08de 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMoveService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMoveService.java @@ -12,10 +12,13 @@ import com.adityachandel.booklore.util.PathPatternResolver; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; -import java.util.List; -import java.util.Set; +import java.util.*; +import java.util.stream.Collectors; + +import static com.adityachandel.booklore.model.enums.PermissionType.ADMIN; +import static com.adityachandel.booklore.model.enums.PermissionType.MANIPULATE_LIBRARY; @Slf4j @Service @@ -30,10 +33,21 @@ public class FileMoveService { private final NotificationService notificationService; private final UnifiedFileMoveService unifiedFileMoveService; + @Transactional public void moveFiles(FileMoveRequest request) { - Set bookIds = request.getBookIds(); - log.info("Moving {} books in batches of {}", bookIds.size(), BATCH_SIZE); + List bookIds = request.getBookIds().stream().toList(); + List moves = request.getMoves() != null ? request.getMoves() : List.of(); + log.info("Moving {} books with {} specific moves in batches of {}", bookIds.size(), moves.size(), BATCH_SIZE); + + Map bookToTargetLibraryMap = moves.stream() + .collect(Collectors.toMap( + FileMoveRequest.Move::getBookId, + FileMoveRequest.Move::getTargetLibraryId + )); + + Map> libraryRemovals = new HashMap<>(); + Map> libraryAdditions = new HashMap<>(); List allUpdatedBooks = new ArrayList<>(); int totalProcessed = 0; int offset = 0; @@ -41,14 +55,17 @@ public class FileMoveService { while (offset < bookIds.size()) { log.info("Processing batch {}/{}", (offset / BATCH_SIZE) + 1, (bookIds.size() + BATCH_SIZE - 1) / BATCH_SIZE); - List batchBooks = bookQueryService.findWithMetadataByIdsWithPagination(bookIds, offset, BATCH_SIZE); + List batchBookIds = bookIds.subList(offset, Math.min(offset + BATCH_SIZE, bookIds.size())); + Set batchBookIdSet = new HashSet<>(batchBookIds); + + List batchBooks = bookQueryService.findWithMetadataByIdsWithPagination(batchBookIdSet, offset, BATCH_SIZE); if (batchBooks.isEmpty()) { log.info("No more books at offset {}", offset); break; } - List batchUpdatedBooks = processBookChunk(batchBooks); + List batchUpdatedBooks = processBookChunk(batchBooks, bookToTargetLibraryMap, libraryRemovals, libraryAdditions); allUpdatedBooks.addAll(batchUpdatedBooks); totalProcessed += batchBooks.size(); @@ -59,20 +76,56 @@ public class FileMoveService { log.info("Move completed: {} books processed, {} updated", totalProcessed, allUpdatedBooks.size()); sendUpdateNotifications(allUpdatedBooks); + sendCrossLibraryMoveNotifications(libraryRemovals, libraryAdditions); + } public String generatePathFromPattern(BookEntity book, String pattern) { return PathPatternResolver.resolvePattern(book, pattern); } - private List processBookChunk(List books) { + private List processBookChunk(List books, Map bookToTargetLibraryMap, Map> libraryRemovals, Map> libraryAdditions) { List updatedBooks = new ArrayList<>(); - unifiedFileMoveService.moveBatchBookFiles(books, new UnifiedFileMoveService.BatchMoveCallback() { + Map originalLibraryIds = new HashMap<>(); + for (BookEntity book : books) { + if (book.getLibraryPath() != null && book.getLibraryPath().getLibrary() != null) { + originalLibraryIds.put(book.getId(), book.getLibraryPath().getLibrary().getId()); + } + } + + unifiedFileMoveService.moveBatchBookFiles(books, bookToTargetLibraryMap, new UnifiedFileMoveService.BatchMoveCallback() { @Override public void onBookMoved(BookEntity book) { - bookRepository.save(book); - updatedBooks.add(bookMapper.toBook(book)); + Long targetLibraryId = bookToTargetLibraryMap.get(book.getId()); + Long originalSourceLibraryId = originalLibraryIds.get(book.getId()); + + log.debug("Processing moved book {}: targetLibraryId={}, originalSourceLibraryId={}", book.getId(), targetLibraryId, originalSourceLibraryId); + + if (targetLibraryId != null && originalSourceLibraryId != null && !targetLibraryId.equals(originalSourceLibraryId)) { + log.info("Cross-library move detected for book {}: {} -> {}", book.getId(), originalSourceLibraryId, targetLibraryId); + + Book bookDtoForRemoval = bookMapper.toBookWithDescription(book, false); + bookDtoForRemoval.setLibraryId(originalSourceLibraryId); + libraryRemovals.computeIfAbsent(originalSourceLibraryId, k -> new ArrayList<>()).add(bookDtoForRemoval); + log.debug("Added book {} to removal list for library {}", book.getId(), originalSourceLibraryId); + + bookRepository.updateLibraryId(book.getId(), targetLibraryId); + log.debug("Updated library_id for book {} to {}", book.getId(), targetLibraryId); + + BookEntity savedBook = bookRepository.saveAndFlush(book); + + Book updatedBookDto = bookMapper.toBookWithDescription(savedBook, false); + updatedBookDto.setLibraryId(targetLibraryId); + libraryAdditions.computeIfAbsent(targetLibraryId, k -> new ArrayList<>()).add(updatedBookDto); + log.debug("Added book {} to addition list for library {} with libraryId {}", book.getId(), targetLibraryId, updatedBookDto.getLibraryId()); + + updatedBooks.add(updatedBookDto); + } else { + log.debug("Same library move for book {} or no target specified. Target: {}, Original: {}", book.getId(), targetLibraryId, originalSourceLibraryId); + BookEntity savedBook = bookRepository.save(book); + updatedBooks.add(bookMapper.toBook(savedBook)); + } } @Override @@ -82,6 +135,8 @@ public class FileMoveService { } }); + log.info("Processed {} books, {} library removals tracked, {} library additions tracked", updatedBooks.size(), libraryRemovals.size(), libraryAdditions.size()); + return updatedBooks; } @@ -90,4 +145,38 @@ public class FileMoveService { notificationService.sendMessage(Topic.BOOK_METADATA_BATCH_UPDATE, updatedBooks); } } + + private void sendCrossLibraryMoveNotifications(Map> libraryRemovals, Map> libraryAdditions) { + log.info("Sending cross-library move notifications: {} removals, {} additions", + libraryRemovals.size(), libraryAdditions.size()); + + for (Map.Entry> entry : libraryRemovals.entrySet()) { + List removedBookIds = entry.getValue().stream() + .map(Book::getId) + .collect(Collectors.toList()); + + log.info("Notifying removal of {} books from library {}: {}", removedBookIds.size(), entry.getKey(), removedBookIds); + try { + notificationService.sendMessageToPermissions(Topic.BOOKS_REMOVE, removedBookIds, Set.of(ADMIN, MANIPULATE_LIBRARY)); + log.info("Successfully sent removal notification for library {}", entry.getKey()); + } catch (Exception e) { + log.error("Failed to send removal notification for library {}: {}", entry.getKey(), e.getMessage(), e); + } + } + + for (Map.Entry> entry : libraryAdditions.entrySet()) { + List addedBooks = entry.getValue(); + + log.info("Notifying addition of {} books to library {}", addedBooks.size(), entry.getKey()); + try { + for (Book book : addedBooks) { + log.debug("Sending BOOK_ADD notification for book {} to library {}", book.getId(), entry.getKey()); + notificationService.sendMessageToPermissions(Topic.BOOK_ADD, book, Set.of(ADMIN, MANIPULATE_LIBRARY)); + } + log.info("Successfully sent {} addition notifications for library {}", addedBooks.size(), entry.getKey()); + } catch (Exception e) { + log.error("Failed to send addition notifications for library {}: {}", entry.getKey(), e.getMessage(), e); + } + } + } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMovingHelper.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMovingHelper.java index 91084d202..c8616fb34 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMovingHelper.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMovingHelper.java @@ -4,7 +4,9 @@ import com.adityachandel.booklore.model.dto.BookMetadata; import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.LibraryEntity; +import com.adityachandel.booklore.model.entity.LibraryPathEntity; import com.adityachandel.booklore.repository.BookAdditionalFileRepository; +import com.adityachandel.booklore.repository.LibraryPathRepository; import com.adityachandel.booklore.service.appsettings.AppSettingService; import com.adityachandel.booklore.util.PathPatternResolver; import lombok.AllArgsConstructor; @@ -30,6 +32,7 @@ public class FileMovingHelper { private final BookAdditionalFileRepository bookAdditionalFileRepository; private final AppSettingService appSettingService; + private final LibraryPathRepository libraryPathRepository; /** * Generates the new file path based on the library's file naming pattern @@ -177,6 +180,53 @@ public class FileMovingHelper { } } + /** + * Moves a book file to a different library with the target library's naming pattern + */ + public boolean moveBookFileToLibrary(BookEntity book, LibraryEntity targetLibrary) throws IOException { + if (!hasRequiredPathComponents(book)) { + log.error("Missing required path components for book id {}. Cannot move to different library.", book.getId()); + return false; + } + + Path oldFilePath = book.getFullFilePath(); + String pattern = getFileNamingPattern(targetLibrary); + + // Generate new file path in target library + Path newFilePath = generateNewFilePathForLibrary(book, targetLibrary, pattern); + + if (oldFilePath.equals(newFilePath)) { + log.debug("Source and destination paths are identical for book id {}. Skipping cross-library move.", book.getId()); + return false; + } + + moveBookFileAndUpdatePathsForLibrary(book, oldFilePath, newFilePath, targetLibrary); + return true; + } + + /** + * Generates the new file path for a book in a different library + */ + public Path generateNewFilePathForLibrary(BookEntity book, LibraryEntity targetLibrary, String pattern) { + String newRelativePathStr = PathPatternResolver.resolvePattern(book, pattern); + if (newRelativePathStr.startsWith("/") || newRelativePathStr.startsWith("\\")) { + newRelativePathStr = newRelativePathStr.substring(1); + } + + Path targetLibraryRoot = getLibraryRootPath(targetLibrary); + return targetLibraryRoot.resolve(newRelativePathStr).normalize(); + } + + /** + * Gets the root path for a library (uses the first library path) + */ + private Path getLibraryRootPath(LibraryEntity library) { + if (library.getLibraryPaths() == null || library.getLibraryPaths().isEmpty()) { + throw new RuntimeException("Library " + library.getName() + " has no paths configured"); + } + return Paths.get(library.getLibraryPaths().getFirst().getPath()).toAbsolutePath().normalize(); + } + private void moveBookFileAndUpdatePaths(BookEntity book, Path oldFilePath, Path newFilePath) throws IOException { moveFile(oldFilePath, newFilePath); updateBookPaths(book, newFilePath); @@ -190,6 +240,65 @@ public class FileMovingHelper { } } + private void moveBookFileAndUpdatePathsForLibrary(BookEntity book, Path oldFilePath, Path newFilePath, LibraryEntity targetLibrary) throws IOException { + moveFile(oldFilePath, newFilePath); + updateBookPathsForLibrary(book, newFilePath, targetLibrary); + + // Clean up empty directories in source library + // IMPORTANT: We need to collect ALL library roots to avoid deleting any library root directory + try { + Set allLibraryRoots = getAllLibraryRoots(); + deleteEmptyParentDirsUpToLibraryFolders(oldFilePath.getParent(), allLibraryRoots); + } catch (IOException e) { + log.warn("Failed to clean up empty directories after moving book ID {}: {}", book.getId(), e.getMessage()); + } + } + + /** + * Gets all library root paths from all libraries to ensure we never delete any library root + */ + private Set getAllLibraryRoots() { + Set allRoots = new HashSet<>(); + try { + // Get all libraries and collect their root paths + libraryPathRepository.findAll().forEach(libraryPath -> { + try { + Path rootPath = Paths.get(libraryPath.getPath()).toAbsolutePath().normalize(); + allRoots.add(rootPath); + log.debug("Added library root to protection list: {}", rootPath); + } catch (Exception e) { + log.warn("Failed to process library path {}: {}", libraryPath.getPath(), e.getMessage()); + } + }); + } catch (Exception e) { + log.error("Failed to collect all library roots: {}", e.getMessage()); + } + return allRoots; + } + + private void updateBookPathsForLibrary(BookEntity book, Path newFilePath, LibraryEntity targetLibrary) { + String newFileName = newFilePath.getFileName().toString(); + Path targetLibraryRoot = getLibraryRootPath(targetLibrary); + Path newRelativeSubPath = targetLibraryRoot.relativize(newFilePath.getParent()); + String newFileSubPath = newRelativeSubPath.toString().replace('\\', '/'); + + // Find or create the appropriate LibraryPathEntity for the target library + String targetLibraryPath = targetLibrary.getLibraryPaths().getFirst().getPath(); + LibraryPathEntity targetLibraryPathEntity = libraryPathRepository + .findByLibraryIdAndPath(targetLibrary.getId(), targetLibraryPath) + .orElseThrow(() -> new RuntimeException("LibraryPath not found for library " + targetLibrary.getId() + " and path " + targetLibraryPath)); + + // Update only the path-related fields, avoid touching library entity relationships + // that might trigger cascade operations + book.setLibraryPath(targetLibraryPathEntity); // This should update library_path_id + book.setFileSubPath(newFileSubPath); + book.setFileName(newFileName); + + // DO NOT set book.setLibrary(targetLibrary) to avoid cascade issues + log.debug("Updated book {} path references: libraryPathId={}, newPath={}", + book.getId(), targetLibraryPathEntity.getId(), newFilePath); + } + private void moveAdditionalFile(BookEntity book, BookAdditionalFileEntity additionalFile, String pattern, Map fileNameCounter) throws IOException { Path oldAdditionalFilePath = additionalFile.getFullFilePath(); if (!Files.exists(oldAdditionalFilePath)) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/UnifiedFileMoveService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/UnifiedFileMoveService.java index a3b602f28..878125a34 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/UnifiedFileMoveService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/UnifiedFileMoveService.java @@ -1,6 +1,8 @@ package com.adityachandel.booklore.service.file; import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.LibraryEntity; +import com.adityachandel.booklore.repository.LibraryRepository; import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -20,6 +22,7 @@ public class UnifiedFileMoveService { private final FileMovingHelper fileMovingHelper; private final MonitoredFileOperationService monitoredFileOperationService; private final MonitoringRegistrationService monitoringRegistrationService; + private final LibraryRepository libraryRepository; /** * Moves a single book file to match the library's file naming pattern. @@ -80,16 +83,16 @@ public class UnifiedFileMoveService { } /** - * Moves multiple book files in batches with library-level monitoring protection. - * Used for bulk file operations where many files need to be moved. + * Moves multiple book files in batches with cross-library support and library-level monitoring protection. + * Used for bulk file operations where many files need to be moved, potentially across libraries. */ - public void moveBatchBookFiles(List books, BatchMoveCallback callback) { + public void moveBatchBookFiles(List books, Map bookToTargetLibraryMap, BatchMoveCallback callback) { if (books.isEmpty()) { log.debug("No books to move"); return; } - Set libraryIds = new HashSet<>(); + Set allLibraryIds = new HashSet<>(); Map> libraryToRootsMap = new HashMap<>(); // Collect library information for monitoring protection @@ -100,23 +103,39 @@ public class UnifiedFileMoveService { Path oldFilePath = book.getFullFilePath(); if (!Files.exists(oldFilePath)) continue; - Long libraryId = book.getLibraryPath().getLibrary().getId(); - Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); + // Source library + Long sourceLibraryId = book.getLibraryPath().getLibrary().getId(); + Path sourceLibraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); + libraryToRootsMap.computeIfAbsent(sourceLibraryId, k -> new HashSet<>()).add(sourceLibraryRoot); + allLibraryIds.add(sourceLibraryId); - libraryToRootsMap.computeIfAbsent(libraryId, k -> new HashSet<>()).add(libraryRoot); - libraryIds.add(libraryId); + // Target library (if different) + Long targetLibraryId = bookToTargetLibraryMap.get(book.getId()); + if (targetLibraryId != null && !targetLibraryId.equals(sourceLibraryId)) { + LibraryEntity targetLibrary = libraryRepository.findById(targetLibraryId).orElse(null); + if (targetLibrary != null && targetLibrary.getLibraryPaths() != null && !targetLibrary.getLibraryPaths().isEmpty()) { + Path targetLibraryRoot = Paths.get(targetLibrary.getLibraryPaths().getFirst().getPath()).toAbsolutePath().normalize(); + libraryToRootsMap.computeIfAbsent(targetLibraryId, k -> new HashSet<>()).add(targetLibraryRoot); + allLibraryIds.add(targetLibraryId); + } + } } - // Unregister libraries for batch operation + // Unregister all affected libraries for batch operation unregisterLibrariesBatch(libraryToRootsMap); try { + // Small delay to let any pending file watcher events settle + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Interrupted during pre-move delay"); + } + // Process each book for (BookEntity book : books) { if (book.getMetadata() == null) continue; - - String pattern = fileMovingHelper.getFileNamingPattern(book.getLibraryPath().getLibrary()); - if (!fileMovingHelper.hasRequiredPathComponents(book)) continue; Path oldFilePath = book.getFullFilePath(); @@ -128,15 +147,39 @@ public class UnifiedFileMoveService { log.debug("Moving book {}: '{}'", book.getId(), book.getMetadata().getTitle()); try { - boolean moved = fileMovingHelper.moveBookFileIfNeeded(book, pattern); - if (moved) { - log.debug("Book {} moved successfully", book.getId()); - callback.onBookMoved(book); + Long targetLibraryId = bookToTargetLibraryMap.get(book.getId()); + boolean moved = false; + + if (targetLibraryId != null && !targetLibraryId.equals(book.getLibraryPath().getLibrary().getId())) { + // Cross-library move + LibraryEntity targetLibrary = libraryRepository.findById(targetLibraryId).orElse(null); + if (targetLibrary != null) { + moved = fileMovingHelper.moveBookFileToLibrary(book, targetLibrary); + if (moved) { + log.debug("Book {} moved to library {} successfully", book.getId(), targetLibraryId); + } + } else { + log.error("Target library {} not found for book {}", targetLibraryId, book.getId()); + callback.onBookMoveFailed(book, new RuntimeException("Target library not found")); + continue; + } + } else { + // Same library move (existing functionality) + String pattern = fileMovingHelper.getFileNamingPattern(book.getLibraryPath().getLibrary()); + moved = fileMovingHelper.moveBookFileIfNeeded(book, pattern); + if (moved) { + log.debug("Book {} moved within library successfully", book.getId()); + } } - // Move additional files if any - if (book.getAdditionalFiles() != null && !book.getAdditionalFiles().isEmpty()) { - fileMovingHelper.moveAdditionalFiles(book, pattern); + if (moved) { + callback.onBookMoved(book); + + // Move additional files if any + if (book.getAdditionalFiles() != null && !book.getAdditionalFiles().isEmpty()) { + String pattern = fileMovingHelper.getFileNamingPattern(book.getLibraryPath().getLibrary()); + fileMovingHelper.moveAdditionalFiles(book, pattern); + } } } catch (IOException e) { log.error("Move failed for book {}: {}", book.getId(), e.getMessage(), e); @@ -144,7 +187,7 @@ public class UnifiedFileMoveService { } } - // Small delay to let filesystem operations settle + // Longer delay to let filesystem operations settle before re-registering monitoring try { Thread.sleep(1000); } catch (InterruptedException e) { @@ -153,18 +196,27 @@ public class UnifiedFileMoveService { } } finally { - // Re-register libraries + // Re-register all affected libraries registerLibrariesBatch(libraryToRootsMap); } } + // Overloaded method for backward compatibility with same-library moves + public void moveBatchBookFiles(List books, BatchMoveCallback callback) { + moveBatchBookFiles(books, new HashMap<>(), callback); + } + private void unregisterLibrariesBatch(Map> libraryToRootsMap) { log.debug("Unregistering {} libraries for batch move", libraryToRootsMap.size()); for (Map.Entry> entry : libraryToRootsMap.entrySet()) { Long libraryId = entry.getKey(); - monitoringRegistrationService.unregisterLibrary(libraryId); - log.debug("Unregistered library {}", libraryId); + try { + monitoringRegistrationService.unregisterLibrary(libraryId); + log.debug("Unregistered library {}", libraryId); + } catch (Exception e) { + log.warn("Failed to unregister library {}: {}", libraryId, e.getMessage()); + } } } @@ -175,11 +227,28 @@ public class UnifiedFileMoveService { Long libraryId = entry.getKey(); Set libraryRoots = entry.getValue(); - for (Path libraryRoot : libraryRoots) { - if (Files.exists(libraryRoot) && Files.isDirectory(libraryRoot)) { - monitoringRegistrationService.registerLibraryPaths(libraryId, libraryRoot); - log.debug("Re-registered library {} at {}", libraryId, libraryRoot); + // Verify library still exists before re-registering + try { + LibraryEntity library = libraryRepository.findById(libraryId).orElse(null); + if (library == null) { + log.warn("Library {} no longer exists, skipping re-registration", libraryId); + continue; } + + for (Path libraryRoot : libraryRoots) { + if (Files.exists(libraryRoot) && Files.isDirectory(libraryRoot)) { + try { + monitoringRegistrationService.registerLibraryPaths(libraryId, libraryRoot); + log.debug("Re-registered library {} at {}", libraryId, libraryRoot); + } catch (Exception e) { + log.warn("Failed to re-register library {} at {}: {}", libraryId, libraryRoot, e.getMessage()); + } + } else { + log.debug("Library root {} no longer exists or is not a directory, skipping re-registration", libraryRoot); + } + } + } catch (Exception e) { + log.error("Error verifying library {} during re-registration: {}", libraryId, e.getMessage()); } } } diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMoveServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMoveServiceTest.java index 92ddbb11a..5507d9cb0 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMoveServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMoveServiceTest.java @@ -22,6 +22,7 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -83,29 +84,30 @@ class FileMoveServiceTest { Set bookIds = Set.of(1L, 2L); FileMoveRequest request = new FileMoveRequest(); request.setBookIds(bookIds); + request.setMoves(List.of()); List batchBooks = List.of(bookEntity1, bookEntity2); when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100)) .thenReturn(batchBooks); - when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 100, 100)) - .thenReturn(List.of()); + when(bookRepository.save(bookEntity1)).thenReturn(bookEntity1); + when(bookRepository.save(bookEntity2)).thenReturn(bookEntity2); when(bookMapper.toBook(bookEntity1)).thenReturn(book1); when(bookMapper.toBook(bookEntity2)).thenReturn(book2); doAnswer(invocation -> { - UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1); + UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(2); callback.onBookMoved(bookEntity1); callback.onBookMoved(bookEntity2); return null; - }).when(unifiedFileMoveService).moveBatchBookFiles(eq(batchBooks), any()); + }).when(unifiedFileMoveService).moveBatchBookFiles(eq(batchBooks), eq(Map.of()), any()); // When fileMoveService.moveFiles(request); // Then verify(bookQueryService).findWithMetadataByIdsWithPagination(bookIds, 0, 100); - verify(unifiedFileMoveService).moveBatchBookFiles(eq(batchBooks), any()); + verify(unifiedFileMoveService).moveBatchBookFiles(eq(batchBooks), eq(Map.of()), any()); verify(bookRepository).save(bookEntity1); verify(bookRepository).save(bookEntity2); verify(notificationService).sendMessage(eq(Topic.BOOK_METADATA_BATCH_UPDATE), eq(List.of(book1, book2))); @@ -119,38 +121,48 @@ class FileMoveServiceTest { .collect(Collectors.toSet()); FileMoveRequest request = new FileMoveRequest(); request.setBookIds(bookIds); + request.setMoves(List.of()); + + // Create subset for first batch (first 100 items) + Set firstBatchIds = IntStream.rangeClosed(1, 100) + .mapToObj(i -> (long) i) + .collect(Collectors.toSet()); + + // Create subset for second batch (remaining 50 items) + Set secondBatchIds = IntStream.rangeClosed(101, 150) + .mapToObj(i -> (long) i) + .collect(Collectors.toSet()); // First batch - when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100)) + when(bookQueryService.findWithMetadataByIdsWithPagination(firstBatchIds, 0, 100)) .thenReturn(List.of(bookEntity1)); // Second batch - when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 100, 100)) + when(bookQueryService.findWithMetadataByIdsWithPagination(secondBatchIds, 100, 100)) .thenReturn(List.of(bookEntity2)); - // Third batch - empty - when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 200, 100)) - .thenReturn(List.of()); when(book1.getId()).thenReturn(1L); when(book2.getId()).thenReturn(2L); + when(bookRepository.save(bookEntity1)).thenReturn(bookEntity1); + when(bookRepository.save(bookEntity2)).thenReturn(bookEntity2); when(bookMapper.toBook(bookEntity1)).thenReturn(book1); when(bookMapper.toBook(bookEntity2)).thenReturn(book2); doAnswer(invocation -> { - UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1); + UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(2); List books = invocation.getArgument(0); for (BookEntity book : books) { callback.onBookMoved(book); } return null; - }).when(unifiedFileMoveService).moveBatchBookFiles(any(), any()); + }).when(unifiedFileMoveService).moveBatchBookFiles(any(), eq(Map.of()), any()); // When fileMoveService.moveFiles(request); // Then - verify(bookQueryService).findWithMetadataByIdsWithPagination(bookIds, 0, 100); - verify(bookQueryService).findWithMetadataByIdsWithPagination(bookIds, 100, 100); - verify(unifiedFileMoveService, times(2)).moveBatchBookFiles(any(), any()); + verify(bookQueryService).findWithMetadataByIdsWithPagination(firstBatchIds, 0, 100); + verify(bookQueryService).findWithMetadataByIdsWithPagination(secondBatchIds, 100, 100); + verify(unifiedFileMoveService, times(2)).moveBatchBookFiles(any(), eq(Map.of()), any()); verify(bookRepository).save(bookEntity1); verify(bookRepository).save(bookEntity2); verify(notificationService).sendMessage(eq(Topic.BOOK_METADATA_BATCH_UPDATE), eq(List.of(book1, book2))); @@ -162,6 +174,7 @@ class FileMoveServiceTest { Set bookIds = Set.of(1L, 2L); FileMoveRequest request = new FileMoveRequest(); request.setBookIds(bookIds); + request.setMoves(List.of()); when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100)) .thenReturn(List.of()); @@ -171,7 +184,7 @@ class FileMoveServiceTest { // Then verify(bookQueryService).findWithMetadataByIdsWithPagination(bookIds, 0, 100); - verify(unifiedFileMoveService, never()).moveBatchBookFiles(any(), any()); + verify(unifiedFileMoveService, never()).moveBatchBookFiles(any(), any(), any()); verify(bookRepository, never()).save(any()); verify(notificationService, never()).sendMessage(any(), any()); } @@ -182,20 +195,22 @@ class FileMoveServiceTest { Set bookIds = Set.of(1L, 2L); FileMoveRequest request = new FileMoveRequest(); request.setBookIds(bookIds); + request.setMoves(List.of()); List batchBooks = List.of(bookEntity1, bookEntity2); when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100)) .thenReturn(batchBooks); + when(bookRepository.save(bookEntity1)).thenReturn(bookEntity1); + when(bookMapper.toBook(bookEntity1)).thenReturn(book1); + RuntimeException moveException = new RuntimeException("File move failed"); doAnswer(invocation -> { - UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1); + UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(2); callback.onBookMoved(bookEntity1); callback.onBookMoveFailed(bookEntity2, moveException); return null; - }).when(unifiedFileMoveService).moveBatchBookFiles(eq(batchBooks), any()); - - when(bookMapper.toBook(bookEntity1)).thenReturn(book1); + }).when(unifiedFileMoveService).moveBatchBookFiles(eq(batchBooks), eq(Map.of()), any()); // When & Then RuntimeException exception = assertThrows(RuntimeException.class, () -> { @@ -214,13 +229,14 @@ class FileMoveServiceTest { Set bookIds = Set.of(); FileMoveRequest request = new FileMoveRequest(); request.setBookIds(bookIds); + request.setMoves(List.of()); // When fileMoveService.moveFiles(request); // Then: service should not call pagination when bookIds is empty verify(bookQueryService, never()).findWithMetadataByIdsWithPagination(anySet(), anyInt(), anyInt()); - verify(unifiedFileMoveService, never()).moveBatchBookFiles(any(), any()); + verify(unifiedFileMoveService, never()).moveBatchBookFiles(any(), any(), any()); verify(notificationService, never()).sendMessage(any(), any()); } @@ -230,27 +246,27 @@ class FileMoveServiceTest { Set bookIds = Set.of(1L); FileMoveRequest request = new FileMoveRequest(); request.setBookIds(bookIds); + request.setMoves(List.of()); when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100)) .thenReturn(List.of(bookEntity1)); - when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 100, 100)) - .thenReturn(List.of()); when(book1.getId()).thenReturn(1L); + when(bookRepository.save(bookEntity1)).thenReturn(bookEntity1); when(bookMapper.toBook(bookEntity1)).thenReturn(book1); doAnswer(invocation -> { - UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1); + UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(2); callback.onBookMoved(bookEntity1); return null; - }).when(unifiedFileMoveService).moveBatchBookFiles(any(), any()); + }).when(unifiedFileMoveService).moveBatchBookFiles(eq(List.of(bookEntity1)), eq(Map.of()), any()); // When fileMoveService.moveFiles(request); // Then verify(bookQueryService).findWithMetadataByIdsWithPagination(bookIds, 0, 100); - verify(unifiedFileMoveService).moveBatchBookFiles(eq(List.of(bookEntity1)), any()); + verify(unifiedFileMoveService).moveBatchBookFiles(eq(List.of(bookEntity1)), eq(Map.of()), any()); verify(bookRepository).save(bookEntity1); verify(notificationService).sendMessage(eq(Topic.BOOK_METADATA_BATCH_UPDATE), eq(List.of(book1))); } @@ -306,21 +322,22 @@ class FileMoveServiceTest { Set bookIds = Set.of(1L, 2L); FileMoveRequest request = new FileMoveRequest(); request.setBookIds(bookIds); + request.setMoves(List.of()); when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100)) .thenReturn(List.of(bookEntity1, bookEntity2)); - when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 100, 100)) - .thenReturn(List.of()); + when(bookRepository.save(bookEntity1)).thenReturn(bookEntity1); + when(bookRepository.save(bookEntity2)).thenReturn(bookEntity2); when(bookMapper.toBook(bookEntity1)).thenReturn(book1); when(bookMapper.toBook(bookEntity2)).thenReturn(book2); doAnswer(invocation -> { - UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1); + UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(2); callback.onBookMoved(bookEntity1); callback.onBookMoved(bookEntity2); return null; - }).when(unifiedFileMoveService).moveBatchBookFiles(any(), any()); + }).when(unifiedFileMoveService).moveBatchBookFiles(any(), eq(Map.of()), any()); // When fileMoveService.moveFiles(request); @@ -341,16 +358,17 @@ class FileMoveServiceTest { Set bookIds = Set.of(1L, 2L); FileMoveRequest request = new FileMoveRequest(); request.setBookIds(bookIds); + request.setMoves(List.of()); when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100)) .thenReturn(List.of(bookEntity1, bookEntity2)); RuntimeException moveException = new RuntimeException("All moves failed"); doAnswer(invocation -> { - UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1); + UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(2); callback.onBookMoveFailed(bookEntity1, moveException); return null; - }).when(unifiedFileMoveService).moveBatchBookFiles(any(), any()); + }).when(unifiedFileMoveService).moveBatchBookFiles(any(), eq(Map.of()), any()); // When & Then assertThrows(RuntimeException.class, () -> { diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/file/UnifiedFileMoveServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/UnifiedFileMoveServiceTest.java index 6a3a45fd7..8c9c89a05 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/file/UnifiedFileMoveServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/UnifiedFileMoveServiceTest.java @@ -1,16 +1,12 @@ package com.adityachandel.booklore.service.file; -import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; -import com.adityachandel.booklore.model.entity.BookEntity; -import com.adityachandel.booklore.model.entity.BookMetadataEntity; -import com.adityachandel.booklore.model.entity.LibraryEntity; -import com.adityachandel.booklore.model.entity.LibraryPathEntity; +import com.adityachandel.booklore.model.entity.*; +import com.adityachandel.booklore.repository.LibraryRepository; import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; +import org.junit.jupiter.api.io.TempDir; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -19,9 +15,10 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Optional; import static java.util.Collections.singletonList; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -37,6 +34,9 @@ class UnifiedFileMoveServiceTest { @Mock MonitoringRegistrationService monitoringRegistrationService; + @Mock + LibraryRepository libraryRepository; + @InjectMocks UnifiedFileMoveService service; @@ -63,7 +63,6 @@ class UnifiedFileMoveServiceTest { @Test void moveSingleBookFile_skipsWhenNoLibrary() { BookEntity book = new BookEntity(); - // no libraryPath set service.moveSingleBookFile(book); verifyNoInteractions(monitoredFileOperationService); verifyNoInteractions(fileMovingHelper); @@ -124,6 +123,8 @@ class UnifiedFileMoveServiceTest { @Test void moveBatchBookFiles_movesBooks_and_callsCallback_and_reRegistersLibraries() throws Exception { + when(libraryRepository.findById(10L)).thenReturn(Optional.of(library)); + BookEntity b1 = new BookEntity(); b1.setId(11L); b1.setLibraryPath(libraryPath); @@ -182,6 +183,8 @@ class UnifiedFileMoveServiceTest { @Test void moveBatchBookFiles_callsOnBookMoveFailed_onIOException() throws Exception { + when(libraryRepository.findById(10L)).thenReturn(Optional.of(library)); + BookEntity b = new BookEntity(); b.setId(21L); b.setLibraryPath(libraryPath); diff --git a/booklore-ui/src/app/book/components/book-browser/BookDialogHelperService.ts b/booklore-ui/src/app/book/components/book-browser/BookDialogHelperService.ts index fbc83dfba..c29435832 100644 --- a/booklore-ui/src/app/book/components/book-browser/BookDialogHelperService.ts +++ b/booklore-ui/src/app/book/components/book-browser/BookDialogHelperService.ts @@ -73,7 +73,7 @@ export class BookDialogHelperService { }); } - openMultibookMetadataEditerDialog(bookIds: Set): DynamicDialogRef { + openMultibookMetadataEditorDialog(bookIds: Set): DynamicDialogRef { return this.dialogService.open(MultiBookMetadataEditorComponent, { header: 'Bulk Edit Metadata', showHeader: false, @@ -96,12 +96,16 @@ export class BookDialogHelperService { return this.dialogService.open(FileMoverComponent, { header: `Organize Book Files (${count} book${count !== 1 ? 's' : ''})`, showHeader: true, + maximizable: true, modal: true, closable: true, closeOnEscape: false, dismissableMask: false, style: { - width: '90vw' + width: '85vw', + height: '80vh', + maxHeight: '95vh', + maxWidth: '97.5vw' }, data: { bookIds: selectedBooks diff --git a/booklore-ui/src/app/book/components/book-browser/book-browser.component.html b/booklore-ui/src/app/book/components/book-browser/book-browser.component.html index c02649b23..9eb3fd0ba 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-browser.component.html +++ b/booklore-ui/src/app/book/components/book-browser/book-browser.component.html @@ -345,7 +345,7 @@ icon="pi pi-arrows-h" severity="info" (click)="moveFiles()" - pTooltip="Move Books" + pTooltip="Organize Files" tooltipPosition="top"> } diff --git a/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts b/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts index 96b7ae332..ccadbacd4 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts +++ b/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts @@ -558,7 +558,7 @@ export class BookBrowserComponent implements OnInit { } multiBookEditMetadata(): void { - this.dialogHelperService.openMultibookMetadataEditerDialog(this.selectedBooks); + this.dialogHelperService.openMultibookMetadataEditorDialog(this.selectedBooks); } moveFiles() { diff --git a/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.ts b/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.ts index 74b67a3dd..7671c2372 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.ts +++ b/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.ts @@ -26,6 +26,7 @@ import {take, takeUntil} from 'rxjs/operators'; import {readStatusLabels} from '../book-filter/book-filter.component'; import {ResetProgressTypes} from '../../../../shared/constants/reset-progress-type'; import {ReadStatusHelper} from '../../../helpers/read-status.helper'; +import {BookDialogHelperService} from '../BookDialogHelperService'; @Component({ selector: 'app-book-card', @@ -63,6 +64,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy { private router = inject(Router); protected urlHelper = inject(UrlHelperService); private confirmationService = inject(ConfirmationService); + private bookDialogHelperService = inject(BookDialogHelperService); private userPermissions: any; private metadataCenterViewMode: 'route' | 'dialog' = 'route'; @@ -370,6 +372,13 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy { label: 'More Actions', icon: 'pi pi-ellipsis-h', items: [ + { + label: 'Organize File', + icon: 'pi pi-arrows-h', + command: () => { + this.bookDialogHelperService.openFileMoverDialog(new Set([this.book.id])); + } + }, { label: 'Read Status', icon: 'pi pi-book', diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html index 05cf0ac3c..ba0a89600 100644 --- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html +++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html @@ -456,7 +456,7 @@ @if (userState.user!.permissions.canEditMetadata || userState.user!.permissions.admin) { @if (refreshMenuItems$ | async; as refreshItems) { + } } diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.ts b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.ts index f707ba3d2..f1d2acdb8 100644 --- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.ts +++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.ts @@ -37,6 +37,7 @@ import {ProgressSpinner} from 'primeng/progressspinner'; import {TieredMenu} from 'primeng/tieredmenu'; import {AdditionalFileUploaderComponent} from '../../../book/components/additional-file-uploader/additional-file-uploader.component'; import {Image} from 'primeng/image'; +import {BookDialogHelperService} from '../../../book/components/book-browser/BookDialogHelperService'; @Component({ selector: 'app-metadata-viewer', @@ -58,6 +59,8 @@ export class MetadataViewerComponent implements OnInit, OnChanges { protected urlHelper = inject(UrlHelperService); protected userService = inject(UserService); private confirmationService = inject(ConfirmationService); + private bookDialogHelperService = inject(BookDialogHelperService); + private router = inject(Router); private destroyRef = inject(DestroyRef); private dialogRef?: DynamicDialogRef; @@ -112,7 +115,7 @@ export class MetadataViewerComponent implements OnInit, OnChanges { filter((book): book is Book => book !== null), map((book): MenuItem[] => [ { - label: 'Granular Refresh', + label: 'Advanced Fetch', icon: 'pi pi-database', command: () => { this.dialogService.open(MetadataFetchOptionsComponent, { @@ -200,6 +203,13 @@ export class MetadataViewerComponent implements OnInit, OnChanges { }); }, }, + { + label: 'Organize Files', + icon: 'pi pi-arrows-h', + command: () => { + this.openFileMoverDialog(book.id); + }, + }, { label: 'Delete Book', icon: 'pi pi-trash', @@ -803,6 +813,10 @@ export class MetadataViewerComponent implements OnInit, OnChanges { this.editDateFinished = null; } + openFileMoverDialog(bookId: number): void { + this.bookDialogHelperService.openFileMoverDialog(new Set([bookId])); + } + protected readonly ResetProgressTypes = ResetProgressTypes; protected readonly ReadStatus = ReadStatus; } diff --git a/booklore-ui/src/app/utilities/component/file-mover-component/file-mover-component.html b/booklore-ui/src/app/utilities/component/file-mover-component/file-mover-component.html index 23267b9fc..c5fed30cc 100644 --- a/booklore-ui/src/app/utilities/component/file-mover-component/file-mover-component.html +++ b/booklore-ui/src/app/utilities/component/file-mover-component/file-mover-component.html @@ -1,4 +1,4 @@ -
+

@@ -6,16 +6,16 @@

- Your files will be automatically renamed and organized based on your library's naming rules. - Check the preview below to see how your files will look, then click Move Files to organize them. + Your files will be automatically renamed, organized, and can be moved between libraries based on your preferences. + Check the preview below to see how your files will look, select target libraries if needed, then click Move Files to organize them.

- Want to change how files are named? Go to Settings → File Naming Pattern + Want to change how files are named? Go to Settings → File Naming Pattern. Each library can have its own naming pattern!

- This will actually move and rename files on your computer, make sure you're happy with the preview first! + This will actually move and rename files on your computer across different library folders. Make sure you're happy with the preview first!

@@ -52,6 +52,23 @@
+
+
+ + +
+
+
ID Current Path + Target Library New Path @@ -71,13 +89,27 @@ {{ preview.bookId }}

- {{ preview.libraryPathPrefix }}/{{ preview.relativeOriginalPath }} + {{ preview.currentLibraryName }}/{{ preview.relativeOriginalPath }}

+ + + →

- {{ preview.libraryPathPrefix }}/{{ preview.relativeNewPath }} + {{ preview.targetLibraryName }}/{{ preview.relativeNewPath }}

diff --git a/booklore-ui/src/app/utilities/component/file-mover-component/file-mover-component.ts b/booklore-ui/src/app/utilities/component/file-mover-component/file-mover-component.ts index e674d7dd2..3875f104f 100644 --- a/booklore-ui/src/app/utilities/component/file-mover-component/file-mover-component.ts +++ b/booklore-ui/src/app/utilities/component/file-mover-component/file-mover-component.ts @@ -13,11 +13,25 @@ import {Book} from '../../../book/model/book.model'; import {FileMoveRequest, FileOperationsService} from '../../service/file-operations-service'; import {LibraryService} from "../../../book/service/library.service"; import {AppSettingsService} from '../../../core/service/app-settings.service'; +import {Select} from 'primeng/select'; + +interface FilePreview { + bookId: number; + originalPath: string; + relativeOriginalPath: string; + currentLibraryId: number | null; + currentLibraryName: string; + targetLibraryId: number | null; + targetLibraryName: string; + newPath: string; + relativeNewPath: string; + isMoved?: boolean; +} @Component({ selector: 'app-file-mover-component', standalone: true, - imports: [Button, FormsModule, TableModule, Divider], + imports: [Button, FormsModule, TableModule, Divider, Select], templateUrl: './file-mover-component.html', styleUrl: './file-mover-component.scss' }) @@ -44,11 +58,14 @@ export class FileMoverComponent implements OnDestroy { bookIds: Set = new Set(); books: Book[] = []; - filePreviews: { originalPath: string; newPath: string; isMoved?: boolean }[] = []; + availableLibraries: { id: number | null; name: string }[] = []; + filePreviews: FilePreview[] = []; + defaultTargetLibraryId: number | null = null; constructor() { this.bookIds = new Set(this.config.data?.bookIds ?? []); this.books = this.bookService.getBooksByIdsFromState([...this.bookIds]); + this.loadAvailableLibraries(); this.loadDefaultPattern(); } @@ -116,6 +133,16 @@ export class FileMoverComponent implements OnDestroy { }); } + private loadAvailableLibraries(): void { + this.libraryService.libraryState$.pipe( + filter(state => state.loaded && state.libraries != null), + take(1), + takeUntil(this.destroy$) + ).subscribe(state => { + this.availableLibraries = state.libraries?.map(lib => ({id: lib.id ?? null, name: lib.name})) || []; + }); + } + applyPattern(): void { this.filePreviews = this.books.map(book => { const meta = book.metadata!; @@ -124,67 +151,107 @@ export class FileMoverComponent implements OnDestroy { const fileSubPath = book.fileSubPath ? `${book.fileSubPath.replace(/\/+$/g, '')}/` : ''; const relativeOriginalPath = `${fileSubPath}${fileName}`; - const libraryPathPrefix = - book.libraryPath?.id != null - ? this.libraryService.getLibraryPathById(book.libraryPath.id)?.replace(/\/+$/g, '') ?? '' - : ''; - const originalPath = `${libraryPathPrefix}/${relativeOriginalPath}`.replace(/\/\/+/g, '/'); - const bookLibraryId = - book.libraryId ?? - book.libraryPath?.id ?? - (book as any).library?.id ?? - null; - const libraryPattern = this.libraryPatterns.find(p => p.libraryId === bookLibraryId); - const pattern = libraryPattern?.pattern || this.defaultMovePattern; + const currentLibraryId = book.libraryId ?? book.libraryPath?.id ?? (book as any).library?.id ?? null; + const currentLibraryName = this.getLibraryNameById(currentLibraryId); - const values: Record = { - authors: this.sanitize(meta.authors?.join(', ') || 'Unknown Author'), - title: this.sanitize(meta.title || 'Untitled'), - year: this.formatYear(meta.publishedDate), - series: this.sanitize(meta.seriesName || ''), - seriesIndex: this.formatSeriesIndex(meta.seriesNumber ?? undefined), - language: this.sanitize(meta.language || ''), - publisher: this.sanitize(meta.publisher || ''), - isbn: this.sanitize(meta.isbn13 || meta.isbn10 || ''), - currentFilename: this.sanitize(fileName) + // Initially set target library to current library + const targetLibraryId = currentLibraryId; + const targetLibraryName = currentLibraryName; + + const preview: FilePreview = { + bookId: book.id, + originalPath: this.getFullPath(currentLibraryId, relativeOriginalPath), + relativeOriginalPath, + currentLibraryId, + currentLibraryName, + targetLibraryId, + targetLibraryName, + newPath: '', + relativeNewPath: '' }; - let newPath: string; + this.updatePreviewPaths(preview, book); + return preview; + }); + } - if (!pattern?.trim()) { - newPath = `${fileSubPath}${fileName}`; - } else { - newPath = pattern.replace(/<([^<>]+)>/g, (_, block) => { - const placeholders = [...block.matchAll(/{(.*?)}/g)].map(m => m[1]); - const allHaveValues = placeholders.every(key => values[key]?.trim()); - return allHaveValues - ? block.replace(/{(.*?)}/g, (_: string, key: string) => values[key] ?? '') - : ''; - }); + onDefaultLibraryChange(): void { + this.filePreviews.forEach(preview => { + if (!preview.isMoved) { + preview.targetLibraryId = this.defaultTargetLibraryId; + preview.targetLibraryName = this.getLibraryNameById(this.defaultTargetLibraryId); - newPath = newPath.replace(/{(.*?)}/g, (_, key) => values[key] ?? ''); - - if (!newPath.endsWith(extension)) { - newPath += extension; + const book = this.books.find(b => b.id === preview.bookId); + if (book) { + this.updatePreviewPaths(preview, book); } } - - const relativeNewPath = newPath; - const fullNewPath = `${libraryPathPrefix}/${relativeNewPath}`.replace(/\/\/+/g, '/'); - - return { - bookId: book.id, - originalPath, - relativeOriginalPath, - libraryPathPrefix, - newPath: fullNewPath, - relativeNewPath - }; - }); } + onLibraryChange(preview: FilePreview): void { + preview.targetLibraryName = this.getLibraryNameById(preview.targetLibraryId); + const book = this.books.find(b => b.id === preview.bookId); + if (book) { + this.updatePreviewPaths(preview, book); + } + } + + private updatePreviewPaths(preview: FilePreview, book: Book): void { + const meta = book.metadata!; + const fileName = book.fileName ?? ''; + const extension = fileName.match(/\.[^.]+$/)?.[0] ?? ''; + + const libraryPattern = this.libraryPatterns.find(p => p.libraryId === preview.targetLibraryId); + const pattern = libraryPattern?.pattern || this.defaultMovePattern; + + const values: Record = { + authors: this.sanitize(meta.authors?.join(', ') || 'Unknown Author'), + title: this.sanitize(meta.title || 'Untitled'), + year: this.formatYear(meta.publishedDate), + series: this.sanitize(meta.seriesName || ''), + seriesIndex: this.formatSeriesIndex(meta.seriesNumber ?? undefined), + language: this.sanitize(meta.language || ''), + publisher: this.sanitize(meta.publisher || ''), + isbn: this.sanitize(meta.isbn13 || meta.isbn10 || ''), + currentFilename: this.sanitize(fileName) + }; + + let newPath: string; + + if (!pattern?.trim()) { + newPath = fileName; + } else { + newPath = pattern.replace(/<([^<>]+)>/g, (_, block) => { + const placeholders = [...block.matchAll(/{(.*?)}/g)].map(m => m[1]); + const allHaveValues = placeholders.every(key => values[key]?.trim()); + return allHaveValues + ? block.replace(/{(.*?)}/g, (_: string, key: string) => values[key] ?? '') + : ''; + }); + + newPath = newPath.replace(/{(.*?)}/g, (_, key) => values[key] ?? ''); + + if (!newPath.endsWith(extension)) { + newPath += extension; + } + } + + preview.relativeNewPath = newPath; + preview.newPath = this.getFullPath(preview.targetLibraryId, newPath); + } + + private getLibraryNameById(libraryId: number | null): string { + if (libraryId === null) return 'Unknown Library'; + return this.availableLibraries.find(lib => lib.id === libraryId)?.name || 'Unknown Library'; + } + + private getFullPath(libraryId: number | null, relativePath: string): string { + const libraryPath = libraryId ? this.libraryService.getLibraryPathById(libraryId)?.replace(/\/+$/g, '') : ''; + return libraryPath ? `${libraryPath}/${relativePath}`.replace(/\/\/+/g, '/') : relativePath; + } + get movedFileCount(): number { return this.filePreviews.filter(p => p.isMoved).length; } @@ -211,7 +278,11 @@ export class FileMoverComponent implements OnDestroy { this.loading = true; const request: FileMoveRequest = { - bookIds: [...this.bookIds] + bookIds: [...this.bookIds], + moves: this.filePreviews.map(preview => ({ + bookId: preview.bookId, + targetLibraryId: preview.targetLibraryId + })) }; this.fileOperationsService.moveFiles(request).pipe( diff --git a/booklore-ui/src/app/utilities/service/file-operations-service.ts b/booklore-ui/src/app/utilities/service/file-operations-service.ts index 607e969b8..1b0e9697a 100644 --- a/booklore-ui/src/app/utilities/service/file-operations-service.ts +++ b/booklore-ui/src/app/utilities/service/file-operations-service.ts @@ -5,6 +5,10 @@ import {API_CONFIG} from '../../config/api-config'; export interface FileMoveRequest { bookIds: number[]; + moves: { + bookId: number; + targetLibraryId: number | null; + }[]; } @Injectable({