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:
Alexander Puzynia
2025-08-27 07:20:29 -07:00
committed by GitHub
parent f725ececf5
commit 694ee11540
20 changed files with 1692 additions and 31 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
package com.adityachandel.booklore.model.enums;
public enum LibraryScanMode {
FILE_AS_BOOK,
FOLDER_AS_BOOK
}

View File

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

View File

@@ -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) {}
}

View File

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

View File

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

View File

@@ -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("."))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" />

View File

@@ -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) => {

View File

@@ -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 {