Add ability to move books across libraries (#1210)

This commit is contained in:
Aditya Chandel
2025-09-25 20:47:12 -06:00
committed by GitHub
parent b510f4cf3f
commit f82b22145c
17 changed files with 583 additions and 148 deletions

View File

@@ -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<Long> bookIds;
private List<Move> moves;
@Data
public static class Move {
private Long bookId;
private Long targetLibraryId;
}
}

View File

@@ -174,5 +174,8 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, 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);
}

View File

@@ -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<LibraryPathEntity, Long> {
Optional<LibraryPathEntity> findByLibraryIdAndPath(Long libraryId, String path);
}

View File

@@ -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<Long> bookIds = request.getBookIds();
log.info("Moving {} books in batches of {}", bookIds.size(), BATCH_SIZE);
List<Long> bookIds = request.getBookIds().stream().toList();
List<FileMoveRequest.Move> 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<Long, Long> bookToTargetLibraryMap = moves.stream()
.collect(Collectors.toMap(
FileMoveRequest.Move::getBookId,
FileMoveRequest.Move::getTargetLibraryId
));
Map<Long, List<Book>> libraryRemovals = new HashMap<>();
Map<Long, List<Book>> libraryAdditions = new HashMap<>();
List<Book> 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<BookEntity> batchBooks = bookQueryService.findWithMetadataByIdsWithPagination(bookIds, offset, BATCH_SIZE);
List<Long> batchBookIds = bookIds.subList(offset, Math.min(offset + BATCH_SIZE, bookIds.size()));
Set<Long> batchBookIdSet = new HashSet<>(batchBookIds);
List<BookEntity> batchBooks = bookQueryService.findWithMetadataByIdsWithPagination(batchBookIdSet, offset, BATCH_SIZE);
if (batchBooks.isEmpty()) {
log.info("No more books at offset {}", offset);
break;
}
List<Book> batchUpdatedBooks = processBookChunk(batchBooks);
List<Book> 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<Book> processBookChunk(List<BookEntity> books) {
private List<Book> processBookChunk(List<BookEntity> books, Map<Long, Long> bookToTargetLibraryMap, Map<Long, List<Book>> libraryRemovals, Map<Long, List<Book>> libraryAdditions) {
List<Book> updatedBooks = new ArrayList<>();
unifiedFileMoveService.moveBatchBookFiles(books, new UnifiedFileMoveService.BatchMoveCallback() {
Map<Long, Long> 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<Long, List<Book>> libraryRemovals, Map<Long, List<Book>> libraryAdditions) {
log.info("Sending cross-library move notifications: {} removals, {} additions",
libraryRemovals.size(), libraryAdditions.size());
for (Map.Entry<Long, List<Book>> entry : libraryRemovals.entrySet()) {
List<Long> 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<Long, List<Book>> entry : libraryAdditions.entrySet()) {
List<Book> 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);
}
}
}
}

View File

@@ -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<Path> 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<Path> getAllLibraryRoots() {
Set<Path> 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<String, Integer> fileNameCounter) throws IOException {
Path oldAdditionalFilePath = additionalFile.getFullFilePath();
if (!Files.exists(oldAdditionalFilePath)) {

View File

@@ -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<BookEntity> books, BatchMoveCallback callback) {
public void moveBatchBookFiles(List<BookEntity> books, Map<Long, Long> bookToTargetLibraryMap, BatchMoveCallback callback) {
if (books.isEmpty()) {
log.debug("No books to move");
return;
}
Set<Long> libraryIds = new HashSet<>();
Set<Long> allLibraryIds = new HashSet<>();
Map<Long, Set<Path>> 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<BookEntity> books, BatchMoveCallback callback) {
moveBatchBookFiles(books, new HashMap<>(), callback);
}
private void unregisterLibrariesBatch(Map<Long, Set<Path>> libraryToRootsMap) {
log.debug("Unregistering {} libraries for batch move", libraryToRootsMap.size());
for (Map.Entry<Long, Set<Path>> 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<Path> 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());
}
}
}

View File

@@ -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<Long> bookIds = Set.of(1L, 2L);
FileMoveRequest request = new FileMoveRequest();
request.setBookIds(bookIds);
request.setMoves(List.of());
List<BookEntity> 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<Long> firstBatchIds = IntStream.rangeClosed(1, 100)
.mapToObj(i -> (long) i)
.collect(Collectors.toSet());
// Create subset for second batch (remaining 50 items)
Set<Long> 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<BookEntity> 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<Long> 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<Long> bookIds = Set.of(1L, 2L);
FileMoveRequest request = new FileMoveRequest();
request.setBookIds(bookIds);
request.setMoves(List.of());
List<BookEntity> 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<Long> 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<Long> 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<Long> 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<Long> 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, () -> {

View File

@@ -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);

View File

@@ -73,7 +73,7 @@ export class BookDialogHelperService {
});
}
openMultibookMetadataEditerDialog(bookIds: Set<number>): DynamicDialogRef {
openMultibookMetadataEditorDialog(bookIds: Set<number>): 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

View File

@@ -345,7 +345,7 @@
icon="pi pi-arrows-h"
severity="info"
(click)="moveFiles()"
pTooltip="Move Books"
pTooltip="Organize Files"
tooltipPosition="top">
</p-button>
}

View File

@@ -558,7 +558,7 @@ export class BookBrowserComponent implements OnInit {
}
multiBookEditMetadata(): void {
this.dialogHelperService.openMultibookMetadataEditerDialog(this.selectedBooks);
this.dialogHelperService.openMultibookMetadataEditorDialog(this.selectedBooks);
}
moveFiles() {

View File

@@ -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',

View File

@@ -456,7 +456,7 @@
@if (userState.user!.permissions.canEditMetadata || userState.user!.permissions.admin) {
@if (refreshMenuItems$ | async; as refreshItems) {
<p-splitbutton
[label]="isAutoFetching ? 'Fetching...' : 'Auto Fetch'"
[label]="isAutoFetching ? 'Fetching...' : 'Fetch Metadata'"
[icon]="isAutoFetching ? 'pi pi-spin pi-spinner' : 'pi pi-bolt'"
[outlined]="true"
[model]="refreshItems"
@@ -470,7 +470,7 @@
}
@if (userState.user!.permissions.canDeleteBook || userState.user!.permissions.admin) {
@if (otherItems$ | async; as otherItems) {
<p-button icon="pi pi-ellipsis-v" outlined severity="danger" (click)="entitymenu.toggle($event)"></p-button>
<p-button icon="pi pi-ellipsis-v" outlined severity="primary" (click)="entitymenu.toggle($event)"></p-button>
<p-tieredMenu #entitymenu [model]="otherItems" [popup]="true" appendTo="body"></p-tieredMenu>
}
}

View File

@@ -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;
}

View File

@@ -1,4 +1,4 @@
<div class="p-fluid px-4 py-2 rounded-xl flex flex-col h-[800px]">
<div class="p-fluid px-4 py-2 rounded-xl flex flex-col h-full">
<div class="px-4 mx-2 mb-4 py-3 rounded-lg border border-[var(--border-color)] transition-all">
<p class="text-[var(--primary-color)] font-semibold mb-1">
@@ -6,16 +6,16 @@
</p>
<div class="text-sm text-gray-300 space-y-3">
<p>
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 <span class="font-semibold text-green-400">Move Files</span> 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 <span class="font-semibold text-green-400">Move Files</span> to organize them.
</p>
<p>
<i class="pi pi-lightbulb text-blue-400 mr-1"></i>
<span class="text-blue-300">Want to change how files are named? Go to <strong>Settings → File Naming Pattern</strong></span>
<span class="text-blue-300">Want to change how files are named? Go to <strong>Settings → File Naming Pattern</strong>. Each library can have its own naming pattern!</span>
</p>
<p class="text-yellow-500 font-medium flex items-center gap-2">
<i class="pi pi-exclamation-triangle text-yellow-400"></i>
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!
</p>
</div>
</div>
@@ -52,6 +52,23 @@
</div>
</div>
<div class="flex gap-4 mb-4 px-2 items-center">
<div class="flex items-center gap-3">
<label class="text-sm font-medium text-gray-300">Set all files to:</label>
<p-select
[options]="availableLibraries"
[(ngModel)]="defaultTargetLibraryId"
optionLabel="name"
optionValue="id"
placeholder="Select default library"
(onChange)="onDefaultLibraryChange()"
class="min-w-[200px]"
size="small"
appendTo="body"
></p-select>
</div>
</div>
<div class="flex-grow overflow-y-auto">
<p-table
[value]="filePreviews"
@@ -62,6 +79,7 @@
<tr>
<th>ID</th>
<th>Current Path</th>
<th>Target Library</th>
<th></th>
<th>New Path</th>
</tr>
@@ -71,13 +89,27 @@
<td class="text-center">{{ preview.bookId }}</td>
<td class="w-auto">
<p>
<span class="text-gray-400 select-none">{{ preview.libraryPathPrefix }}/</span><span class="text-gray-200">{{ preview.relativeOriginalPath }}</span>
<span class="text-gray-400 select-none">{{ preview.currentLibraryName }}/</span><span class="text-gray-200">{{ preview.relativeOriginalPath }}</span>
</p>
</td>
<td class="w-auto">
<p-select
[options]="availableLibraries"
[(ngModel)]="preview.targetLibraryId"
optionLabel="name"
optionValue="id"
placeholder="Select Library"
(onChange)="onLibraryChange(preview)"
[disabled]="preview.isMoved"
class="w-full"
appendTo="body"
size="small"
></p-select>
</td>
<td class="w-auto text-[var(--primary-color)] text-center"></td>
<td class="w-auto">
<p>
<span class="text-gray-400 select-none">{{ preview.libraryPathPrefix }}/</span><span class="text-gray-200">{{ preview.relativeNewPath }}</span>
<span class="text-gray-400 select-none">{{ preview.targetLibraryName }}/</span><span class="text-gray-200">{{ preview.relativeNewPath }}</span>
</p>
</td>
<td class="w-auto text-center">

View File

@@ -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<number> = 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<string, string> = {
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<string, string> = {
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(

View File

@@ -5,6 +5,10 @@ import {API_CONFIG} from '../../config/api-config';
export interface FileMoveRequest {
bookIds: number[];
moves: {
bookId: number;
targetLibraryId: number | null;
}[];
}
@Injectable({