mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-01-05 16:49:46 -06:00
Add ability to move books across libraries (#1210)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, () -> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -345,7 +345,7 @@
|
||||
icon="pi pi-arrows-h"
|
||||
severity="info"
|
||||
(click)="moveFiles()"
|
||||
pTooltip="Move Books"
|
||||
pTooltip="Organize Files"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
}
|
||||
|
||||
@@ -558,7 +558,7 @@ export class BookBrowserComponent implements OnInit {
|
||||
}
|
||||
|
||||
multiBookEditMetadata(): void {
|
||||
this.dialogHelperService.openMultibookMetadataEditerDialog(this.selectedBooks);
|
||||
this.dialogHelperService.openMultibookMetadataEditorDialog(this.selectedBooks);
|
||||
}
|
||||
|
||||
moveFiles() {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -5,6 +5,10 @@ import {API_CONFIG} from '../../config/api-config';
|
||||
|
||||
export interface FileMoveRequest {
|
||||
bookIds: number[];
|
||||
moves: {
|
||||
bookId: number;
|
||||
targetLibraryId: number | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
|
||||
Reference in New Issue
Block a user