mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 22:28:11 -05:00
Book additional files support library (3/3) (#872)
* feat(api/model): add new entity BookAdditionalFileEntity * feat(api/db): add book additional file repository * feat(api/db): add migration * test(api/db): add book additional file repository test * test(api/db): add book additional file repository tests for hash uniqueness * feat(api/domain): add support to additional file model * feat(api): add additional files controller * refactor(api): move addAdditionalFile to FileUploadService as uploadAdditionalFile method * feat(service): search book by additional file * feat(services): process deleted additional files with ability to promote alternative formats to book instead of deleting them * refactor(util): use common code to resolve patter for entity and domain object * feat(service): move additional files * test(service): test move additional files along with book itself * feat(ui/domain): add alternativeFormats and supplementaryFiles to book model * feat(ui/domain): add download additional file method to book service * refactor(api/domain): extract FileInfo interface with common fields Allow to share the same interface thet is implemented by AdditionFile and Book * feat(ui): show multiple download options * feat(ui/domain): add delete additional file method to book service * feat(ui): add delete additional file ui * feat(ui): add additional-file-uploader.component * feat(ui/domain): add uploadAdditionalFile to the service * feat(ui): add Upload File menu item * feat(ui): show supplementary files in download menu item * feat(ui): show supplementary files in delete file menu item * feat(ui): book card allow to select single file to download or delete * feat(api/domain): add scan mode and default book format to the library * feat(ui): select scan mode and default book format * feat(api): create/update library settings * refactor(services): get processor based on scan mode * feat(services): read all files if processor supports supplimentary files * fix(services): detectNewBookPaths should check additional files as well * feat(services): implement folder as book file processor * test(service): add tests for FolderAsBookFileProcessor * feat(services): allow follow links for search library files * test(library): add tests for folder as book file processor * refactor(library): use Path instead of String for directory path * feat(library): sort directories before processing * feat(library): reuse library file book type * fix(library): do not add additional format if it is used in another book * test(library): test same hashes files * refactor(library): simplify FolderAsBookFileProcessor * test(library): add test with deep folder structure * test(library): add additional files to existing book --------- Co-authored-by: Aditya Chandel <8075870+adityachandelgit@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
f725ececf5
commit
694ee11540
@@ -1,5 +1,7 @@
|
||||
package com.adityachandel.booklore.model.dto;
|
||||
|
||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import com.adityachandel.booklore.model.enums.LibraryScanMode;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -17,5 +19,7 @@ public class Library {
|
||||
private String fileNamingPattern;
|
||||
private boolean watch;
|
||||
private List<LibraryPath> paths;
|
||||
private LibraryScanMode scanMode;
|
||||
private BookFileType defaultBookFormat;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.adityachandel.booklore.model.dto.request;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.LibraryPath;
|
||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import com.adityachandel.booklore.model.enums.LibraryScanMode;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
@@ -20,4 +22,6 @@ public class CreateLibraryRequest {
|
||||
@NotEmpty
|
||||
private List<LibraryPath> paths;
|
||||
private boolean watch;
|
||||
private LibraryScanMode scanMode;
|
||||
private BookFileType defaultBookFormat;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.adityachandel.booklore.model.entity;
|
||||
|
||||
import com.adityachandel.booklore.convertor.SortConverter;
|
||||
import com.adityachandel.booklore.model.dto.Sort;
|
||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import com.adityachandel.booklore.model.enums.LibraryScanMode;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
@@ -40,4 +42,12 @@ public class LibraryEntity {
|
||||
|
||||
@Column(name = "file_naming_pattern")
|
||||
private String fileNamingPattern;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "scan_mode", nullable = false)
|
||||
private LibraryScanMode scanMode = LibraryScanMode.FILE_AS_BOOK;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "default_book_format")
|
||||
private BookFileType defaultBookFormat;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.adityachandel.booklore.model.enums;
|
||||
|
||||
public enum LibraryScanMode {
|
||||
FILE_AS_BOOK,
|
||||
FOLDER_AS_BOOK
|
||||
}
|
||||
@@ -18,14 +18,21 @@ import java.util.List;
|
||||
|
||||
import static com.adityachandel.booklore.model.websocket.LogNotification.createLogNotification;
|
||||
|
||||
import com.adityachandel.booklore.model.enums.LibraryScanMode;
|
||||
|
||||
@AllArgsConstructor
|
||||
@Component
|
||||
@Slf4j
|
||||
public class FileAsBookProcessor implements LibraryFileProcessor {
|
||||
|
||||
|
||||
private final NotificationService notificationService;
|
||||
private final BookFileProcessorRegistry processorRegistry;
|
||||
|
||||
@Override
|
||||
public LibraryScanMode getScanMode() {
|
||||
return LibraryScanMode.FILE_AS_BOOK;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void processLibraryFiles(List<LibraryFile> libraryFiles, LibraryEntity libraryEntity) {
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package com.adityachandel.booklore.service.library;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.Book;
|
||||
import com.adityachandel.booklore.model.dto.settings.LibraryFile;
|
||||
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.model.enums.AdditionalFileType;
|
||||
import com.adityachandel.booklore.model.enums.BookFileExtension;
|
||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import com.adityachandel.booklore.model.enums.LibraryScanMode;
|
||||
import com.adityachandel.booklore.repository.BookAdditionalFileRepository;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
import com.adityachandel.booklore.service.FileFingerprint;
|
||||
import com.adityachandel.booklore.service.NotificationService;
|
||||
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor;
|
||||
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry;
|
||||
import com.adityachandel.booklore.util.FileUtils;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
@Slf4j
|
||||
public class FolderAsBookFileProcessor implements LibraryFileProcessor {
|
||||
|
||||
private final BookRepository bookRepository;
|
||||
private final BookAdditionalFileRepository bookAdditionalFileRepository;
|
||||
private final NotificationService notificationService;
|
||||
private final BookFileProcessorRegistry bookFileProcessorRegistry;
|
||||
|
||||
@Override
|
||||
public LibraryScanMode getScanMode() {
|
||||
return LibraryScanMode.FOLDER_AS_BOOK;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsSupplementaryFiles() {
|
||||
// This processor supports supplementary files, as it processes all files in the folder.
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processLibraryFiles(List<LibraryFile> libraryFiles, LibraryEntity libraryEntity) {
|
||||
// Group files by their directory path
|
||||
Map<Path, List<LibraryFile>> filesByDirectory = libraryFiles.stream()
|
||||
.collect(Collectors.groupingBy(libraryFile -> libraryFile.getFullPath().getParent()));
|
||||
|
||||
log.info("Processing {} directories with {} total files for library: {}",
|
||||
filesByDirectory.size(), libraryFiles.size(), libraryEntity.getName());
|
||||
|
||||
// Process each directory
|
||||
var sortedDirectories = filesByDirectory.entrySet()
|
||||
.stream()
|
||||
.sorted(Map.Entry.comparingByKey())
|
||||
.toList();
|
||||
for (Map.Entry<Path, List<LibraryFile>> entry : sortedDirectories) {
|
||||
Path directoryPath = entry.getKey();
|
||||
List<LibraryFile> filesInDirectory = entry.getValue();
|
||||
|
||||
log.debug("Processing directory: {} with {} files", directoryPath, filesInDirectory.size());
|
||||
processDirectory(directoryPath, filesInDirectory, libraryEntity);
|
||||
}
|
||||
}
|
||||
|
||||
private void processDirectory(Path directoryPath, List<LibraryFile> filesInDirectory, LibraryEntity libraryEntity) {
|
||||
var bookCreationResult = getOrCreateBookInDirectory(directoryPath, filesInDirectory, libraryEntity);
|
||||
if (bookCreationResult.bookEntity.isEmpty()) {
|
||||
log.warn("No book created for directory: {}", directoryPath);
|
||||
return;
|
||||
}
|
||||
|
||||
processAdditionalFiles(bookCreationResult.bookEntity.get(), bookCreationResult.remainingFiles);
|
||||
}
|
||||
|
||||
private GetOrCreateBookResult getOrCreateBookInDirectory(Path directoryPath, List<LibraryFile> filesInDirectory, LibraryEntity libraryEntity) {
|
||||
var existingBook = findExistingBookInDirectory(directoryPath, libraryEntity);
|
||||
if (existingBook.isPresent()) {
|
||||
log.debug("Found existing book in directory {}: {}", directoryPath, existingBook.get().getFileName());
|
||||
return new GetOrCreateBookResult(existingBook, filesInDirectory);
|
||||
}
|
||||
|
||||
// No existing book, check parent directories
|
||||
Optional<BookEntity> parentBook = findBookInParentDirectories(directoryPath, libraryEntity);
|
||||
if (parentBook.isPresent()) {
|
||||
log.debug("Found parent book for directory {}: {}", directoryPath, parentBook.get().getFileName());
|
||||
return new GetOrCreateBookResult(parentBook, filesInDirectory);
|
||||
}
|
||||
|
||||
log.debug("No existing book found, creating new book from directory: {}", directoryPath);
|
||||
Optional<CreateBookResult> newBook = createNewBookFromDirectory(directoryPath, filesInDirectory, libraryEntity);
|
||||
if (newBook.isPresent()) {
|
||||
log.info("Created new book: {}", newBook.get().bookEntity.getFileName());
|
||||
var remainingFiles = filesInDirectory.stream()
|
||||
.filter(file -> !file.equals(newBook.get().libraryFile))
|
||||
.toList();
|
||||
return new GetOrCreateBookResult(Optional.of(newBook.get().bookEntity), remainingFiles);
|
||||
} else {
|
||||
log.warn("Failed to create book from directory: {}", directoryPath);
|
||||
return new GetOrCreateBookResult(Optional.empty(), filesInDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<BookEntity> findExistingBookInDirectory(Path directoryPath, LibraryEntity libraryEntity) {
|
||||
// Find books in all library paths for this library
|
||||
return libraryEntity.getLibraryPaths().stream()
|
||||
.flatMap(libPath -> {
|
||||
String filesSearchPath = Path.of(libPath.getPath())
|
||||
.relativize(directoryPath)
|
||||
.toString()
|
||||
.replace("\\", "/");
|
||||
return bookRepository
|
||||
.findAllByLibraryPathIdAndFileSubPathStartingWith(libPath.getId(), filesSearchPath)
|
||||
.stream();
|
||||
})
|
||||
.filter(book -> book.getFullFilePath().getParent().equals(directoryPath))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
private Optional<BookEntity> findBookInParentDirectories(Path directoryPath, LibraryEntity libraryEntity) {
|
||||
Path parent = directoryPath.getParent();
|
||||
LibraryPathEntity directoryLibraryPathEntity = libraryEntity.getLibraryPaths().stream()
|
||||
.filter(libPath -> directoryPath.startsWith(libPath.getPath()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("No library path found for directory: " + directoryPath));
|
||||
Path directoryLibraryPath = Path.of(directoryLibraryPathEntity.getPath());
|
||||
|
||||
while (parent != null) {
|
||||
final String parentPath = directoryLibraryPath
|
||||
.relativize(parent)
|
||||
.toString()
|
||||
.replace("\\", "/");
|
||||
|
||||
Optional<BookEntity> parentBook =
|
||||
bookRepository.findAllByLibraryPathIdAndFileSubPathStartingWith(
|
||||
directoryLibraryPathEntity.getId(), parentPath).stream()
|
||||
.filter(book -> book.getFileSubPath().equals(parentPath))
|
||||
.findFirst();
|
||||
if (parentBook.isPresent()) {
|
||||
return parentBook;
|
||||
}
|
||||
parent = parent.getParent();
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private Optional<CreateBookResult> createNewBookFromDirectory(Path directoryPath, List<LibraryFile> filesInDirectory, LibraryEntity libraryEntity) {
|
||||
// Find the best candidate for the main book file
|
||||
Optional<LibraryFile> mainBookFile = findBestMainBookFile(filesInDirectory, libraryEntity);
|
||||
|
||||
if (mainBookFile.isEmpty()) {
|
||||
log.debug("No suitable book file found in directory: {}", directoryPath);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
LibraryFile bookFile = mainBookFile.get();
|
||||
|
||||
try {
|
||||
log.info("Creating new book from file: {}", bookFile.getFileName());
|
||||
|
||||
// Create the main book
|
||||
BookFileProcessor processor = bookFileProcessorRegistry.getProcessorOrThrow(bookFile.getBookFileType());
|
||||
Book book = processor.processFile(bookFile);
|
||||
|
||||
if (book != null) {
|
||||
// Send notifications
|
||||
notificationService.sendMessage(
|
||||
com.adityachandel.booklore.model.websocket.Topic.BOOK_ADD,
|
||||
book
|
||||
);
|
||||
notificationService.sendMessage(
|
||||
com.adityachandel.booklore.model.websocket.Topic.LOG,
|
||||
com.adityachandel.booklore.model.websocket.LogNotification.createLogNotification(
|
||||
"Book added: " + book.getFileName()
|
||||
)
|
||||
);
|
||||
|
||||
// Find the created book entity
|
||||
BookEntity bookEntity = bookRepository.getReferenceById(book.getId());
|
||||
if (bookEntity.getFullFilePath().equals(bookFile.getFullPath())) {
|
||||
log.info("Successfully created new book: {}", bookEntity.getFileName());
|
||||
} else {
|
||||
log.warn("Found duplicate book with different path: {} vs {}",
|
||||
bookEntity.getFullFilePath(), bookFile.getFullPath());
|
||||
}
|
||||
|
||||
return Optional.of(new CreateBookResult(bookEntity, bookFile));
|
||||
} else {
|
||||
log.warn("Book processor returned null for file: {}", bookFile.getFileName());
|
||||
|
||||
notificationService.sendMessage(
|
||||
com.adityachandel.booklore.model.websocket.Topic.LOG,
|
||||
com.adityachandel.booklore.model.websocket.LogNotification.createLogNotification(
|
||||
"Failed to create book from file: " + bookFile.getFileName()
|
||||
)
|
||||
);
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error processing book file {}: {}", bookFile.getFileName(), e.getMessage(), e);
|
||||
|
||||
notificationService.sendMessage(
|
||||
com.adityachandel.booklore.model.websocket.Topic.LOG,
|
||||
com.adityachandel.booklore.model.websocket.LogNotification.createLogNotification(
|
||||
"Error processing book file: " + bookFile.getFileName() + " - " + e.getMessage()
|
||||
)
|
||||
);
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<LibraryFile> findBestMainBookFile(List<LibraryFile> filesInDirectory, LibraryEntity libraryEntity) {
|
||||
var defaultBookFormat = libraryEntity.getDefaultBookFormat();
|
||||
return filesInDirectory.stream()
|
||||
.filter(f -> f.getBookFileType() != null)
|
||||
.min(Comparator.comparingInt(f -> {
|
||||
BookFileType bookFileType = f.getBookFileType();
|
||||
return bookFileType == defaultBookFormat
|
||||
? -1 // Prefer the default format
|
||||
: bookFileType.ordinal();
|
||||
}));
|
||||
}
|
||||
|
||||
private void processAdditionalFiles(BookEntity existingBook, List<LibraryFile> filesInDirectory) {
|
||||
for (LibraryFile file : filesInDirectory) {
|
||||
Optional<BookFileExtension> extension = BookFileExtension.fromFileName(file.getFileName());
|
||||
AdditionalFileType fileType = extension.isPresent() ?
|
||||
AdditionalFileType.ALTERNATIVE_FORMAT : AdditionalFileType.SUPPLEMENTARY;
|
||||
|
||||
createAdditionalFileIfNotExists(existingBook, file, fileType);
|
||||
}
|
||||
}
|
||||
|
||||
private void createAdditionalFileIfNotExists(BookEntity bookEntity, LibraryFile file, AdditionalFileType fileType) {
|
||||
// Check if an additional file already exists
|
||||
Optional<BookAdditionalFileEntity> existingFile = bookAdditionalFileRepository
|
||||
.findByLibraryPath_IdAndFileSubPathAndFileName(
|
||||
file.getLibraryPathEntity().getId(), file.getFileSubPath(), file.getFileName());
|
||||
|
||||
if (existingFile.isPresent()) {
|
||||
log.debug("Additional file already exists: {}", file.getFileName());
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new additional file
|
||||
String hash = FileFingerprint.generateHash(file.getFullPath());
|
||||
BookAdditionalFileEntity additionalFile = BookAdditionalFileEntity.builder()
|
||||
.book(bookEntity)
|
||||
.fileName(file.getFileName())
|
||||
.fileSubPath(file.getFileSubPath())
|
||||
.additionalFileType(fileType)
|
||||
.fileSizeKb(FileUtils.getFileSizeInKb(file.getFullPath()))
|
||||
.initialHash(hash)
|
||||
.currentHash(hash)
|
||||
.addedOn(java.time.Instant.now())
|
||||
.build();
|
||||
|
||||
try {
|
||||
log.debug("Creating additional file: {} (type: {})", file.getFileName(), fileType);
|
||||
bookAdditionalFileRepository.save(additionalFile);
|
||||
|
||||
log.debug("Successfully created additional file: {}", file.getFileName());
|
||||
} catch (Exception e) {
|
||||
// Remove an additional file from the book entity if its creation fails
|
||||
bookEntity.getAdditionalFiles().removeIf(a -> a.equals(additionalFile));
|
||||
|
||||
log.error("Error creating additional file {}: {}", file.getFileName(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public record GetOrCreateBookResult(Optional<BookEntity> bookEntity, List<LibraryFile> remainingFiles) {}
|
||||
|
||||
public record CreateBookResult(BookEntity bookEntity, LibraryFile libraryFile) {}
|
||||
|
||||
}
|
||||
@@ -5,6 +5,18 @@ import com.adityachandel.booklore.model.entity.LibraryEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.adityachandel.booklore.model.enums.LibraryScanMode;
|
||||
|
||||
public interface LibraryFileProcessor {
|
||||
LibraryScanMode getScanMode();
|
||||
void processLibraryFiles(List<LibraryFile> libraryFiles, LibraryEntity libraryEntity);
|
||||
|
||||
/**
|
||||
* Indicates whether this processor supports supplementary files (any file type)
|
||||
* in addition to book files.
|
||||
* @return true if supplementary files are supported, false otherwise
|
||||
*/
|
||||
default boolean supportsSupplementaryFiles() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,25 @@
|
||||
package com.adityachandel.booklore.service.library;
|
||||
|
||||
import com.adityachandel.booklore.model.entity.LibraryEntity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import com.adityachandel.booklore.model.enums.LibraryScanMode;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
// Removed unused imports
|
||||
@AllArgsConstructor
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
public class LibraryFileProcessorRegistry {
|
||||
|
||||
private final FileAsBookProcessor fileAsBookProcessor;
|
||||
private final Map<LibraryScanMode, LibraryFileProcessor> processorMap;
|
||||
|
||||
public LibraryFileProcessor getProcessor(LibraryEntity library) {
|
||||
return fileAsBookProcessor;
|
||||
public LibraryFileProcessorRegistry(List<LibraryFileProcessor> processors) {
|
||||
this.processorMap = processors.stream()
|
||||
.collect(Collectors.toMap(LibraryFileProcessor::getScanMode, Function.identity()));
|
||||
}
|
||||
|
||||
public LibraryFileProcessor getProcessor(LibraryEntity libraryEntity) {
|
||||
return processorMap.get(libraryEntity.getScanMode());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileVisitOption;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
@@ -49,8 +50,8 @@ public class LibraryProcessingService {
|
||||
public void processLibrary(long libraryId) throws IOException {
|
||||
LibraryEntity libraryEntity = libraryRepository.findById(libraryId).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId));
|
||||
notificationService.sendMessage(Topic.LOG, createLogNotification("Started processing library: " + libraryEntity.getName()));
|
||||
List<LibraryFile> libraryFiles = getLibraryFiles(libraryEntity);
|
||||
LibraryFileProcessor processor = fileProcessorRegistry.getProcessor(libraryEntity);
|
||||
List<LibraryFile> libraryFiles = getLibraryFiles(libraryEntity, processor);
|
||||
processor.processLibraryFiles(libraryFiles, libraryEntity);
|
||||
notificationService.sendMessage(Topic.LOG, createLogNotification("Finished processing library: " + libraryEntity.getName()));
|
||||
}
|
||||
@@ -59,7 +60,8 @@ public class LibraryProcessingService {
|
||||
public void rescanLibrary(long libraryId) throws IOException {
|
||||
LibraryEntity libraryEntity = libraryRepository.findById(libraryId).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId));
|
||||
notificationService.sendMessage(Topic.LOG, createLogNotification("Started refreshing library: " + libraryEntity.getName()));
|
||||
List<LibraryFile> libraryFiles = getLibraryFiles(libraryEntity);
|
||||
LibraryFileProcessor processor = fileProcessorRegistry.getProcessor(libraryEntity);
|
||||
List<LibraryFile> libraryFiles = getLibraryFiles(libraryEntity, processor);
|
||||
List<Long> additionalFileIds = detectDeletedAdditionalFiles(libraryFiles, libraryEntity);
|
||||
if (!additionalFileIds.isEmpty()) {
|
||||
log.info("Detected {} removed additional files in library: {}", additionalFileIds.size(), libraryEntity.getName());
|
||||
@@ -71,7 +73,6 @@ public class LibraryProcessingService {
|
||||
processDeletedLibraryFiles(bookIds, libraryFiles);
|
||||
}
|
||||
restoreDeletedBooks(libraryFiles);
|
||||
LibraryFileProcessor processor = fileProcessorRegistry.getProcessor(libraryEntity);
|
||||
processor.processLibraryFiles(detectNewBookPaths(libraryFiles, libraryEntity), libraryEntity);
|
||||
notificationService.sendMessage(Topic.LOG, createLogNotification("Finished refreshing library: " + libraryEntity.getName()));
|
||||
}
|
||||
@@ -111,7 +112,7 @@ public class LibraryProcessingService {
|
||||
processor.processLibraryFiles(libraryFiles, libraryEntity);
|
||||
}
|
||||
|
||||
public static List<Long> detectDeletedBookIds(List<LibraryFile> libraryFiles, LibraryEntity libraryEntity) {
|
||||
protected static List<Long> detectDeletedBookIds(List<LibraryFile> libraryFiles, LibraryEntity libraryEntity) {
|
||||
Set<Path> currentFullPaths = libraryFiles.stream()
|
||||
.map(LibraryFile::getFullPath)
|
||||
.collect(Collectors.toSet());
|
||||
@@ -123,16 +124,25 @@ public class LibraryProcessingService {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public static List<LibraryFile> detectNewBookPaths(List<LibraryFile> libraryFiles, LibraryEntity libraryEntity) {
|
||||
protected List<LibraryFile> detectNewBookPaths(List<LibraryFile> libraryFiles, LibraryEntity libraryEntity) {
|
||||
Set<Path> existingFullPaths = libraryEntity.getBookEntities().stream()
|
||||
.map(BookEntity::getFullFilePath)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// Also collect paths from additional files using repository method
|
||||
Set<Path> additionalFilePaths = bookAdditionalFileRepository.findByLibraryId(libraryEntity.getId()).stream()
|
||||
.map(BookAdditionalFileEntity::getFullFilePath)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// Combine both sets of existing paths
|
||||
existingFullPaths.addAll(additionalFilePaths);
|
||||
|
||||
return libraryFiles.stream()
|
||||
.filter(file -> !existingFullPaths.contains(file.getFullPath()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public List<Long> detectDeletedAdditionalFiles(List<LibraryFile> libraryFiles, LibraryEntity libraryEntity) {
|
||||
protected List<Long> detectDeletedAdditionalFiles(List<LibraryFile> libraryFiles, LibraryEntity libraryEntity) {
|
||||
// Create a set of current file names for quick lookup
|
||||
Set<String> currentFileNames = libraryFiles.stream()
|
||||
.map(LibraryFile::getFileName)
|
||||
@@ -264,29 +274,36 @@ public class LibraryProcessingService {
|
||||
}
|
||||
|
||||
|
||||
private List<LibraryFile> getLibraryFiles(LibraryEntity libraryEntity) throws IOException {
|
||||
private List<LibraryFile> getLibraryFiles(LibraryEntity libraryEntity, LibraryFileProcessor processor) throws IOException {
|
||||
List<LibraryFile> allFiles = new ArrayList<>();
|
||||
for (LibraryPathEntity pathEntity : libraryEntity.getLibraryPaths()) {
|
||||
allFiles.addAll(findLibraryFiles(pathEntity, libraryEntity));
|
||||
allFiles.addAll(findLibraryFiles(pathEntity, libraryEntity, processor));
|
||||
}
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
private List<LibraryFile> findLibraryFiles(LibraryPathEntity pathEntity, LibraryEntity libraryEntity) throws IOException {
|
||||
private List<LibraryFile> findLibraryFiles(LibraryPathEntity pathEntity, LibraryEntity libraryEntity, LibraryFileProcessor processor) throws IOException {
|
||||
Path libraryPath = Path.of(pathEntity.getPath());
|
||||
try (Stream<Path> stream = Files.walk(libraryPath)) {
|
||||
boolean supportsSupplementaryFiles = processor.supportsSupplementaryFiles();
|
||||
|
||||
try (Stream<Path> stream = Files.walk(libraryPath, FileVisitOption.FOLLOW_LINKS)) {
|
||||
return stream.filter(Files::isRegularFile)
|
||||
.map(fullPath -> {
|
||||
String fileName = fullPath.getFileName().toString();
|
||||
return BookFileExtension.fromFileName(fileName)
|
||||
.map(ext -> LibraryFile.builder()
|
||||
.libraryEntity(libraryEntity)
|
||||
.libraryPathEntity(pathEntity)
|
||||
.fileSubPath(FileUtils.getRelativeSubPath(pathEntity.getPath(), fullPath))
|
||||
.fileName(fileName)
|
||||
.bookFileType(ext.getType())
|
||||
.build())
|
||||
.orElse(null);
|
||||
Optional<BookFileExtension> bookExtension = BookFileExtension.fromFileName(fileName);
|
||||
|
||||
if (bookExtension.isEmpty() && !supportsSupplementaryFiles) {
|
||||
// Skip files that are not recognized book files and supplementary files are not supported
|
||||
return null;
|
||||
}
|
||||
|
||||
return LibraryFile.builder()
|
||||
.libraryEntity(libraryEntity)
|
||||
.libraryPathEntity(pathEntity)
|
||||
.fileSubPath(FileUtils.getRelativeSubPath(pathEntity.getPath(), fullPath))
|
||||
.fileName(fileName)
|
||||
.bookFileType(bookExtension.map(BookFileExtension::getType).orElse(null))
|
||||
.build();
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
.filter(file -> !file.getFileName().startsWith("."))
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
|
||||
import com.adityachandel.booklore.model.entity.LibraryEntity;
|
||||
import com.adityachandel.booklore.model.entity.LibraryPathEntity;
|
||||
import com.adityachandel.booklore.model.enums.LibraryScanMode;
|
||||
import com.adityachandel.booklore.model.websocket.Topic;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
import com.adityachandel.booklore.repository.LibraryPathRepository;
|
||||
@@ -72,6 +73,10 @@ public class LibraryService {
|
||||
library.setName(request.getName());
|
||||
library.setIcon(request.getIcon());
|
||||
library.setWatch(request.isWatch());
|
||||
if (request.getScanMode() != null) {
|
||||
library.setScanMode(request.getScanMode());
|
||||
}
|
||||
library.setDefaultBookFormat(request.getDefaultBookFormat());
|
||||
|
||||
Set<String> currentPaths = library.getLibraryPaths().stream()
|
||||
.map(LibraryPathEntity::getPath)
|
||||
@@ -148,6 +153,8 @@ public class LibraryService {
|
||||
)
|
||||
.icon(request.getIcon())
|
||||
.watch(request.isWatch())
|
||||
.scanMode(request.getScanMode() != null ? request.getScanMode() : LibraryScanMode.FILE_AS_BOOK)
|
||||
.defaultBookFormat(request.getDefaultBookFormat())
|
||||
.build();
|
||||
|
||||
libraryEntity = libraryRepository.save(libraryEntity);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE library
|
||||
ADD COLUMN IF NOT EXISTS scan_mode VARCHAR(20) NOT NULL DEFAULT 'FILE_AS_BOOK',
|
||||
ADD COLUMN IF NOT EXISTS default_book_format VARCHAR(10) NULL;
|
||||
@@ -3,6 +3,7 @@ package com.adityachandel.booklore.repository;
|
||||
import com.adityachandel.booklore.model.entity.*;
|
||||
import com.adityachandel.booklore.model.enums.AdditionalFileType;
|
||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import com.adityachandel.booklore.model.enums.LibraryScanMode;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -66,6 +67,7 @@ class BookAdditionalFileRepositoryTest {
|
||||
testLibrary = LibraryEntity.builder()
|
||||
.name("Test Library")
|
||||
.icon("book")
|
||||
.scanMode(LibraryScanMode.FILE_AS_BOOK)
|
||||
.watch(false)
|
||||
.build();
|
||||
testLibrary = entityManager.persistAndFlush(testLibrary);
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package com.adityachandel.booklore.service.library;
|
||||
|
||||
import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity;
|
||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import com.adityachandel.booklore.repository.BookAdditionalFileRepository;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
import com.adityachandel.booklore.service.FileFingerprint;
|
||||
import com.adityachandel.booklore.service.NotificationService;
|
||||
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor;
|
||||
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry;
|
||||
import com.adityachandel.booklore.util.FileUtils;
|
||||
import com.adityachandel.booklore.util.builder.LibraryTestBuilder;
|
||||
import static com.adityachandel.booklore.util.builder.LibraryTestBuilderAssert.assertThat;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.*;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FolderAsBookFileProcessorExampleTest {
|
||||
|
||||
@Mock
|
||||
private BookRepository bookRepository;
|
||||
|
||||
@Mock
|
||||
private BookAdditionalFileRepository bookAdditionalFileRepository;
|
||||
|
||||
@Mock
|
||||
private NotificationService notificationService;
|
||||
|
||||
@Mock
|
||||
private BookFileProcessorRegistry bookFileProcessorRegistry;
|
||||
|
||||
@Mock
|
||||
private BookFileProcessor mockBookFileProcessor;
|
||||
|
||||
@InjectMocks
|
||||
private FolderAsBookFileProcessor processor;
|
||||
|
||||
@Captor
|
||||
private ArgumentCaptor<BookAdditionalFileEntity> additionalFileCaptor;
|
||||
|
||||
private MockedStatic<FileUtils> fileUtilsMock;
|
||||
private MockedStatic<FileFingerprint> fileFingerprintMock;
|
||||
private LibraryTestBuilder libraryTestBuilder;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
fileUtilsMock = mockStatic(FileUtils.class);
|
||||
fileFingerprintMock = mockStatic(FileFingerprint.class);
|
||||
libraryTestBuilder = new LibraryTestBuilder(fileUtilsMock, fileFingerprintMock, bookFileProcessorRegistry, mockBookFileProcessor, bookRepository, bookAdditionalFileRepository);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
fileUtilsMock.close();
|
||||
fileFingerprintMock.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void processLibraryFiles_shouldCreateNewBookFromDirectory() {
|
||||
// Given
|
||||
libraryTestBuilder
|
||||
.addDefaultLibrary()
|
||||
.addLibraryFile("/101/Accounting", "Accounting 101.pdf")
|
||||
.addLibraryFile("/101/Anatomy", "Anatomy 101.pdf");
|
||||
|
||||
// When
|
||||
processor.processLibraryFiles(libraryTestBuilder.getLibraryFiles(), libraryTestBuilder.getLibraryEntity());
|
||||
|
||||
// Then
|
||||
assertThat(libraryTestBuilder)
|
||||
.hasBooks("Accounting 101", "Anatomy 101")
|
||||
.hasNoAdditionalFiles();
|
||||
}
|
||||
|
||||
@Test
|
||||
void processLibraryFiles_shouldCreateNewBookFromDirectoryWithAdditionalBookFormats() {
|
||||
// Given
|
||||
libraryTestBuilder
|
||||
.addDefaultLibrary()
|
||||
.addLibraryFile("/101/Accounting", "Accounting 101.pdf")
|
||||
.addLibraryFile("/101/Accounting", "Accounting 101.epub")
|
||||
.addLibraryFile("/101/Anatomy", "Anatomy 101.pdf");
|
||||
|
||||
// When
|
||||
processor.processLibraryFiles(libraryTestBuilder.getLibraryFiles(), libraryTestBuilder.getLibraryEntity());
|
||||
|
||||
// Then
|
||||
assertThat(libraryTestBuilder)
|
||||
.hasBooks("Accounting 101", "Anatomy 101")
|
||||
.bookHasAdditionalFormats("Accounting 101", BookFileType.PDF)
|
||||
.bookHasNoSupplementaryFiles("Accounting 101")
|
||||
.bookHasNoAdditionalFiles("Anatomy 101");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processLibraryFiles_shouldCreateNewBookFromDirectoryWithAdditionalBookFormatsAndSupplementaryFiles() {
|
||||
// Given
|
||||
libraryTestBuilder
|
||||
.addDefaultLibrary()
|
||||
.addLibraryFile("/101/Accounting", "Accounting 101.pdf")
|
||||
.addLibraryFile("/101/Accounting", "Accounting 101.epub")
|
||||
.addLibraryFile("/101/Accounting", "Accounting 101.zip")
|
||||
.addLibraryFile("/101/Anatomy", "Anatomy 101.pdf");
|
||||
|
||||
// When
|
||||
processor.processLibraryFiles(libraryTestBuilder.getLibraryFiles(), libraryTestBuilder.getLibraryEntity());
|
||||
|
||||
// Then
|
||||
assertThat(libraryTestBuilder)
|
||||
.hasBooks("Accounting 101", "Anatomy 101")
|
||||
.bookHasAdditionalFormats("Accounting 101", BookFileType.PDF)
|
||||
.bookHasSupplementaryFiles("Accounting 101", "Accounting 101.zip")
|
||||
.bookHasNoAdditionalFiles("Anatomy 101");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processLibraryFiles_shouldCreateNewBookFromDirectoryWithSameSupplementaryFilesByHash() {
|
||||
// Given
|
||||
String supplementaryFileHash = "hash-accounting-101";
|
||||
libraryTestBuilder
|
||||
.addDefaultLibrary()
|
||||
.addLibraryFile("/101/Accounting", "Accounting 101.pdf")
|
||||
.addLibraryFile(
|
||||
"/101/Accounting",
|
||||
"Accounting 101.zip",
|
||||
supplementaryFileHash)
|
||||
.addLibraryFile("/101/Accounting 2nd Edition", "Accounting 101 2nd Edition.pdf")
|
||||
.addLibraryFile(
|
||||
"/101/Accounting 2nd Edition",
|
||||
"Accounting 101 2nd Edition.zip",
|
||||
supplementaryFileHash);
|
||||
|
||||
// When
|
||||
processor.processLibraryFiles(libraryTestBuilder.getLibraryFiles(), libraryTestBuilder.getLibraryEntity());
|
||||
|
||||
// Then
|
||||
assertThat(libraryTestBuilder)
|
||||
.hasBooks("Accounting 101", "Accounting 101 2nd Edition")
|
||||
.bookHasSupplementaryFiles("Accounting 101", "Accounting 101.zip")
|
||||
.bookHasSupplementaryFiles("Accounting 101 2nd Edition", "Accounting 101 2nd Edition.zip");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processLibraryFiles_shouldIgnoreAdditionalFormatWithTheSameHashUsedInOtherBook() {
|
||||
// Given
|
||||
String additionalFormatHash = "hash-accounting-101";
|
||||
libraryTestBuilder
|
||||
.addDefaultLibrary()
|
||||
.addLibraryFile("/101/Accounting", "Accounting 101.epub")
|
||||
.addLibraryFile(
|
||||
"/101/Accounting",
|
||||
"Accounting 101.pdf",
|
||||
additionalFormatHash)
|
||||
.addLibraryFile("/101/Accounting 2nd Edition", "Accounting 101 2nd Edition.epub")
|
||||
.addLibraryFile(
|
||||
"/101/Accounting 2nd Edition",
|
||||
"Accounting 101 2nd Edition.pdf",
|
||||
additionalFormatHash);
|
||||
|
||||
// When
|
||||
processor.processLibraryFiles(libraryTestBuilder.getLibraryFiles(), libraryTestBuilder.getLibraryEntity());
|
||||
|
||||
// Then
|
||||
assertThat(libraryTestBuilder)
|
||||
.hasBooks("Accounting 101", "Accounting 101 2nd Edition")
|
||||
.bookHasAdditionalFormats("Accounting 101", BookFileType.PDF)
|
||||
.bookHasNoAdditionalFiles("Accounting 101 2nd Edition");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processLibraryFiles_shouldAddAdditionalFormatsToExistingBook() {
|
||||
// Given
|
||||
libraryTestBuilder
|
||||
.addDefaultLibrary()
|
||||
.addBook("/101/Accounting", "Accounting 101.pdf")
|
||||
.addLibraryFile("/101/Accounting", "Accounting 101.epub");
|
||||
|
||||
// When
|
||||
processor.processLibraryFiles(libraryTestBuilder.getLibraryFiles(), libraryTestBuilder.getLibraryEntity());
|
||||
|
||||
// Then
|
||||
assertThat(libraryTestBuilder)
|
||||
.hasBooks("Accounting 101")
|
||||
.bookHasAdditionalFormats("Accounting 101", BookFileType.EPUB)
|
||||
.bookHasNoSupplementaryFiles("Accounting 101");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processLibraryFiles_shouldAddSupplementaryFilesToExistingBook() {
|
||||
// Given
|
||||
libraryTestBuilder
|
||||
.addDefaultLibrary()
|
||||
.addBook("/101/Accounting", "Accounting 101.pdf")
|
||||
.addLibraryFile("/101/Accounting", "sources.zip");
|
||||
|
||||
// When
|
||||
processor.processLibraryFiles(libraryTestBuilder.getLibraryFiles(), libraryTestBuilder.getLibraryEntity());
|
||||
|
||||
// Then
|
||||
assertThat(libraryTestBuilder)
|
||||
.hasBooks("Accounting 101")
|
||||
.bookHasNoAdditionalFormats("Accounting 101")
|
||||
.bookHasSupplementaryFiles("Accounting 101", "sources.zip");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processLibraryFiles_shouldAddAdditionalFilesToExistingBook() {
|
||||
// Given
|
||||
libraryTestBuilder
|
||||
.addDefaultLibrary()
|
||||
.addBook("/101/Accounting", "Accounting 101.pdf")
|
||||
.addLibraryFile("/101/Accounting", "Accounting 101.epub")
|
||||
.addLibraryFile("/101/Accounting", "sources.zip");
|
||||
|
||||
// When
|
||||
processor.processLibraryFiles(libraryTestBuilder.getLibraryFiles(), libraryTestBuilder.getLibraryEntity());
|
||||
|
||||
// Then
|
||||
assertThat(libraryTestBuilder)
|
||||
.hasBooks("Accounting 101")
|
||||
.bookHasAdditionalFormats("Accounting 101", BookFileType.EPUB)
|
||||
.bookHasSupplementaryFiles("Accounting 101", "sources.zip");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processLibraryFiles_shouldProcessDeepSubfolders() {
|
||||
var javaSourcesSameHash = "hash-java-sources";
|
||||
|
||||
libraryTestBuilder
|
||||
.addDefaultLibrary()
|
||||
// Basic books
|
||||
.addLibraryFile("/Basic/101/Accounting", "Accounting 101.epub")
|
||||
.addLibraryFile("/Basic/101/Accounting", "Accounting 101.pdf")
|
||||
.addLibraryFile("/Basic/101/Anatomy", "Anatomy 101.pdf")
|
||||
.addLibraryFile("/Basic/How-To/Repair", "How to Repair.epub")
|
||||
.addLibraryFile("/Basic/How-To/Repair", "How to Repair.pdf")
|
||||
// Software Engineering books
|
||||
.addLibraryFile("/Software Engineering/Java/Design Patterns", "Design Patterns.pdf")
|
||||
.addLibraryFile("/Software Engineering/Java/Design Patterns", "Design Patterns.epub")
|
||||
.addLibraryFile("/Software Engineering/Java/Design Patterns", "Design Patterns.zip", javaSourcesSameHash)
|
||||
.addLibraryFile("/Software Engineering/Java/Effective Java", "Effective Java.pdf")
|
||||
.addLibraryFile("/Software Engineering/Java/Effective Java", "Effective Java.epub")
|
||||
.addLibraryFile("/Software Engineering/Java/Effective Java", "Effective Java.zip", javaSourcesSameHash)
|
||||
.addLibraryFile("/Software Engineering/Python/AI/Machine Learning/Pytorch", "PyTorch for Machine Learning.pdf")
|
||||
.addLibraryFile("/Software Engineering/Python/AI/Machine Learning/Pytorch", "PyTorch for Machine Learning.epub")
|
||||
.addLibraryFile("/Software Engineering/Python/AI/Machine Learning/Pytorch", "sources.zip")
|
||||
.addLibraryFile("/Software Engineering/Python/AI/Machine Learning/TensorFlow", "TensorFlow for Machine Learning.pdf")
|
||||
.addLibraryFile("/Software Engineering/Python/AI/Machine Learning/TensorFlow", "TensorFlow for Machine Learning.epub")
|
||||
.addLibraryFile("/Software Engineering/Python/AI/Machine Learning/TensorFlow", "sources.zip")
|
||||
.addLibraryFile("/Software Engineering/Python/Flask/Flask Web Development", "Flask Web Development.pdf")
|
||||
.addLibraryFile("/Software Engineering/Python/Flask/Flask Web Development", "Flask Web Development.epub")
|
||||
// Comics Marvel
|
||||
.addLibraryFile("/Comics/Marvel/Batman/Volume 1", "Batman v1.cbr")
|
||||
.addLibraryFile("/Comics/Marvel/Batman/Volume 2", "Batman v2.cbr")
|
||||
.addLibraryFile("/Comics/Marvel/Spiderman/Volume 1", "Spiderman v1.cbz")
|
||||
.addLibraryFile("/Comics/Marvel/Spiderman/Volume 2", "Spiderman v2.cbz")
|
||||
// Comics DC
|
||||
.addLibraryFile("/Comics/DC/Superman/Volume 1", "Superman v1.cbr")
|
||||
.addLibraryFile("/Comics/DC/Superman/Volume 1", "Poster.jpg")
|
||||
.addLibraryFile("/Comics/DC/Superman/Volume 2", "Superman v2.cbr")
|
||||
// Manga
|
||||
.addLibraryFile("/Manga/One Piece/Volume 1", "One Piece v1.cbz")
|
||||
.addLibraryFile("/Manga/One Piece/Volume 2", "One Piece v2.cbz")
|
||||
.addLibraryFile("/Manga/Naruto/Volume 1", "Naruto v1.cbr")
|
||||
.addLibraryFile("/Manga/Naruto/Volume 2", "Naruto v2.cbr");
|
||||
|
||||
// When
|
||||
processor.processLibraryFiles(libraryTestBuilder.getLibraryFiles(), libraryTestBuilder.getLibraryEntity());
|
||||
|
||||
// Then
|
||||
assertThat(libraryTestBuilder)
|
||||
.hasBooks(
|
||||
"Accounting 101", "Anatomy 101", "How to Repair",
|
||||
"Design Patterns", "Effective Java",
|
||||
"PyTorch for Machine Learning", "TensorFlow for Machine Learning",
|
||||
"Flask Web Development",
|
||||
"Batman v1", "Batman v2",
|
||||
"Spiderman v1", "Spiderman v2",
|
||||
"Superman v1", "Superman v2",
|
||||
"One Piece v1", "One Piece v2",
|
||||
"Naruto v1", "Naruto v2")
|
||||
// Basic books
|
||||
.bookHasAdditionalFormats("Accounting 101", BookFileType.PDF)
|
||||
.bookHasNoSupplementaryFiles("Anatomy 101")
|
||||
.bookHasAdditionalFormats("How to Repair", BookFileType.PDF)
|
||||
// Software Engineering books
|
||||
.bookHasAdditionalFormats("Design Patterns", BookFileType.PDF)
|
||||
.bookHasSupplementaryFiles("Design Patterns", "Design Patterns.zip")
|
||||
.bookHasAdditionalFormats("Effective Java", BookFileType.PDF)
|
||||
.bookHasSupplementaryFiles("Effective Java", "Effective Java.zip")
|
||||
.bookHasAdditionalFormats("PyTorch for Machine Learning", BookFileType.PDF)
|
||||
.bookHasSupplementaryFiles("PyTorch for Machine Learning", "sources.zip")
|
||||
.bookHasAdditionalFormats("TensorFlow for Machine Learning", BookFileType.PDF)
|
||||
.bookHasSupplementaryFiles("TensorFlow for Machine Learning", "sources.zip")
|
||||
.bookHasAdditionalFormats("Flask Web Development", BookFileType.PDF)
|
||||
// Comics Marvel
|
||||
.bookHasNoAdditionalFiles("Batman v1")
|
||||
.bookHasNoAdditionalFiles("Batman v2")
|
||||
.bookHasNoAdditionalFiles("Spiderman v1")
|
||||
.bookHasNoAdditionalFiles("Spiderman v2")
|
||||
// Comics DC
|
||||
.bookHasSupplementaryFiles("Superman v1", "Poster.jpg")
|
||||
.bookHasNoAdditionalFiles("Superman v2")
|
||||
// Manga
|
||||
.bookHasNoAdditionalFiles("One Piece v1")
|
||||
.bookHasNoAdditionalFiles("One Piece v2")
|
||||
.bookHasNoAdditionalFiles("Naruto v1")
|
||||
.bookHasNoAdditionalFiles("Naruto v2");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.adityachandel.booklore.service.library;
|
||||
|
||||
import com.adityachandel.booklore.model.enums.LibraryScanMode;
|
||||
import com.adityachandel.booklore.repository.BookAdditionalFileRepository;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
import com.adityachandel.booklore.service.NotificationService;
|
||||
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class FolderAsBookFileProcessorSimpleTest {
|
||||
|
||||
@Test
|
||||
void getScanMode_shouldReturnFolderAsBook() {
|
||||
BookRepository bookRepository = mock(BookRepository.class);
|
||||
BookAdditionalFileRepository bookAdditionalFileRepository = mock(BookAdditionalFileRepository.class);
|
||||
NotificationService notificationService = mock(NotificationService.class);
|
||||
BookFileProcessorRegistry bookFileProcessorRegistry = mock(BookFileProcessorRegistry.class);
|
||||
|
||||
FolderAsBookFileProcessor processor = new FolderAsBookFileProcessor(
|
||||
bookRepository, bookAdditionalFileRepository, notificationService, bookFileProcessorRegistry);
|
||||
|
||||
assertThat(processor.getScanMode()).isEqualTo(LibraryScanMode.FOLDER_AS_BOOK);
|
||||
}
|
||||
|
||||
@Test
|
||||
void supportsSupplementaryFiles_shouldReturnTrue() {
|
||||
BookRepository bookRepository = mock(BookRepository.class);
|
||||
BookAdditionalFileRepository bookAdditionalFileRepository = mock(BookAdditionalFileRepository.class);
|
||||
NotificationService notificationService = mock(NotificationService.class);
|
||||
BookFileProcessorRegistry bookFileProcessorRegistry = mock(BookFileProcessorRegistry.class);
|
||||
|
||||
FolderAsBookFileProcessor processor = new FolderAsBookFileProcessor(
|
||||
bookRepository, bookAdditionalFileRepository, notificationService, bookFileProcessorRegistry);
|
||||
|
||||
assertThat(processor.supportsSupplementaryFiles()).isTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
package com.adityachandel.booklore.service.library;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.Book;
|
||||
import com.adityachandel.booklore.model.dto.settings.LibraryFile;
|
||||
import com.adityachandel.booklore.model.entity.*;
|
||||
import com.adityachandel.booklore.model.enums.AdditionalFileType;
|
||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import com.adityachandel.booklore.model.enums.LibraryScanMode;
|
||||
import com.adityachandel.booklore.model.websocket.LogNotification;
|
||||
import com.adityachandel.booklore.model.websocket.Topic;
|
||||
import com.adityachandel.booklore.repository.BookAdditionalFileRepository;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
import com.adityachandel.booklore.service.FileFingerprint;
|
||||
import com.adityachandel.booklore.service.NotificationService;
|
||||
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor;
|
||||
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry;
|
||||
import com.adityachandel.booklore.util.FileUtils;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.*;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FolderAsBookFileProcessorTest {
|
||||
|
||||
@Mock
|
||||
private BookRepository bookRepository;
|
||||
|
||||
@Mock
|
||||
private BookAdditionalFileRepository bookAdditionalFileRepository;
|
||||
|
||||
@Mock
|
||||
private NotificationService notificationService;
|
||||
|
||||
@Mock
|
||||
private BookFileProcessorRegistry bookFileProcessorRegistry;
|
||||
|
||||
@Mock
|
||||
private BookFileProcessor mockBookFileProcessor;
|
||||
|
||||
@InjectMocks
|
||||
private FolderAsBookFileProcessor processor;
|
||||
|
||||
@Captor
|
||||
private ArgumentCaptor<BookAdditionalFileEntity> additionalFileCaptor;
|
||||
|
||||
private MockedStatic<FileUtils> fileUtilsMock;
|
||||
private MockedStatic<FileFingerprint> fileFingerprintMock;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
fileUtilsMock = mockStatic(FileUtils.class);
|
||||
fileFingerprintMock = mockStatic(FileFingerprint.class);
|
||||
// Setup common FileUtils mocks for all tests
|
||||
fileUtilsMock.when(() -> FileUtils.getFileSizeInKb(any(Path.class))).thenReturn(100L);
|
||||
fileFingerprintMock.when(() -> FileFingerprint.generateHash(any(Path.class)))
|
||||
.then(invocation -> {
|
||||
MessageDigest digest = MessageDigest.getInstance("MD5");
|
||||
Path path = invocation.getArgument(0);
|
||||
byte[] hash = digest.digest(path.toString().getBytes());
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hash) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) hexString.append('0');
|
||||
hexString.append(hex);
|
||||
}
|
||||
return hexString.toString();
|
||||
});
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
fileUtilsMock.close();
|
||||
fileFingerprintMock.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getScanMode_shouldReturnFolderAsBook() {
|
||||
assertThat(processor.getScanMode()).isEqualTo(LibraryScanMode.FOLDER_AS_BOOK);
|
||||
}
|
||||
|
||||
@Test
|
||||
void supportsSupplementaryFiles_shouldReturnTrue() {
|
||||
assertThat(processor.supportsSupplementaryFiles()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void processLibraryFiles_shouldCreateNewBookFromDirectory() {
|
||||
// Given
|
||||
LibraryEntity libraryEntity = createLibraryEntity();
|
||||
List<LibraryFile> libraryFiles = createLibraryFilesInSameDirectory();
|
||||
|
||||
Book createdBook = Book.builder()
|
||||
.id(1L)
|
||||
.fileName("book.pdf")
|
||||
.bookType(BookFileType.PDF)
|
||||
.build();
|
||||
|
||||
BookEntity bookEntity = createBookEntity(1L, "book.pdf", "books");
|
||||
|
||||
when(bookRepository.findAllByLibraryPathIdAndFileSubPathStartingWith(anyLong(), anyString()))
|
||||
.thenReturn(new ArrayList<>());
|
||||
when(bookFileProcessorRegistry.getProcessorOrThrow(BookFileType.PDF))
|
||||
.thenReturn(mockBookFileProcessor);
|
||||
when(mockBookFileProcessor.processFile(any(LibraryFile.class)))
|
||||
.thenReturn(createdBook);
|
||||
when(bookRepository.getReferenceById(createdBook.getId()))
|
||||
.thenReturn(bookEntity);
|
||||
when(bookAdditionalFileRepository.findByLibraryPath_IdAndFileSubPathAndFileName(anyLong(), anyString(), anyString()))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
// When
|
||||
processor.processLibraryFiles(libraryFiles, libraryEntity);
|
||||
|
||||
// Then
|
||||
verify(mockBookFileProcessor).processFile(any(LibraryFile.class));
|
||||
verify(notificationService).sendMessage(Topic.BOOK_ADD, createdBook);
|
||||
verify(notificationService).sendMessage(eq(Topic.LOG), any(LogNotification.class));
|
||||
verify(bookAdditionalFileRepository, times(2)).save(additionalFileCaptor.capture());
|
||||
|
||||
List<BookAdditionalFileEntity> capturedFiles = additionalFileCaptor.getAllValues();
|
||||
assertThat(capturedFiles).hasSize(2);
|
||||
assertThat(capturedFiles).extracting(BookAdditionalFileEntity::getFileName)
|
||||
.containsExactlyInAnyOrder("book.epub", "cover.jpg");
|
||||
assertThat(capturedFiles).extracting(BookAdditionalFileEntity::getAdditionalFileType)
|
||||
.containsExactly(AdditionalFileType.ALTERNATIVE_FORMAT, AdditionalFileType.SUPPLEMENTARY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void processLibraryFiles_shouldProcessExistingBook() {
|
||||
// Given
|
||||
LibraryEntity libraryEntity = createLibraryEntity();
|
||||
List<LibraryFile> libraryFiles = createLibraryFilesInSameDirectory()
|
||||
.stream()
|
||||
.filter(f -> !f.getFileName().equals("book.pdf"))
|
||||
.toList();
|
||||
|
||||
BookEntity existingBook = createBookEntity(1L, "book.pdf", "books");
|
||||
|
||||
when(bookRepository.findAllByLibraryPathIdAndFileSubPathStartingWith(anyLong(), eq("books")))
|
||||
.thenReturn(List.of(existingBook));
|
||||
when(bookAdditionalFileRepository.findByLibraryPath_IdAndFileSubPathAndFileName(anyLong(), anyString(), anyString()))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
// When
|
||||
processor.processLibraryFiles(libraryFiles, libraryEntity);
|
||||
|
||||
// Then
|
||||
verify(mockBookFileProcessor, never()).processFile(any());
|
||||
verify(notificationService, never()).sendMessage(eq(Topic.BOOK_ADD), any());
|
||||
verify(bookAdditionalFileRepository, times(2)).save(additionalFileCaptor.capture());
|
||||
|
||||
List<BookAdditionalFileEntity> capturedFiles = additionalFileCaptor.getAllValues();
|
||||
assertThat(capturedFiles).hasSize(2);
|
||||
assertThat(capturedFiles).extracting(BookAdditionalFileEntity::getFileName)
|
||||
.containsExactlyInAnyOrder("book.epub", "cover.jpg");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processLibraryFiles_shouldProcessFilesWithParentBook() {
|
||||
// Given
|
||||
LibraryEntity libraryEntity = createLibraryEntity();
|
||||
List<LibraryFile> libraryFiles = List.of(
|
||||
createLibraryFile("file1.txt", "books/chapter1"),
|
||||
createLibraryFile("file2.txt", "books/chapter1")
|
||||
);
|
||||
|
||||
BookEntity parentBook = createBookEntity(1L, "book.pdf", "books");
|
||||
|
||||
when(bookRepository.findAllByLibraryPathIdAndFileSubPathStartingWith(anyLong(), eq("books/chapter1")))
|
||||
.thenReturn(new ArrayList<>());
|
||||
when(bookRepository.findAllByLibraryPathIdAndFileSubPathStartingWith(anyLong(), eq("books")))
|
||||
.thenReturn(List.of(parentBook));
|
||||
when(bookAdditionalFileRepository.findByLibraryPath_IdAndFileSubPathAndFileName(anyLong(), anyString(), anyString()))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
// When
|
||||
processor.processLibraryFiles(libraryFiles, libraryEntity);
|
||||
|
||||
// Then
|
||||
verify(mockBookFileProcessor, never()).processFile(any());
|
||||
verify(bookAdditionalFileRepository, times(2)).save(additionalFileCaptor.capture());
|
||||
|
||||
List<BookAdditionalFileEntity> capturedFiles = additionalFileCaptor.getAllValues();
|
||||
assertThat(capturedFiles).hasSize(2);
|
||||
assertThat(capturedFiles).extracting(BookAdditionalFileEntity::getAdditionalFileType)
|
||||
.containsOnly(AdditionalFileType.SUPPLEMENTARY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void processLibraryFiles_shouldRespectDefaultBookFormat() {
|
||||
// Given
|
||||
LibraryEntity libraryEntity = createLibraryEntity();
|
||||
libraryEntity.setDefaultBookFormat(BookFileType.EPUB);
|
||||
|
||||
List<LibraryFile> libraryFiles = List.of(
|
||||
createLibraryFile("book.pdf", "books", BookFileType.PDF),
|
||||
createLibraryFile("book.epub", "books", BookFileType.EPUB),
|
||||
createLibraryFile("book.cbz", "books", BookFileType.CBX)
|
||||
);
|
||||
|
||||
Book createdBook = Book.builder()
|
||||
.id(1L)
|
||||
.fileName("book.epub")
|
||||
.bookType(BookFileType.EPUB)
|
||||
.build();
|
||||
|
||||
BookEntity bookEntity = createBookEntity(1L, "book.epub", "books");
|
||||
|
||||
when(bookRepository.findAllByLibraryPathIdAndFileSubPathStartingWith(anyLong(), anyString()))
|
||||
.thenReturn(new ArrayList<>());
|
||||
when(bookFileProcessorRegistry.getProcessorOrThrow(BookFileType.EPUB))
|
||||
.thenReturn(mockBookFileProcessor);
|
||||
when(mockBookFileProcessor.processFile(argThat(file -> file.getFileName().equals("book.epub"))))
|
||||
.thenReturn(createdBook);
|
||||
when(bookRepository.getReferenceById(createdBook.getId()))
|
||||
.thenReturn(bookEntity);
|
||||
when(bookAdditionalFileRepository.findByLibraryPath_IdAndFileSubPathAndFileName(anyLong(), anyString(), anyString()))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
// When
|
||||
processor.processLibraryFiles(libraryFiles, libraryEntity);
|
||||
|
||||
// Then
|
||||
verify(mockBookFileProcessor).processFile(argThat(file -> file.getFileName().equals("book.epub")));
|
||||
verify(bookAdditionalFileRepository, times(2)).save(additionalFileCaptor.capture());
|
||||
|
||||
List<BookAdditionalFileEntity> capturedFiles = additionalFileCaptor.getAllValues();
|
||||
assertThat(capturedFiles).extracting(BookAdditionalFileEntity::getFileName)
|
||||
.containsExactlyInAnyOrder("book.pdf", "book.cbz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processLibraryFiles_shouldUseDefaultPriorityWhenNoDefaultFormat() {
|
||||
// Given
|
||||
LibraryEntity libraryEntity = createLibraryEntity();
|
||||
libraryEntity.setDefaultBookFormat(null);
|
||||
|
||||
List<LibraryFile> libraryFiles = List.of(
|
||||
createLibraryFile("book.epub", "books", BookFileType.EPUB),
|
||||
createLibraryFile("book.cbz", "books", BookFileType.CBX),
|
||||
createLibraryFile("book.pdf", "books", BookFileType.PDF)
|
||||
);
|
||||
|
||||
Book createdBook = Book.builder()
|
||||
.id(1L)
|
||||
.fileName("book.pdf")
|
||||
.bookType(BookFileType.PDF)
|
||||
.build();
|
||||
|
||||
BookEntity bookEntity = createBookEntity(1L, "book.pdf", "books");
|
||||
|
||||
when(bookRepository.findAllByLibraryPathIdAndFileSubPathStartingWith(anyLong(), anyString()))
|
||||
.thenReturn(new ArrayList<>());
|
||||
when(bookFileProcessorRegistry.getProcessorOrThrow(BookFileType.PDF))
|
||||
.thenReturn(mockBookFileProcessor);
|
||||
when(mockBookFileProcessor.processFile(argThat(file -> file.getFileName().equals("book.pdf"))))
|
||||
.thenReturn(createdBook);
|
||||
when(bookRepository.getReferenceById(createdBook.getId()))
|
||||
.thenReturn(bookEntity);
|
||||
when(bookAdditionalFileRepository.findByLibraryPath_IdAndFileSubPathAndFileName(anyLong(), anyString(), anyString()))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
// When
|
||||
processor.processLibraryFiles(libraryFiles, libraryEntity);
|
||||
|
||||
// Then
|
||||
verify(mockBookFileProcessor).processFile(argThat(file -> file.getFileName().equals("book.pdf")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void processLibraryFiles_shouldSkipExistingAdditionalFiles() {
|
||||
// Given
|
||||
LibraryEntity libraryEntity = createLibraryEntity();
|
||||
List<LibraryFile> libraryFiles = createLibraryFilesInSameDirectory()
|
||||
.stream()
|
||||
.filter(f -> !f.getFileName().equals("book.pdf"))
|
||||
.toList();
|
||||
|
||||
BookEntity existingBook = createBookEntity(1L, "book.pdf", "books");
|
||||
BookAdditionalFileEntity existingAdditionalFile = BookAdditionalFileEntity.builder()
|
||||
.id(1L)
|
||||
.book(existingBook)
|
||||
.fileName("book.epub")
|
||||
.fileSubPath("books")
|
||||
.additionalFileType(AdditionalFileType.ALTERNATIVE_FORMAT)
|
||||
.build();
|
||||
|
||||
when(bookRepository.findAllByLibraryPathIdAndFileSubPathStartingWith(anyLong(), eq("books")))
|
||||
.thenReturn(List.of(existingBook));
|
||||
when(bookAdditionalFileRepository.findByLibraryPath_IdAndFileSubPathAndFileName(anyLong(), eq("books"), eq("book.epub")))
|
||||
.thenReturn(Optional.of(existingAdditionalFile));
|
||||
when(bookAdditionalFileRepository.findByLibraryPath_IdAndFileSubPathAndFileName(anyLong(), eq("books"), eq("cover.jpg")))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
// When
|
||||
processor.processLibraryFiles(libraryFiles, libraryEntity);
|
||||
|
||||
// Then
|
||||
verify(bookAdditionalFileRepository, times(1)).save(additionalFileCaptor.capture());
|
||||
|
||||
BookAdditionalFileEntity capturedFile = additionalFileCaptor.getValue();
|
||||
assertThat(capturedFile.getFileName()).isEqualTo("cover.jpg");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processLibraryFiles_shouldHandleEmptyDirectory() {
|
||||
// Given
|
||||
LibraryEntity libraryEntity = createLibraryEntity();
|
||||
List<LibraryFile> libraryFiles = List.of(
|
||||
createLibraryFile("readme.txt", "docs"),
|
||||
createLibraryFile("notes.txt", "docs")
|
||||
);
|
||||
|
||||
when(bookRepository.findAllByLibraryPathIdAndFileSubPathStartingWith(anyLong(), anyString()))
|
||||
.thenReturn(new ArrayList<>());
|
||||
|
||||
// When
|
||||
processor.processLibraryFiles(libraryFiles, libraryEntity);
|
||||
|
||||
// Then
|
||||
verify(mockBookFileProcessor, never()).processFile(any());
|
||||
verify(notificationService, never()).sendMessage(eq(Topic.BOOK_ADD), any());
|
||||
verify(bookAdditionalFileRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void processLibraryFiles_shouldHandleProcessorError() {
|
||||
// Given
|
||||
LibraryEntity libraryEntity = createLibraryEntity();
|
||||
List<LibraryFile> libraryFiles = List.of(
|
||||
createLibraryFile("book.pdf", "books", BookFileType.PDF)
|
||||
);
|
||||
|
||||
when(bookRepository.findAllByLibraryPathIdAndFileSubPathStartingWith(anyLong(), anyString()))
|
||||
.thenReturn(new ArrayList<>());
|
||||
when(bookFileProcessorRegistry.getProcessorOrThrow(BookFileType.PDF))
|
||||
.thenReturn(mockBookFileProcessor);
|
||||
when(mockBookFileProcessor.processFile(any(LibraryFile.class)))
|
||||
.thenThrow(new RuntimeException("Processing error"));
|
||||
|
||||
// When
|
||||
processor.processLibraryFiles(libraryFiles, libraryEntity);
|
||||
|
||||
// Then
|
||||
verify(notificationService, never()).sendMessage(eq(Topic.BOOK_ADD), any());
|
||||
verify(bookAdditionalFileRepository, never()).save(any());
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private LibraryEntity createLibraryEntity() {
|
||||
LibraryEntity library = new LibraryEntity();
|
||||
library.setId(1L);
|
||||
library.setName("Test Library");
|
||||
library.setScanMode(LibraryScanMode.FOLDER_AS_BOOK);
|
||||
|
||||
LibraryPathEntity path = new LibraryPathEntity();
|
||||
path.setId(1L);
|
||||
path.setPath("/test/library");
|
||||
library.setLibraryPaths(List.of(path));
|
||||
|
||||
return library;
|
||||
}
|
||||
|
||||
private List<LibraryFile> createLibraryFilesInSameDirectory() {
|
||||
return List.of(
|
||||
createLibraryFile("book.pdf", "books", BookFileType.PDF),
|
||||
createLibraryFile("book.epub", "books", BookFileType.EPUB),
|
||||
createLibraryFile("cover.jpg", "books")
|
||||
);
|
||||
}
|
||||
|
||||
private LibraryFile createLibraryFile(String fileName, String subPath) {
|
||||
return createLibraryFile(fileName, subPath, null);
|
||||
}
|
||||
|
||||
private LibraryFile createLibraryFile(String fileName, String subPath, BookFileType bookFileType) {
|
||||
LibraryPathEntity libraryPath = new LibraryPathEntity();
|
||||
libraryPath.setId(1L);
|
||||
libraryPath.setPath("/test/library");
|
||||
|
||||
return LibraryFile.builder()
|
||||
.libraryEntity(new LibraryEntity())
|
||||
.libraryPathEntity(libraryPath)
|
||||
.fileName(fileName)
|
||||
.fileSubPath(subPath)
|
||||
.bookFileType(bookFileType)
|
||||
.build();
|
||||
}
|
||||
|
||||
private BookEntity createBookEntity(Long id, String fileName, String subPath) {
|
||||
BookEntity book = new BookEntity();
|
||||
book.setId(id);
|
||||
book.setFileName(fileName);
|
||||
book.setFileSubPath(subPath);
|
||||
book.setBookType(BookFileType.PDF);
|
||||
book.setAddedOn(Instant.now());
|
||||
|
||||
LibraryPathEntity libraryPath = new LibraryPathEntity();
|
||||
libraryPath.setId(1L);
|
||||
libraryPath.setPath("/test/library");
|
||||
book.setLibraryPath(libraryPath);
|
||||
|
||||
return book;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package com.adityachandel.booklore.util.builder;
|
||||
|
||||
import com.adityachandel.booklore.mapper.BookMapper;
|
||||
import com.adityachandel.booklore.mapper.BookMapperImpl;
|
||||
import com.adityachandel.booklore.model.dto.Book;
|
||||
import com.adityachandel.booklore.model.dto.settings.LibraryFile;
|
||||
import com.adityachandel.booklore.model.entity.*;
|
||||
import com.adityachandel.booklore.model.enums.AdditionalFileType;
|
||||
import com.adityachandel.booklore.model.enums.BookFileExtension;
|
||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import com.adityachandel.booklore.model.enums.LibraryScanMode;
|
||||
import com.adityachandel.booklore.repository.BookAdditionalFileRepository;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
import com.adityachandel.booklore.service.FileFingerprint;
|
||||
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor;
|
||||
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry;
|
||||
import com.adityachandel.booklore.util.FileUtils;
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
|
||||
/**
|
||||
* Test builder for creating Library-related test objects.
|
||||
* Provides fluent API for creating LibraryEntity and Library DTO objects with sensible defaults.
|
||||
*/
|
||||
public class LibraryTestBuilder {
|
||||
|
||||
public static final String DEFAULT_LIBRARY_NAME = "Test Library";
|
||||
public static final String DEFAULT_LIBRARY_PATH = "/library/books";
|
||||
|
||||
private static final BookMapper BOOK_MAPPER = new BookMapperImpl();
|
||||
|
||||
private Long libraryId = 1L;
|
||||
|
||||
private final List<LibraryEntity> libraries = new ArrayList<>();
|
||||
private final Map<String, LibraryEntity> libraryMap = new HashMap<>();
|
||||
|
||||
// DTO-specific fields
|
||||
private final List<LibraryFile> libraryFiles = new ArrayList<>();
|
||||
private final Map<Path, String> libraryFileHashes = new HashMap<>();
|
||||
private final Map<Long, BookEntity> bookRepository = new HashMap<>();
|
||||
private final Map<String, BookEntity> bookMap = new HashMap<>();
|
||||
private final Map<Long, BookAdditionalFileEntity> bookAdditionalFileRepository = new HashMap<>();
|
||||
|
||||
public LibraryTestBuilder(MockedStatic<FileUtils> fileUtilsMock,
|
||||
MockedStatic<FileFingerprint> fileFingerprintMock,
|
||||
BookFileProcessorRegistry bookFileProcessorRegistry,
|
||||
BookFileProcessor bookFileProcessorMock,
|
||||
BookRepository bookRepositoryMock,
|
||||
BookAdditionalFileRepository bookAdditionalFileRepositoryMock) {
|
||||
fileUtilsMock.when(() -> FileUtils.getFileSizeInKb(any(Path.class))).thenReturn(100L);
|
||||
fileFingerprintMock.when(() -> FileFingerprint.generateHash(any(Path.class)))
|
||||
.then(invocation -> {
|
||||
Path path = invocation.getArgument(0);
|
||||
return computeFileHash(path);
|
||||
});
|
||||
|
||||
lenient().when(bookFileProcessorRegistry.getProcessorOrThrow(any(BookFileType.class)))
|
||||
.thenReturn(bookFileProcessorMock);
|
||||
lenient().when(bookFileProcessorMock.processFile(any(LibraryFile.class)))
|
||||
.then(invocation -> {
|
||||
LibraryFile libraryFile = invocation.getArgument(0);
|
||||
return processFile(libraryFile);
|
||||
});
|
||||
lenient().when(bookRepositoryMock.getReferenceById(anyLong()))
|
||||
.thenAnswer(invocation -> {
|
||||
Long bookId = invocation.getArgument(0);
|
||||
return getBookById(bookId);
|
||||
});
|
||||
when(bookRepositoryMock.findAllByLibraryPathIdAndFileSubPathStartingWith(anyLong(), any(String.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
Long libraryPathId = invocation.getArgument(0);
|
||||
String fileSubPath = invocation.getArgument(1);
|
||||
return bookRepository.values()
|
||||
.stream()
|
||||
.filter(book -> book.getLibraryPath().getId().equals(libraryPathId) &&
|
||||
book.getFileSubPath().startsWith(fileSubPath))
|
||||
.toList();
|
||||
});
|
||||
|
||||
// lenient is used to avoid strict stubbing issues,
|
||||
// the builder does not know when the save method will be called
|
||||
lenient().when(bookAdditionalFileRepositoryMock.save(any(BookAdditionalFileEntity.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
BookAdditionalFileEntity additionalFile = invocation.getArgument(0);
|
||||
return saveBookAdditionalFile(additionalFile);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a default library with a single path.
|
||||
*/
|
||||
public LibraryTestBuilder addDefaultLibrary() {
|
||||
return addLibrary(DEFAULT_LIBRARY_NAME)
|
||||
.addPath(DEFAULT_LIBRARY_PATH);
|
||||
}
|
||||
|
||||
public LibraryTestBuilder addLibrary(String name) {
|
||||
LibraryEntity library = new LibraryEntity();
|
||||
library.setId(libraryId++);
|
||||
library.setName(name);
|
||||
library.setScanMode(LibraryScanMode.FOLDER_AS_BOOK);
|
||||
library.setDefaultBookFormat(BookFileType.EPUB);
|
||||
library.setLibraryPaths(new ArrayList<>());
|
||||
|
||||
libraries.add(library);
|
||||
libraryMap.put(name, library);
|
||||
return this;
|
||||
}
|
||||
|
||||
public LibraryEntity getLibraryEntity() {
|
||||
if (libraries.isEmpty()) {
|
||||
throw new IllegalStateException("No library available. Please add a library first.");
|
||||
}
|
||||
return getLibraryEntity(DEFAULT_LIBRARY_NAME);
|
||||
}
|
||||
|
||||
public LibraryEntity getLibraryEntity(String libraryName) {
|
||||
return libraryMap.get(libraryName);
|
||||
}
|
||||
|
||||
public List<LibraryFile> getLibraryFiles() {
|
||||
if (libraryFiles.isEmpty()) {
|
||||
throw new IllegalStateException("No library files available. Please add a library file first.");
|
||||
}
|
||||
return List.copyOf(libraryFiles);
|
||||
}
|
||||
|
||||
public List<BookEntity> getBookEntities() {
|
||||
if (bookRepository.isEmpty()) {
|
||||
throw new IllegalStateException("No book entities available. Please process a file first.");
|
||||
}
|
||||
return new ArrayList<>(bookRepository.values());
|
||||
}
|
||||
|
||||
public BookEntity getBookEntity(String bookTitle) {
|
||||
if (bookMap.isEmpty()) {
|
||||
throw new IllegalStateException("No book entities available. Please process a file first.");
|
||||
}
|
||||
if (!bookMap.containsKey(bookTitle)) {
|
||||
throw new IllegalStateException("No book found with title: " + bookTitle);
|
||||
}
|
||||
return bookMap.get(bookTitle);
|
||||
}
|
||||
|
||||
public List<BookAdditionalFileEntity> getBookAdditionalFiles() {
|
||||
return new ArrayList<>(bookAdditionalFileRepository.values());
|
||||
}
|
||||
|
||||
public LibraryTestBuilder addPath(String path) {
|
||||
if (libraries.isEmpty()) {
|
||||
throw new IllegalStateException("No library available to add a path. Please add a library first.");
|
||||
}
|
||||
|
||||
var library = libraries.getLast();
|
||||
var id = libraries
|
||||
.stream()
|
||||
.map(l -> l.getLibraryPaths()
|
||||
.stream()
|
||||
.map(LibraryPathEntity::getId)
|
||||
.max(Long::compareTo)
|
||||
.orElse(0L))
|
||||
.max(Long::compareTo)
|
||||
.orElse(0L)+ 1;
|
||||
LibraryPathEntity libraryPath = LibraryPathEntity.builder()
|
||||
.id(id)
|
||||
.path(path)
|
||||
.build();
|
||||
library.getLibraryPaths().add(libraryPath);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public LibraryTestBuilder addBook(String fileSubPath, String fileName) {
|
||||
fileSubPath = removeLeadingSlash(fileSubPath);
|
||||
|
||||
long id = bookRepository.size() + 1L;
|
||||
BookMetadataEntity metadata = BookMetadataEntity.builder()
|
||||
.title(FilenameUtils.removeExtension(fileName))
|
||||
.bookId(id)
|
||||
.build();
|
||||
|
||||
String hash = computeFileHash(Path.of(fileSubPath, fileName));
|
||||
BookEntity bookEntity = BookEntity.builder()
|
||||
.id(id)
|
||||
.fileName(fileName)
|
||||
.fileSubPath(fileSubPath)
|
||||
.bookType(getBookFileType(fileName))
|
||||
.fileSizeKb(1024L)
|
||||
.library(getLibraryEntity())
|
||||
.libraryPath(getLibraryEntity().getLibraryPaths().getLast())
|
||||
.addedOn(java.time.Instant.now())
|
||||
.initialHash(hash)
|
||||
.currentHash(hash)
|
||||
.metadata(metadata)
|
||||
.additionalFiles(new ArrayList<>())
|
||||
.build();
|
||||
|
||||
bookRepository.put(bookEntity.getId(), bookEntity);
|
||||
bookMap.put(metadata.getTitle(), bookEntity);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public LibraryTestBuilder addLibraryFile(String fileSubPath, String fileName, String hash) {
|
||||
addLibraryFile(fileSubPath, fileName);
|
||||
|
||||
var lastLibraryFiles = libraryFiles.getLast();
|
||||
|
||||
Path filePath = Path.of(lastLibraryFiles.getLibraryPathEntity().getPath(), fileSubPath, fileName);
|
||||
if (libraryFileHashes.containsKey(filePath)) {
|
||||
throw new IllegalArgumentException("File with the same path and name already exists: " + fileSubPath + "/" + fileName);
|
||||
}
|
||||
libraryFileHashes.put(filePath, hash);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public LibraryTestBuilder addLibraryFile(String fileSubPath, String fileName) {
|
||||
if (libraries.isEmpty()) {
|
||||
throw new IllegalStateException("No library available to add a book. Please add a library first.");
|
||||
}
|
||||
|
||||
var library = libraries.getLast();
|
||||
if (library.getLibraryPaths().isEmpty()) {
|
||||
throw new IllegalStateException("No library path available to add a book. Please add a path first.");
|
||||
}
|
||||
var libraryPath = library.getLibraryPaths().getLast();
|
||||
|
||||
// Really don't want to check if there is subpath in library paths
|
||||
var libraryFile = LibraryFile.builder()
|
||||
.libraryPathEntity(libraryPath)
|
||||
.fileSubPath(fileSubPath)
|
||||
.fileName(fileName)
|
||||
.bookFileType(getBookFileType(fileName))
|
||||
.build();
|
||||
|
||||
libraryFiles.add(libraryFile);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private @NotNull String computeFileHash(Path path) {
|
||||
if (libraryFileHashes.containsKey(path)) {
|
||||
return libraryFileHashes.get(path);
|
||||
}
|
||||
|
||||
MessageDigest digest;
|
||||
try {
|
||||
digest = MessageDigest.getInstance("MD5");
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
byte[] hash = digest.digest(path.getFileName().toString().getBytes());
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hash) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) hexString.append('0');
|
||||
hexString.append(hex);
|
||||
}
|
||||
return hexString.toString();
|
||||
}
|
||||
|
||||
private Book processFile(LibraryFile libraryFile) {
|
||||
var hash = computeFileHash(libraryFile.getFullPath());
|
||||
|
||||
long id = libraryFiles.indexOf(libraryFile) + 1L;
|
||||
BookMetadataEntity metadata = BookMetadataEntity.builder()
|
||||
.title(FilenameUtils.removeExtension(libraryFile.getFileName()))
|
||||
.bookId(id)
|
||||
.build();
|
||||
BookEntity bookEntity = BookEntity.builder()
|
||||
.id(id) // Simple ID generation based on index
|
||||
.fileName(libraryFile.getFileName())
|
||||
.fileSubPath(libraryFile.getFileSubPath())
|
||||
.bookType(libraryFile.getBookFileType())
|
||||
.fileSizeKb(1024L)
|
||||
.library(libraryFile.getLibraryPathEntity().getLibrary())
|
||||
.libraryPath(libraryFile.getLibraryPathEntity())
|
||||
.addedOn(java.time.Instant.now())
|
||||
.initialHash(hash)
|
||||
.currentHash(hash)
|
||||
.metadata(metadata)
|
||||
.additionalFiles(new ArrayList<>())
|
||||
.build();
|
||||
|
||||
bookRepository.put(bookEntity.getId(), bookEntity);
|
||||
bookMap.put(metadata.getTitle(), bookEntity);
|
||||
|
||||
return BOOK_MAPPER.toBook(bookEntity);
|
||||
}
|
||||
|
||||
private BookEntity getBookById(Long bookId) {
|
||||
if (!bookRepository.containsKey(bookId)) {
|
||||
throw new IllegalStateException("No book found with ID: " + bookId);
|
||||
}
|
||||
return bookRepository.get(bookId);
|
||||
}
|
||||
|
||||
private @NotNull BookAdditionalFileEntity saveBookAdditionalFile(BookAdditionalFileEntity additionalFile) {
|
||||
if (additionalFile.getId() != null) {
|
||||
throw new IllegalArgumentException("ID must be null for new additional files");
|
||||
}
|
||||
|
||||
// Do not allow files with duplicate hashes for alternative formats only
|
||||
if (additionalFile.getAdditionalFileType() == AdditionalFileType.ALTERNATIVE_FORMAT &&
|
||||
bookAdditionalFileRepository.values()
|
||||
.stream()
|
||||
.anyMatch(existingFile -> existingFile.getCurrentHash()
|
||||
.equals(additionalFile.getCurrentHash()))) {
|
||||
throw new IllegalArgumentException("File with the same hash already exists: " + additionalFile.getCurrentHash());
|
||||
}
|
||||
|
||||
additionalFile.setId((long) bookAdditionalFileRepository.size() + 1);
|
||||
bookAdditionalFileRepository.put(additionalFile.getId(), additionalFile);
|
||||
return additionalFile;
|
||||
}
|
||||
|
||||
private static BookFileType getBookFileType(String fileName) {
|
||||
var extension = BookFileExtension.fromFileName(fileName);
|
||||
return extension.map(BookFileExtension::getType).orElse(null);
|
||||
}
|
||||
|
||||
private static String removeLeadingSlash(String path) {
|
||||
return path.startsWith("/") ? path.substring(1) : path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.adityachandel.booklore.util.builder;
|
||||
|
||||
import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity;
|
||||
import com.adityachandel.booklore.model.enums.AdditionalFileType;
|
||||
import com.adityachandel.booklore.model.enums.BookFileExtension;
|
||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import org.assertj.core.api.AbstractAssert;
|
||||
import org.assertj.core.api.Assertions;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public class LibraryTestBuilderAssert extends AbstractAssert<LibraryTestBuilderAssert, LibraryTestBuilder> {
|
||||
|
||||
protected LibraryTestBuilderAssert(LibraryTestBuilder libraryTestBuilder) {
|
||||
super(libraryTestBuilder, LibraryTestBuilderAssert.class);
|
||||
}
|
||||
|
||||
public static LibraryTestBuilderAssert assertThat(LibraryTestBuilder actual) {
|
||||
return new LibraryTestBuilderAssert(actual);
|
||||
}
|
||||
|
||||
public LibraryTestBuilderAssert hasBooks(String ...expectedBookTitles) {
|
||||
Assertions.assertThat(actual.getBookEntities())
|
||||
.extracting(bookEntity -> bookEntity.getMetadata().getTitle())
|
||||
.containsExactlyInAnyOrder(expectedBookTitles);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public LibraryTestBuilderAssert hasNoAdditionalFiles() {
|
||||
var additionalFiles = actual.getBookAdditionalFiles();
|
||||
Assertions.assertThat(additionalFiles)
|
||||
.isEmpty();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public LibraryTestBuilderAssert bookHasAdditionalFormats(String bookTitle, BookFileType ...additionalFormatTypes) {
|
||||
var book = actual.getBookEntity(bookTitle);
|
||||
Assertions.assertThat(book)
|
||||
.describedAs("Book with title '%s' should exist", bookTitle)
|
||||
.isNotNull();
|
||||
|
||||
Assertions.assertThat(book.getAdditionalFiles()
|
||||
.stream()
|
||||
.filter(a -> a.getAdditionalFileType() == AdditionalFileType.ALTERNATIVE_FORMAT)
|
||||
.map(BookAdditionalFileEntity::getFileName)
|
||||
.map(BookFileExtension::fromFileName)
|
||||
.filter(Optional::isPresent)
|
||||
.map(Optional::get)
|
||||
.map(BookFileExtension::getType))
|
||||
.describedAs("Book '%s' should have additional formats: %s", bookTitle, additionalFormatTypes)
|
||||
.containsExactlyInAnyOrder(additionalFormatTypes);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public LibraryTestBuilderAssert bookHasSupplementaryFiles(String bookTitle, String ...supplementaryFiles) {
|
||||
var book = actual.getBookEntity(bookTitle);
|
||||
Assertions.assertThat(book)
|
||||
.describedAs("Book with title '%s' should exist", bookTitle)
|
||||
.isNotNull();
|
||||
|
||||
Assertions.assertThat(book.getAdditionalFiles()
|
||||
.stream()
|
||||
.filter(a -> a.getAdditionalFileType() == AdditionalFileType.SUPPLEMENTARY)
|
||||
.map(BookAdditionalFileEntity::getFileName))
|
||||
.describedAs("Book '%s' should have supplementary files", bookTitle)
|
||||
.containsExactlyInAnyOrder(supplementaryFiles);
|
||||
|
||||
var additionalFiles = actual.getBookAdditionalFiles();
|
||||
Assertions.assertThat(additionalFiles)
|
||||
.describedAs("Book '%s' should have supplementary files", bookTitle)
|
||||
.anyMatch(a -> a.getAdditionalFileType() == AdditionalFileType.SUPPLEMENTARY &&
|
||||
a.getBook().getId().equals(book.getId()) &&
|
||||
a.getFileName().equals(supplementaryFiles[0]));
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public LibraryTestBuilderAssert bookHasNoAdditionalFormats(String bookTitle) {
|
||||
var book = actual.getBookEntity(bookTitle);
|
||||
Assertions.assertThat(book)
|
||||
.describedAs("Book with title '%s' should exist", bookTitle)
|
||||
.isNotNull();
|
||||
|
||||
Assertions.assertThat(book.getAdditionalFiles()
|
||||
.stream()
|
||||
.filter(a -> a.getAdditionalFileType() == AdditionalFileType.ALTERNATIVE_FORMAT))
|
||||
.describedAs("Book '%s' should have no additional formats", bookTitle)
|
||||
.isEmpty();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public LibraryTestBuilderAssert bookHasNoSupplementaryFiles(String bookTitle) {
|
||||
var book = actual.getBookEntity(bookTitle);
|
||||
Assertions.assertThat(book)
|
||||
.describedAs("Book with title '%s' should exist", bookTitle)
|
||||
.isNotNull();
|
||||
|
||||
Assertions.assertThat(book.getAdditionalFiles())
|
||||
.describedAs("Book '%s' should have no supplementary files", bookTitle)
|
||||
.noneMatch(a -> a.getAdditionalFileType() == AdditionalFileType.SUPPLEMENTARY);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public LibraryTestBuilderAssert bookHasNoAdditionalFiles(String bookTitle) {
|
||||
var book = actual.getBookEntity(bookTitle);
|
||||
Assertions.assertThat(book)
|
||||
.describedAs("Book with title '%s' should exist", bookTitle)
|
||||
.isNotNull();
|
||||
|
||||
Assertions.assertThat(book.getAdditionalFiles())
|
||||
.describedAs("Book '%s' should have no additional files", bookTitle)
|
||||
.isEmpty();
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,37 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<label class="self-center">Scan Mode</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<p-select
|
||||
[(ngModel)]="scanMode"
|
||||
[options]="scanModeOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Select scan mode"
|
||||
class="w-full" />
|
||||
<i
|
||||
class="pi pi-info-circle text-sky-600 cursor-pointer"
|
||||
pTooltip="File as Book: Each file is a separate book. Folder as Book: Each folder contains one book in multiple formats."
|
||||
tooltipPosition="right"></i>
|
||||
</div>
|
||||
|
||||
<label class="self-center">Default Format</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<p-select
|
||||
[(ngModel)]="defaultBookFormat"
|
||||
[options]="bookFormatOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Select default format"
|
||||
class="w-full"
|
||||
[disabled]="scanMode !== 'FOLDER_AS_BOOK'" />
|
||||
<i
|
||||
class="pi pi-info-circle text-sky-600 cursor-pointer"
|
||||
pTooltip="When a folder contains multiple book formats, this format will be used as the primary book file. Only applicable in 'Folder as Book' mode."
|
||||
tooltipPosition="right"></i>
|
||||
</div>
|
||||
|
||||
<label class="self-center">Monitor Folders</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<p-toggleswitch [(ngModel)]="watch" />
|
||||
|
||||
@@ -9,16 +9,17 @@ import {TableModule} from 'primeng/table';
|
||||
import {Step, StepList, StepPanel, StepPanels, Stepper} from 'primeng/stepper';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {InputText} from 'primeng/inputtext';
|
||||
import {Library} from '../../model/library.model';
|
||||
import {Library, LibraryScanMode, BookFileType} from '../../model/library.model';
|
||||
import {ToggleSwitch} from 'primeng/toggleswitch';
|
||||
import {Tooltip} from 'primeng/tooltip';
|
||||
import { IconPickerService } from '../../../utilities/services/icon-picker.service';
|
||||
import {Select} from 'primeng/select';
|
||||
|
||||
@Component({
|
||||
selector: 'app-library-creator',
|
||||
standalone: true,
|
||||
templateUrl: './library-creator.component.html',
|
||||
imports: [Button, TableModule, StepPanel, FormsModule, InputText, Stepper, StepList, Step, StepPanels, ToggleSwitch, Tooltip],
|
||||
imports: [Button, TableModule, StepPanel, FormsModule, InputText, Stepper, StepList, Step, StepPanels, ToggleSwitch, Tooltip, Select],
|
||||
styleUrl: './library-creator.component.scss'
|
||||
})
|
||||
export class LibraryCreatorComponent implements OnInit {
|
||||
@@ -31,6 +32,20 @@ export class LibraryCreatorComponent implements OnInit {
|
||||
editModeLibraryName: string = '';
|
||||
directoryPickerDialogRef!: DynamicDialogRef<DirectoryPickerComponent>;
|
||||
watch: boolean = false;
|
||||
scanMode: LibraryScanMode = 'FILE_AS_BOOK';
|
||||
defaultBookFormat: BookFileType | undefined = undefined;
|
||||
|
||||
readonly scanModeOptions = [
|
||||
{label: 'Each file is a separate book', value: 'FILE_AS_BOOK'},
|
||||
{label: 'Each folder is a book', value: 'FOLDER_AS_BOOK'}
|
||||
];
|
||||
|
||||
readonly bookFormatOptions = [
|
||||
{label: 'None', value: undefined},
|
||||
{label: 'EPUB', value: 'EPUB'},
|
||||
{label: 'PDF', value: 'PDF'},
|
||||
{label: 'CBX/CBZ/CBR', value: 'CBX'}
|
||||
];
|
||||
|
||||
private dialogService = inject(DialogService);
|
||||
private dynamicDialogRef = inject(DynamicDialogRef);
|
||||
@@ -46,11 +61,13 @@ export class LibraryCreatorComponent implements OnInit {
|
||||
this.mode = data.mode;
|
||||
this.library = this.libraryService.findLibraryById(data.libraryId);
|
||||
if (this.library) {
|
||||
const {name, icon, paths, watch} = this.library;
|
||||
const {name, icon, paths, watch, scanMode, defaultBookFormat} = this.library;
|
||||
this.chosenLibraryName = name;
|
||||
this.editModeLibraryName = name;
|
||||
this.selectedIcon = `pi pi-${icon}`;
|
||||
this.watch = watch;
|
||||
this.scanMode = scanMode || 'FILE_AS_BOOK';
|
||||
this.defaultBookFormat = defaultBookFormat || undefined;
|
||||
this.folders = paths.map(path => path.path);
|
||||
}
|
||||
}
|
||||
@@ -123,7 +140,9 @@ export class LibraryCreatorComponent implements OnInit {
|
||||
name: this.chosenLibraryName,
|
||||
icon: this.selectedIcon?.replace('pi pi-', '') || 'heart',
|
||||
paths: this.folders.map(folder => ({path: folder})),
|
||||
watch: this.watch
|
||||
watch: this.watch,
|
||||
scanMode: this.scanMode,
|
||||
defaultBookFormat: this.defaultBookFormat
|
||||
};
|
||||
this.libraryService.updateLibrary(library, this.library?.id).subscribe({
|
||||
next: () => {
|
||||
@@ -140,7 +159,9 @@ export class LibraryCreatorComponent implements OnInit {
|
||||
name: this.chosenLibraryName,
|
||||
icon: this.selectedIcon?.replace('pi pi-', '') || 'heart',
|
||||
paths: this.folders.map(folder => ({path: folder})),
|
||||
watch: this.watch
|
||||
watch: this.watch,
|
||||
scanMode: this.scanMode,
|
||||
defaultBookFormat: this.defaultBookFormat
|
||||
};
|
||||
this.libraryService.createLibrary(library).subscribe({
|
||||
next: (createdLibrary) => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import {SortOption} from './sort.model';
|
||||
|
||||
export type LibraryScanMode = 'FILE_AS_BOOK' | 'FOLDER_AS_BOOK';
|
||||
export type BookFileType = 'PDF' | 'EPUB' | 'CBX';
|
||||
|
||||
export interface Library {
|
||||
id?: number;
|
||||
name: string;
|
||||
@@ -8,6 +11,8 @@ export interface Library {
|
||||
fileNamingPattern?: string;
|
||||
sort?: SortOption;
|
||||
paths: LibraryPath[];
|
||||
scanMode?: LibraryScanMode;
|
||||
defaultBookFormat?: BookFileType;
|
||||
}
|
||||
|
||||
export interface LibraryPath {
|
||||
|
||||
Reference in New Issue
Block a user