Merge pull request #1633 from booklore-app/develop

Merge develop into master for the release
This commit is contained in:
Aditya Chandel
2025-11-25 05:54:57 -07:00
committed by GitHub
102 changed files with 3127 additions and 1960 deletions

View File

@@ -7,28 +7,40 @@
---
# Bug Report Template for Booklore
# 🐛 Bug Report for Booklore
**What happened?**
Please describe the problem or issue you encountered in Booklore.
Thank you for helping us improve Booklore! Please fill out the sections below.
**How can we see it happen?**
Steps to reproduce the issue:
1. Open Booklore and go to ‘…’
2. Do ‘…’ (like adding a book, editing details, etc.)
3. Notice what goes wrong
---
**What did you expect to happen?**
Tell us what you thought should happen instead.
## 📝 What happened?
<!-- Describe the bug in a few sentences -->
**Screenshots or Error Messages**
If you can, please share screenshots or any error messages you saw. It really helps!
**About your setup:**
- Booklore version (e.g., v0.35.0)
- What computer or device are you using? (Windows, Mac, Linux, etc.)
- Which browser and version? (Chrome, Firefox, Safari, etc.)
- How did you install Booklore? (Docker, manual install, etc.)
## 🔄 Steps to Reproduce
<!-- Walk us through the exact steps to see the bug -->
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
**Anything else we should know?**
Any other info that might help us understand the issue better.
## ✅ Expected Behavior
<!-- What should have happened instead? -->
## 📸 Screenshots / Error Messages
<!-- Add screenshots or paste error messages (drag & drop images here) -->
## 💻 Environment
- **Version:** (e.g., v1.1.0)
- **OS:** (e.g., Windows 11, macOS Sonoma, Ubuntu 22.04)
- **Browser:** (e.g., Chrome 120, Firefox 121, Safari 17)
- **Installation:** (e.g., Docker, Unraid, Manual)
## 📌 Additional Context
<!-- Recent changes? Specific books? Anything else that might help? -->
## ✨ Possible Solution _(Optional)_
<!-- Have ideas on how to fix this? Share them here! -->

View File

@@ -6,13 +6,6 @@ # BookLore
![Docker Pulls](https://img.shields.io/docker/pulls/booklore/booklore?color=2496ED)
[![Join us on Discord](https://img.shields.io/badge/Chat-Discord-5865F2?logo=discord&style=flat)](https://discord.gg/Ee5hd458Uz)
[![Open Collective backers and sponsors](https://img.shields.io/opencollective/all/booklore?label=Open%20Collective&logo=opencollective&color=7FADF2)](https://opencollective.com/booklore)
> 🚨 **Important Announcement:**
> Docker images have moved to new repositories:
> - Docker Hub: `https://hub.docker.com/r/booklore/booklore`
> - GitHub Container Registry: `https://ghcr.io/booklore-app/booklore`
>
> The legacy repo (`https://ghcr.io/adityachandelgit/booklore-app`) will remain available for existing images but will not receive further updates.
BookLore is a self-hosted web app for organizing and managing your personal book collection. It provides an intuitive interface to browse, read, and track your progress across PDFs and eBooks. With robust metadata management, multi-user support, and a sleek, modern UI, BookLore makes it easy to
build and explore your personal library.
@@ -44,7 +37,6 @@ ## 💖 Support the Project
- 💸 Contribute via [Open Collective](https://opencollective.com/booklore) to help fund development, hosting, and testing costs.
> 📌 Currently raising funds for a **Kobo device** to implement and test native Kobo sync support.
> 💡 [Support the Kobo Sync Bounty →](https://opencollective.com/booklore/projects/kobo-device-for-testing)
- ⚡ Prefer one-time support? You can also donate via [Venmo](https://venmo.com/AdityaChandel).
## 🌐 Live Demo: Explore BookLore in Action
@@ -71,9 +63,6 @@ ## 🚀 Getting Started with BookLore
> 💡 **Want to improve the documentation?**
> You can update the docs at [booklore-app/booklore-docs](https://github.com/booklore-app/booklore-docs) and create a pull request to contribute your changes!
🎥 [BookLore Tutorials: YouTube](https://www.youtube.com/watch?v=UMrn_fIeFRo&list=PLi0fq0zaM7lqY7dX0R66jQtKW64z4_Tdz)
These older videos provide useful walkthroughs and visual guidance, but note that some content may be outdated compared to the current docs.
## 🐳 Deploy with Docker
You can quickly set up and run BookLore using Docker.
@@ -202,26 +191,6 @@ ### ⚙️ Configuration (Docker Setup)
- ./bookdrop:/bookdrop # 👈 Bookdrop directory
```
## 🔑 OIDC/OAuth2 Authentication (Authentik, Pocket ID, etc.)
BookLore supports optional OIDC/OAuth2 authentication for secure access. This feature allows you to integrate external authentication providers for a seamless login experience.
While the integration has been tested with **Authentik** and **Pocket ID**, it should work with other OIDC providers like **Authelia** as well. The setup allows you to use either JWT-based local authentication or external providers, giving users the flexibility to choose their preferred method.
For detailed instructions on setting up OIDC authentication:
- 📺 [YouTube video on configuring Authentik with BookLore](https://www.youtube.com/watch?v=r6Ufh9ldF9M)
- 📘 [Step-by-step setup guide for Pocket ID](docs/OIDC-Setup-With-PocketID.md)
## 🛡️ Forward Auth with Reverse Proxy
BookLore also supports **Forward Auth** (also known as Remote Auth) for authentication through reverse proxies like **Traefik**, **Nginx**, or **Caddy**. Forward Auth works by having your reverse proxy handle authentication and pass user information via HTTP headers to BookLore. This can be set up
with providers like **Authelia** and **Authentik**.
For detailed setup instructions and configuration examples:
- 📘 [Complete Forward Auth Setup Guide](docs/forward-auth-with-proxy.md)
## 🤝 Community & Support
- 🐞 Found a bug? [Open an issue](https://github.com/adityachandelgit/BookLore/issues)

View File

@@ -2,15 +2,26 @@ package com.adityachandel.booklore.config;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.socket.config.WebSocketMessageBrokerStats;
import java.time.Duration;
@Configuration
public class BeanConfig {
@Autowired
private WebSocketMessageBrokerStats webSocketMessageBrokerStats;
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder.connectTimeout(Duration.ofSeconds(10)).readTimeout(Duration.ofSeconds(15))
.build();
}
@PostConstruct
public void init() {
webSocketMessageBrokerStats.setLoggingPeriod(30 * 24 * 60 * 60 * 1000L); // 30 days

View File

@@ -1,15 +0,0 @@
package com.adityachandel.booklore.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@Builder
@Getter
@AllArgsConstructor
public class DuplicateFileInfo {
private final Long bookId;
private final String fileName;
private final String fullPath;
private final String hash;
}

View File

@@ -14,6 +14,4 @@ import lombok.ToString;
public class FileProcessResult {
private final Book book;
private final FileProcessStatus status;
@Builder.Default
private final DuplicateFileInfo duplicate = null;
}

View File

@@ -1,20 +0,0 @@
package com.adityachandel.booklore.model.dto;
import lombok.*;
import java.time.Instant;
@NoArgsConstructor
@AllArgsConstructor
@Setter
@Getter
@Builder
public class DuplicateFileNotification {
private long libraryId;
private String libraryName;
private long fileId;
private String fileName;
private String fullPath;
private String hash;
private Instant timestamp;
}

View File

@@ -25,5 +25,6 @@ public class KoboSnapshotBookEntity {
private Long bookId;
@Column(nullable = false)
@Builder.Default
private boolean synced = false;
}

View File

@@ -23,9 +23,11 @@ public class KoboUserSettingsEntity {
private String token;
@Column(name = "sync_enabled")
@Builder.Default
private boolean syncEnabled = true;
@Column(name = "progress_mark_as_reading_threshold")
@Builder.Default
private Float progressMarkAsReadingThreshold = 1f;
@Column(name = "progress_mark_as_finished_threshold")

View File

@@ -28,12 +28,14 @@ public class KoreaderUserEntity {
private String passwordMD5;
@Column(name = "created_at", nullable = false, updatable = false)
@Builder.Default
private Instant createdAt = Instant.now();
@Column(name = "updated_at")
private Instant updatedAt;
@Column(name = "sync_enabled", nullable = false)
@Builder.Default
private boolean syncEnabled = false;
@OneToOne(fetch = FetchType.LAZY)

View File

@@ -37,9 +37,11 @@ public class MagicShelfEntity {
private boolean isPublic = false;
@Column(name = "created_at", nullable = false, updatable = false)
@lombok.Builder.Default
private LocalDateTime createdAt = LocalDateTime.now();
@Column(name = "updated_at", nullable = false)
@lombok.Builder.Default
private LocalDateTime updatedAt = LocalDateTime.now();
@PreUpdate

View File

@@ -29,6 +29,7 @@ public class RefreshTokenEntity {
private Instant expiryDate;
@Column(nullable = false)
@Builder.Default
private boolean revoked = false;
@Column(name = "revocation_date")

View File

@@ -21,30 +21,39 @@ public class UserPermissionsEntity {
private BookLoreUserEntity user;
@Column(name = "permission_upload", nullable = false)
@Builder.Default
private boolean permissionUpload = false;
@Column(name = "permission_download", nullable = false)
@Builder.Default
private boolean permissionDownload = false;
@Column(name = "permission_edit_metadata", nullable = false)
@Builder.Default
private boolean permissionEditMetadata = false;
@Column(name = "permission_manipulate_library", nullable = false)
@Builder.Default
private boolean permissionManipulateLibrary = false;
@Column(name = "permission_email_book", nullable = false)
@Builder.Default
private boolean permissionEmailBook = false;
@Column(name = "permission_delete_book", nullable = false)
@Builder.Default
private boolean permissionDeleteBook = false;
@Column(name = "permission_sync_koreader", nullable = false)
@Builder.Default
private boolean permissionSyncKoreader = false;
@Column(name = "permission_access_opds", nullable = false)
@Builder.Default
private boolean permissionAccessOpds = false;
@Column(name = "permission_sync_kobo", nullable = false)
@Builder.Default
private boolean permissionSyncKobo = false;
@Column(name = "permission_admin", nullable = false)

View File

@@ -2,6 +2,5 @@ package com.adityachandel.booklore.model.enums;
public enum FileProcessStatus {
NEW,
DUPLICATE,
UPDATED
}

View File

@@ -13,7 +13,6 @@ public enum Topic {
BOOK_METADATA_BATCH_UPDATE("/queue/book-metadata-batch-update"),
BOOK_METADATA_BATCH_PROGRESS("/queue/book-metadata-batch-progress"),
BOOKDROP_FILE("/queue/bookdrop-file"),
DUPLICATE_FILE("/queue/duplicate-file"),
LOG("/queue/log"),
TASK_PROGRESS("/queue/task-progress");

View File

@@ -15,6 +15,8 @@ public interface AuthorRepository extends JpaRepository<AuthorEntity, Long> {
Optional<AuthorEntity> findByName(String name);
Optional<AuthorEntity> findByNameIgnoreCase(String name);
@Query("SELECT a FROM AuthorEntity a JOIN a.bookMetadataEntityList bm WHERE bm.bookId = :bookId")
List<AuthorEntity> findAuthorsByBookId(@Param("bookId") Long bookId);
}

View File

@@ -7,7 +7,6 @@ import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import org.springframework.security.core.parameters.P;
import org.springframework.stereotype.Repository;
import java.time.Instant;
@@ -24,8 +23,6 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
Optional<BookEntity> findByCurrentHash(String currentHash);
Optional<BookEntity> findByCurrentHashAndDeletedTrue(String currentHash);
@Query("SELECT b.id FROM BookEntity b WHERE b.library.id = :libraryId AND (b.deleted IS NULL OR b.deleted = false)")
Set<Long> findBookIdsByLibraryId(@Param("libraryId") long libraryId);
@@ -43,10 +40,6 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
@Query("SELECT b FROM BookEntity b WHERE (b.deleted IS NULL OR b.deleted = false)")
List<BookEntity> findAllWithMetadata();
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"})
@Query(value = "SELECT b FROM BookEntity b WHERE (b.deleted IS NULL OR b.deleted = false)")
Page<BookEntity> findAllWithMetadata(Pageable pageable);
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"})
@Query("SELECT b FROM BookEntity b WHERE b.id IN :bookIds AND (b.deleted IS NULL OR b.deleted = false)")
List<BookEntity> findAllWithMetadataByIds(@Param("bookIds") Set<Long> bookIds);

View File

@@ -4,6 +4,8 @@ import com.adityachandel.booklore.config.AppProperties;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.mapper.BookdropFileMapper;
import com.adityachandel.booklore.model.FileProcessResult;
import com.adityachandel.booklore.model.MetadataUpdateContext;
import com.adityachandel.booklore.model.MetadataUpdateWrapper;
import com.adityachandel.booklore.model.dto.BookMetadata;
import com.adityachandel.booklore.model.dto.BookdropFile;
import com.adityachandel.booklore.model.dto.BookdropFileNotification;
@@ -17,6 +19,7 @@ import com.adityachandel.booklore.model.entity.LibraryEntity;
import com.adityachandel.booklore.model.entity.LibraryPathEntity;
import com.adityachandel.booklore.model.enums.BookFileExtension;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.model.enums.MetadataReplaceMode;
import com.adityachandel.booklore.model.websocket.Topic;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.repository.BookdropFileRepository;
@@ -431,7 +434,19 @@ public class BookDropService {
.orElseThrow(() -> ApiError.FILE_NOT_FOUND.createException("Book ID missing after import"));
notificationService.sendMessage(Topic.BOOK_ADD, fileProcessResult.getBook());
metadataRefreshService.updateBookMetadata(bookEntity, metadata, metadata.getThumbnailUrl() != null, false);
MetadataUpdateContext context = MetadataUpdateContext.builder()
.bookEntity(bookEntity)
.metadataUpdateWrapper(MetadataUpdateWrapper.builder()
.metadata(metadata)
.build())
.updateThumbnail(metadata.getThumbnailUrl() != null)
.mergeCategories(false)
.replaceMode(MetadataReplaceMode.REPLACE_ALL)
.mergeMoods(true)
.mergeTags(true)
.build();
metadataRefreshService.updateBookMetadata(context);
cleanupBookdropData(bookdropFile);

View File

@@ -4,7 +4,6 @@ import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.model.dto.Book;
import com.adityachandel.booklore.model.dto.BookMetadata;
import com.adityachandel.booklore.model.dto.request.MetadataRefreshOptions;
import com.adityachandel.booklore.model.dto.request.MetadataRefreshRequest;
import com.adityachandel.booklore.model.dto.settings.AppSettings;
import com.adityachandel.booklore.model.entity.BookdropFileEntity;
import com.adityachandel.booklore.model.enums.BookFileExtension;
@@ -106,7 +105,7 @@ public class BookdropMetadataService {
byte[] coverBytes = metadataExtractorFactory.extractCover(fileExt, file);
if (coverBytes != null) {
try {
fileService.saveImage(coverBytes, fileService.getTempBookdropCoverImagePath(entity.getId()));
FileService.saveImage(coverBytes, fileService.getTempBookdropCoverImagePath(entity.getId()));
} catch (IOException e) {
log.warn("Failed to save extracted cover for file: {}", entity.getFilePath(), e);
}

View File

@@ -9,6 +9,7 @@ import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.nio.file.Paths;
@@ -33,7 +34,10 @@ public class BackgroundUploadService {
String extension = getFileExtension(originalFilename);
String filename = "1." + extension;
BufferedImage originalImage = ImageIO.read(file.getInputStream());
BufferedImage originalImage;
try (InputStream inputStream = file.getInputStream()) {
originalImage = ImageIO.read(inputStream);
}
if (originalImage == null) {
throw new IllegalArgumentException("Invalid image file");
}
@@ -41,7 +45,7 @@ public class BackgroundUploadService {
deleteExistingBackgroundFiles(userId);
fileService.saveBackgroundImage(originalImage, filename, userId);
String fileUrl = fileService.getBackgroundUrl(filename, userId);
String fileUrl = FileService.getBackgroundUrl(filename, userId);
return new UploadResponse(fileUrl);
} catch (Exception e) {
log.error("Failed to upload background file: {}", e.getMessage(), e);
@@ -56,12 +60,12 @@ public class BackgroundUploadService {
String extension = getFileExtension(originalFilename);
String filename = "1." + extension;
BufferedImage originalImage = fileService.downloadImageFromUrl(imageUrl);
BufferedImage originalImage = FileService.downloadImageFromUrl(imageUrl);
deleteExistingBackgroundFiles(userId);
fileService.saveBackgroundImage(originalImage, filename, userId);
String fileUrl = fileService.getBackgroundUrl(filename, userId);
String fileUrl = FileService.getBackgroundUrl(filename, userId);
return new UploadResponse(fileUrl);
} catch (Exception e) {
log.error("Failed to upload background from URL: {}", e.getMessage(), e);

View File

@@ -31,10 +31,37 @@ public class FileMoveHelper {
if (target.getParent() != null) {
Files.createDirectories(target.getParent());
}
log.info("Moving file from {} to {}", source, target);
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
}
public Path moveFileWithBackup(Path source, Path target) throws IOException {
Path tempPath = source.resolveSibling(source.getFileName().toString() + ".tmp_move");
log.info("Moving file from {} to temporary location {}", source, tempPath);
Files.move(source, tempPath, StandardCopyOption.REPLACE_EXISTING);
return tempPath;
}
public void commitMove(Path tempPath, Path target) throws IOException {
if (target.getParent() != null) {
Files.createDirectories(target.getParent());
}
log.info("Committing move from temporary location {} to {}", tempPath, target);
Files.move(tempPath, target, StandardCopyOption.REPLACE_EXISTING);
}
public void rollbackMove(Path tempPath, Path originalSource) {
if (Files.exists(tempPath)) {
try {
log.info("Rolling back move from {} to {}", tempPath, originalSource);
Files.move(tempPath, originalSource, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
log.error("Failed to rollback file move from {} to {}", tempPath, originalSource, e);
}
}
}
public String extractSubPath(Path filePath, LibraryPathEntity libraryPathEntity) {
Path libraryRoot = Paths.get(libraryPathEntity.getPath()).toAbsolutePath().normalize();
Path parentDir = filePath.getParent().toAbsolutePath().normalize();

View File

@@ -50,6 +50,9 @@ public class FileMoveService {
Long targetLibraryId = move.getTargetLibraryId();
Long targetLibraryPathId = move.getTargetLibraryPathId();
Path tempPath = null;
Path currentFilePath = null;
try {
Optional<BookEntity> optionalBook = bookRepository.findById(bookId);
Optional<LibraryEntity> optionalLibrary = libraryRepository.findById(targetLibraryId);
@@ -70,18 +73,22 @@ public class FileMoveService {
monitoringRegistrationService.unregisterLibraries(Collections.singleton(sourceLibrary.getId()));
sourceLibraryIds.add(sourceLibrary.getId());
}
Path currentFilePath = bookEntity.getFullFilePath();
currentFilePath = bookEntity.getFullFilePath();
String pattern = fileMoveHelper.getFileNamingPattern(targetLibrary);
Path newFilePath = fileMoveHelper.generateNewFilePath(bookEntity, libraryPathEntity, pattern);
if (currentFilePath.equals(newFilePath)) {
continue;
}
fileMoveHelper.moveFile(currentFilePath, newFilePath);
tempPath = fileMoveHelper.moveFileWithBackup(currentFilePath, newFilePath);
String newFileName = newFilePath.getFileName().toString();
String newFileSubPath = fileMoveHelper.extractSubPath(newFilePath, libraryPathEntity);
bookRepository.updateFileAndLibrary(bookEntity.getId(), newFileSubPath, newFileName, targetLibrary.getId(), libraryPathEntity);
fileMoveHelper.commitMove(tempPath, newFilePath);
tempPath = null;
Path libraryRoot = Paths.get(bookEntity.getLibraryPath().getPath()).toAbsolutePath().normalize();
fileMoveHelper.deleteEmptyParentDirsUpToLibraryFolders(currentFilePath.getParent(), Set.of(libraryRoot));
@@ -93,6 +100,10 @@ public class FileMoveService {
} catch (Exception e) {
log.error("Error moving file for book ID {}: {}", bookId, e.getMessage(), e);
} finally {
if (tempPath != null && currentFilePath != null) {
fileMoveHelper.rollbackMove(tempPath, currentFilePath);
}
}
}

View File

@@ -1,7 +1,6 @@
package com.adityachandel.booklore.service.fileprocessor;
import com.adityachandel.booklore.mapper.BookMapper;
import com.adityachandel.booklore.model.DuplicateFileInfo;
import com.adityachandel.booklore.model.FileProcessResult;
import com.adityachandel.booklore.model.dto.Book;
import com.adityachandel.booklore.model.dto.settings.LibraryFile;
@@ -13,15 +12,11 @@ import com.adityachandel.booklore.service.book.BookCreatorService;
import com.adityachandel.booklore.service.file.FileFingerprint;
import com.adityachandel.booklore.service.metadata.MetadataMatchService;
import com.adityachandel.booklore.util.FileService;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.nio.file.Path;
import java.util.Objects;
import java.util.Optional;
@Slf4j
public abstract class AbstractFileProcessor implements BookFileProcessor {
@@ -32,8 +27,6 @@ public abstract class AbstractFileProcessor implements BookFileProcessor {
protected final BookMapper bookMapper;
protected final MetadataMatchService metadataMatchService;
protected final FileService fileService;
@PersistenceContext
private EntityManager entityManager;
protected AbstractFileProcessor(BookRepository bookRepository,
@@ -54,95 +47,9 @@ public abstract class AbstractFileProcessor implements BookFileProcessor {
@Override
public FileProcessResult processFile(LibraryFile libraryFile) {
Path path = libraryFile.getFullPath();
String fileName = path.getFileName().toString();
String hash = FileFingerprint.generateHash(path);
Optional<Book> duplicate = fileService.checkForDuplicateAndUpdateMetadataIfNeeded(libraryFile, hash, bookRepository, bookAdditionalFileRepository, bookMapper);
if (duplicate.isPresent()) {
return handleDuplicate(duplicate.get(), libraryFile, hash);
}
Long libraryId = libraryFile.getLibraryEntity().getId();
return bookRepository.findBookByFileNameAndLibraryId(fileName, libraryId)
.map(bookMapper::toBook)
.map(b -> new FileProcessResult(b, FileProcessStatus.DUPLICATE, createDuplicateInfo(b, libraryFile, hash)))
.orElseGet(() -> {
Book book = createAndMapBook(libraryFile, hash);
return new FileProcessResult(book, FileProcessStatus.NEW, null);
});
}
private FileProcessResult handleDuplicate(Book bookDto, LibraryFile libraryFile, String hash) {
return bookRepository.findById(bookDto.getId())
.map(entity -> {
boolean sameHash = Objects.equals(entity.getCurrentHash(), hash);
boolean sameFileName = Objects.equals(entity.getFileName(), libraryFile.getFileName());
boolean sameSubPath = Objects.equals(entity.getFileSubPath(), libraryFile.getFileSubPath());
boolean sameLibraryPath = Objects.equals(entity.getLibraryPath(), libraryFile.getLibraryPathEntity());
if (sameHash && sameFileName && sameSubPath && sameLibraryPath) {
return new FileProcessResult(
bookDto,
FileProcessStatus.DUPLICATE,
createDuplicateInfo(bookDto, libraryFile, hash)
);
}
boolean folderChanged = !sameSubPath;
boolean updated = false;
if (!sameSubPath) {
entity.setFileSubPath(libraryFile.getFileSubPath());
updated = true;
}
if (!sameFileName) {
entity.setFileName(libraryFile.getFileName());
updated = true;
}
if (!sameLibraryPath) {
entity.setLibraryPath(libraryFile.getLibraryPathEntity());
entity.setLibrary(libraryFile.getLibraryEntity());
updated = true;
}
entity.setCurrentHash(hash);
/*if (folderChanged) {
log.info("Duplicate file found in different folder: bookId={} oldSubPath='{}' newSubPath='{}'",
entity.getId(),
bookDto.getFileSubPath(),
libraryFile.getFileSubPath());
}*/
DuplicateFileInfo dupeInfo = createDuplicateInfo(bookMapper.toBook(entity), libraryFile, hash);
if (updated) {
/*log.info("Duplicate file updated: bookId={} fileName='{}' libraryId={} subPath='{}'",
entity.getId(),
entity.getFileName(),
entity.getLibraryPath().getLibrary().getId(),
entity.getFileSubPath());*/
entityManager.flush();
entityManager.detach(entity);
return new FileProcessResult(bookMapper.toBook(entity), FileProcessStatus.UPDATED, dupeInfo);
} else {
entityManager.detach(entity);
return new FileProcessResult(bookMapper.toBook(entity), FileProcessStatus.DUPLICATE, dupeInfo);
}
})
.orElse(new FileProcessResult(bookDto, FileProcessStatus.DUPLICATE, null));
}
private DuplicateFileInfo createDuplicateInfo(Book book, LibraryFile libraryFile, String hash) {
return new DuplicateFileInfo(
book.getId(),
libraryFile.getFileName(),
libraryFile.getFullPath().toString(),
hash
);
Book book = createAndMapBook(libraryFile, hash);
return new FileProcessResult(book, FileProcessStatus.NEW);
}
private Book createAndMapBook(LibraryFile libraryFile, String hash) {

View File

@@ -61,7 +61,7 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
public BookEntity processNewFile(LibraryFile libraryFile) {
BookEntity bookEntity = bookCreatorService.createShellBook(libraryFile, BookFileType.CBX);
if (generateCover(bookEntity)) {
fileService.setBookCoverPath(bookEntity.getMetadata());
FileService.setBookCoverPath(bookEntity.getMetadata());
}
extractAndSetMetadata(bookEntity);

View File

@@ -54,7 +54,7 @@ public class EpubProcessor extends AbstractFileProcessor implements BookFileProc
BookEntity bookEntity = bookCreatorService.createShellBook(libraryFile, BookFileType.EPUB);
setBookMetadata(bookEntity);
if (generateCover(bookEntity)) {
fileService.setBookCoverPath(bookEntity.getMetadata());
FileService.setBookCoverPath(bookEntity.getMetadata());
}
return bookEntity;
}
@@ -63,7 +63,10 @@ public class EpubProcessor extends AbstractFileProcessor implements BookFileProc
public boolean generateCover(BookEntity bookEntity) {
try {
File epubFile = new File(FileUtils.getBookFullPath(bookEntity));
io.documentnode.epub4j.domain.Book epub = new EpubReader().readEpub(new FileInputStream(epubFile));
io.documentnode.epub4j.domain.Book epub;
try (FileInputStream fis = new FileInputStream(epubFile)) {
epub = new EpubReader().readEpub(fis);
}
Resource coverImage = epub.getCoverImage();
if (coverImage == null) {

View File

@@ -53,7 +53,7 @@ public class PdfProcessor extends AbstractFileProcessor implements BookFileProce
public BookEntity processNewFile(LibraryFile libraryFile) {
BookEntity bookEntity = bookCreatorService.createShellBook(libraryFile, BookFileType.PDF);
if (generateCover(bookEntity)) {
fileService.setBookCoverPath(bookEntity.getMetadata());
FileService.setBookCoverPath(bookEntity.getMetadata());
}
extractAndSetMetadata(bookEntity);
return bookEntity;

View File

@@ -121,9 +121,12 @@ public class KepubConversionService {
}
private String readProcessOutput(InputStream inputStream) {
return new BufferedReader(new InputStreamReader(inputStream))
.lines()
.collect(Collectors.joining("\n"));
try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
return reader.lines().collect(Collectors.joining("\n"));
} catch (Exception e) {
log.warn("Error reading process output: {}", e.getMessage());
return "";
}
}
private void logProcessResults(int exitCode, String output, String error) {

View File

@@ -185,7 +185,9 @@ public class KoboReadingStateService {
if (progressPercent >= finishedThreshold) {
userProgress.setReadStatus(ReadStatus.READ);
userProgress.setDateFinished(Instant.now());
if (userProgress.getDateFinished() == null) {
userProgress.setDateFinished(Instant.now());
}
} else if (progressPercent >= readingThreshold) {
userProgress.setReadStatus(ReadStatus.READING);
} else {

View File

@@ -1,14 +1,10 @@
package com.adityachandel.booklore.service.library;
import com.adityachandel.booklore.model.DuplicateFileInfo;
import com.adityachandel.booklore.model.FileProcessResult;
import com.adityachandel.booklore.model.dto.DuplicateFileNotification;
import com.adityachandel.booklore.model.dto.settings.LibraryFile;
import com.adityachandel.booklore.model.entity.LibraryEntity;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.model.enums.FileProcessStatus;
import com.adityachandel.booklore.model.enums.LibraryScanMode;
import com.adityachandel.booklore.model.websocket.Topic;
import com.adityachandel.booklore.service.NotificationService;
import com.adityachandel.booklore.service.event.BookEventBroadcaster;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor;
@@ -18,7 +14,6 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
@AllArgsConstructor
@@ -44,28 +39,8 @@ public class FileAsBookProcessor implements LibraryFileProcessor {
FileProcessResult result = processLibraryFile(libraryFile);
if (result != null) {
if (result.getDuplicate() != null) {
DuplicateFileInfo dupe = result.getDuplicate();
DuplicateFileNotification notification = DuplicateFileNotification.builder()
.libraryId(libraryEntity.getId())
.libraryName(libraryEntity.getName())
.fileId(dupe.getBookId())
.fileName(dupe.getFileName())
.fullPath(dupe.getFullPath())
.hash(dupe.getHash())
.timestamp(Instant.now())
.build();
log.info("Duplicate file detected: {}", notification);
notificationService.sendMessage(Topic.DUPLICATE_FILE, notification);
}
if (result.getStatus() != FileProcessStatus.DUPLICATE) {
bookEventBroadcaster.broadcastBookAddEvent(result.getBook());
log.info("Processed file: {}", libraryFile.getFileName());
}
bookEventBroadcaster.broadcastBookAddEvent(result.getBook());
log.info("Processed file: {}", libraryFile.getFileName());
}
}

View File

@@ -88,7 +88,11 @@ public class MetadataManagementService {
private void consolidateAuthors(List<String> targetValues, List<String> valuesToMerge, boolean writeToFile, boolean moveFile) {
List<AuthorEntity> targetAuthors = targetValues.stream()
.map(name -> authorRepository.findByName(name)
.map(name -> authorRepository.findByNameIgnoreCase(name)
.map(existing -> {
existing.setName(name);
return authorRepository.save(existing);
})
.orElseGet(() -> {
AuthorEntity author = new AuthorEntity();
author.setName(name);
@@ -97,7 +101,7 @@ public class MetadataManagementService {
.toList();
List<AuthorEntity> authorsToMerge = valuesToMerge.stream()
.map(authorRepository::findByName)
.map(authorRepository::findByNameIgnoreCase)
.filter(java.util.Optional::isPresent)
.map(java.util.Optional::get)
.toList();

View File

@@ -273,80 +273,105 @@ public class MetadataRefreshService {
public void updateBookMetadata(BookEntity bookEntity, BookMetadata metadata, boolean replaceCover, boolean mergeCategories) {
if (metadata != null) {
updateBookMetadata(bookEntity, metadata, replaceCover, mergeCategories, MetadataReplaceMode.REPLACE_MISSING);
}
MetadataUpdateContext context = MetadataUpdateContext.builder()
.bookEntity(bookEntity)
.metadataUpdateWrapper(MetadataUpdateWrapper.builder()
.metadata(metadata)
.build())
.updateThumbnail(replaceCover)
.mergeCategories(mergeCategories)
.replaceMode(MetadataReplaceMode.REPLACE_MISSING)
.mergeMoods(true)
.mergeTags(true)
.build();
public void updateBookMetadata(BookEntity bookEntity, BookMetadata metadata, boolean replaceCover, boolean mergeCategories, MetadataReplaceMode replaceMode) {
MetadataUpdateContext context = MetadataUpdateContext.builder()
.bookEntity(bookEntity)
.metadataUpdateWrapper(MetadataUpdateWrapper.builder()
.metadata(metadata)
.build())
.updateThumbnail(replaceCover)
.mergeCategories(mergeCategories)
.replaceMode(replaceMode)
.mergeMoods(true)
.mergeTags(true)
.build();
updateBookMetadata(context);
}
public void updateBookMetadata(MetadataUpdateContext context) {
if (context.getMetadataUpdateWrapper() != null && context.getMetadataUpdateWrapper().getMetadata() != null) {
bookMetadataUpdater.setBookMetadata(context);
Book book = bookMapper.toBook(bookEntity);
Book book = bookMapper.toBook(context.getBookEntity());
notificationService.sendMessage(Topic.BOOK_METADATA_UPDATE, book);
}
}
public List<MetadataProvider> prepareProviders(MetadataRefreshOptions refreshOptions) {
AppSettings appSettings = appSettingService.getAppSettings();
Set<MetadataProvider> allProviders = EnumSet.noneOf(MetadataProvider.class);
allProviders.addAll(getAllProvidersUsingIndividualFields(refreshOptions));
allProviders.addAll(getAllProvidersUsingIndividualFields(refreshOptions, appSettings));
return new ArrayList<>(allProviders);
}
protected Set<MetadataProvider> getAllProvidersUsingIndividualFields(MetadataRefreshOptions refreshOptions) {
protected Set<MetadataProvider> getAllProvidersUsingIndividualFields(MetadataRefreshOptions refreshOptions, AppSettings appSettings) {
MetadataRefreshOptions.FieldOptions fieldOptions = refreshOptions.getFieldOptions();
Set<MetadataProvider> uniqueProviders = EnumSet.noneOf(MetadataProvider.class);
if (fieldOptions != null) {
addProviderToSet(fieldOptions.getTitle(), uniqueProviders);
addProviderToSet(fieldOptions.getSubtitle(), uniqueProviders);
addProviderToSet(fieldOptions.getDescription(), uniqueProviders);
addProviderToSet(fieldOptions.getAuthors(), uniqueProviders);
addProviderToSet(fieldOptions.getPublisher(), uniqueProviders);
addProviderToSet(fieldOptions.getPublishedDate(), uniqueProviders);
addProviderToSet(fieldOptions.getSeriesName(), uniqueProviders);
addProviderToSet(fieldOptions.getSeriesNumber(), uniqueProviders);
addProviderToSet(fieldOptions.getSeriesTotal(), uniqueProviders);
addProviderToSet(fieldOptions.getIsbn13(), uniqueProviders);
addProviderToSet(fieldOptions.getIsbn10(), uniqueProviders);
addProviderToSet(fieldOptions.getLanguage(), uniqueProviders);
addProviderToSet(fieldOptions.getCategories(), uniqueProviders);
addProviderToSet(fieldOptions.getCover(), uniqueProviders);
addProviderToSet(fieldOptions.getPageCount(), uniqueProviders);
addProviderToSet(fieldOptions.getAsin(), uniqueProviders);
addProviderToSet(fieldOptions.getGoodreadsId(), uniqueProviders);
addProviderToSet(fieldOptions.getComicvineId(), uniqueProviders);
addProviderToSet(fieldOptions.getHardcoverId(), uniqueProviders);
addProviderToSet(fieldOptions.getGoogleId(), uniqueProviders);
addProviderToSet(fieldOptions.getAmazonRating(), uniqueProviders);
addProviderToSet(fieldOptions.getAmazonReviewCount(), uniqueProviders);
addProviderToSet(fieldOptions.getGoodreadsRating(), uniqueProviders);
addProviderToSet(fieldOptions.getGoodreadsReviewCount(), uniqueProviders);
addProviderToSet(fieldOptions.getHardcoverRating(), uniqueProviders);
addProviderToSet(fieldOptions.getHardcoverReviewCount(), uniqueProviders);
addProviderToSet(fieldOptions.getMoods(), uniqueProviders);
addProviderToSet(fieldOptions.getTags(), uniqueProviders);
addProviderToSet(fieldOptions.getTitle(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getSubtitle(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getDescription(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getAuthors(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getPublisher(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getPublishedDate(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getSeriesName(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getSeriesNumber(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getSeriesTotal(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getIsbn13(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getIsbn10(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getLanguage(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getCategories(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getCover(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getPageCount(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getAsin(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getGoodreadsId(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getComicvineId(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getHardcoverId(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getGoogleId(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getAmazonRating(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getAmazonReviewCount(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getGoodreadsRating(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getGoodreadsReviewCount(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getHardcoverRating(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getHardcoverReviewCount(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getMoods(), uniqueProviders, appSettings);
addProviderToSet(fieldOptions.getTags(), uniqueProviders, appSettings);
}
return uniqueProviders;
}
protected void addProviderToSet(MetadataRefreshOptions.FieldProvider fieldProvider, Set<MetadataProvider> providerSet) {
protected void addProviderToSet(MetadataRefreshOptions.FieldProvider fieldProvider, Set<MetadataProvider> providerSet, AppSettings appSettings) {
if (fieldProvider != null) {
if (fieldProvider.getP4() != null) providerSet.add(fieldProvider.getP4());
if (fieldProvider.getP3() != null) providerSet.add(fieldProvider.getP3());
if (fieldProvider.getP2() != null) providerSet.add(fieldProvider.getP2());
if (fieldProvider.getP1() != null) providerSet.add(fieldProvider.getP1());
if (fieldProvider.getP4() != null && isProviderEnabled(fieldProvider.getP4(), appSettings)) providerSet.add(fieldProvider.getP4());
if (fieldProvider.getP3() != null && isProviderEnabled(fieldProvider.getP3(), appSettings)) providerSet.add(fieldProvider.getP3());
if (fieldProvider.getP2() != null && isProviderEnabled(fieldProvider.getP2(), appSettings)) providerSet.add(fieldProvider.getP2());
if (fieldProvider.getP1() != null && isProviderEnabled(fieldProvider.getP1(), appSettings)) providerSet.add(fieldProvider.getP1());
}
}
protected boolean isProviderEnabled(MetadataProvider provider, AppSettings appSettings) {
if (provider == null || appSettings == null || appSettings.getMetadataProviderSettings() == null) {
return true;
}
var settings = appSettings.getMetadataProviderSettings();
return switch (provider) {
case Amazon -> settings.getAmazon() != null && settings.getAmazon().isEnabled();
case Google -> settings.getGoogle() != null && settings.getGoogle().isEnabled();
case GoodReads -> settings.getGoodReads() != null && settings.getGoodReads().isEnabled();
case Hardcover -> settings.getHardcover() != null && settings.getHardcover().isEnabled();
case Comicvine -> settings.getComicvine() != null && settings.getComicvine().isEnabled();
case Douban -> settings.getDouban() != null && settings.getDouban().isEnabled();
default -> true;
};
}
public BookMetadata fetchTopMetadataFromAProvider(MetadataProvider provider, Book book) {
return getParser(provider).fetchTopMetadata(book, buildFetchMetadataRequestFromBook(book));
}

View File

@@ -6,6 +6,7 @@ import io.documentnode.epub4j.epub.EpubReader;
import lombok.extern.slf4j.Slf4j;
import net.lingala.zip4j.ZipFile;
import net.lingala.zip4j.model.FileHeader;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.configurationprocessor.json.JSONException;
import org.springframework.boot.configurationprocessor.json.JSONObject;
@@ -17,10 +18,14 @@ import org.w3c.dom.NodeList;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.*;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
@Slf4j
@@ -29,8 +34,8 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
@Override
public byte[] extractCover(File epubFile) {
try {
Book epub = new EpubReader().readEpub(new FileInputStream(epubFile));
try (FileInputStream fis = new FileInputStream(epubFile)) {
Book epub = new EpubReader().readEpub(fis);
io.documentnode.epub4j.domain.Resource coverImage = epub.getCoverImage();
if (coverImage == null) {
@@ -54,6 +59,7 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
}
}
@Override
public BookMetadata extractMetadata(File epubFile) {
try (ZipFile zip = new ZipFile(epubFile)) {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
@@ -88,6 +94,10 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
boolean seriesIndexFound = false;
NodeList children = metadata.getChildNodes();
Map<String, String> titlesById = new HashMap<>();
Map<String, String> titleTypeById = new HashMap<>();
for (int i = 0; i < children.getLength(); i++) {
if (!(children.item(i) instanceof Element el)) continue;
@@ -95,51 +105,29 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
String text = el.getTextContent().trim();
switch (tag) {
case "title" -> builderMeta.title(text);
case "description" -> builderMeta.description(text);
case "publisher" -> builderMeta.publisher(text);
case "language" -> builderMeta.language(text);
case "creator" -> authors.add(text);
case "subject" -> categories.add(text);
case "identifier" -> {
String scheme = el.getAttributeNS("http://www.idpf.org/2007/opf", "scheme").toUpperCase();
String value = text.toLowerCase().startsWith("isbn:") ? text.substring(5) : text;
if (!scheme.isEmpty()) {
switch (scheme) {
case "ISBN" -> {
if (value.length() == 13) builderMeta.isbn13(value);
else if (value.length() == 10) builderMeta.isbn10(value);
}
case "GOODREADS" -> builderMeta.goodreadsId(value);
case "COMICVINE" -> builderMeta.comicvineId(value);
case "GOOGLE" -> builderMeta.googleId(value);
case "AMAZON" -> builderMeta.asin(value);
case "HARDCOVER" -> builderMeta.hardcoverId(value);
}
case "title" -> {
String id = el.getAttribute("id");
if (StringUtils.isNotBlank(id)) {
titlesById.put(id, text);
} else {
if (text.toLowerCase().startsWith("isbn:")) {
if (value.length() == 13) builderMeta.isbn13(value);
else if (value.length() == 10) builderMeta.isbn10(value);
}
builderMeta.title(text);
}
}
case "date" -> {
LocalDate parsed = parseDate(text);
if (parsed != null) builderMeta.publishedDate(parsed);
}
case "meta" -> {
String name = el.getAttribute("name").trim().toLowerCase();
String prop = el.getAttribute("property").trim().toLowerCase();
String prop = el.getAttribute("property").trim();
String name = el.getAttribute("name").trim();
String refines = el.getAttribute("refines").trim();
String content = el.hasAttribute("content") ? el.getAttribute("content").trim() : text;
if (StringUtils.isBlank(content)) continue;
if (!seriesFound && ("booklore:series".equals(prop) || "calibre:series".equals(name) || "calibre:series".equals(prop) || "belongs-to-collection".equals(prop))) {
if ("title-type".equals(prop) && StringUtils.isNotBlank(refines)) {
titleTypeById.put(refines.substring(1), content.toLowerCase());
}
if (!seriesFound && ("booklore:series".equals(prop) || "calibre:series".equals(name) || "belongs-to-collection".equals(prop))) {
builderMeta.seriesName(content);
seriesFound = true;
}
if (!seriesIndexFound && ("booklore:series_index".equals(prop) || "calibre:series_index".equals(name) || "calibre:series_index".equals(prop) || "group-position".equals(prop))) {
if (!seriesIndexFound && ("booklore:series_index".equals(prop) || "calibre:series_index".equals(name) || "group-position".equals(prop))) {
try {
builderMeta.seriesNumber(Float.parseFloat(content));
seriesIndexFound = true;
@@ -179,14 +167,52 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
case "booklore:page_count" -> safeParseInt(content, builderMeta::pageCount);
}
}
case "creator" -> authors.add(text);
case "subject" -> categories.add(text);
case "publisher" -> builderMeta.publisher(text);
case "language" -> builderMeta.language(text);
case "identifier" -> {
String scheme = el.getAttributeNS("http://www.idpf.org/2007/opf", "scheme").toUpperCase();
String value = text.toLowerCase().startsWith("isbn:") ? text.substring(5) : text;
if (!scheme.isEmpty()) {
switch (scheme) {
case "ISBN" -> {
if (value.length() == 13) builderMeta.isbn13(value);
else if (value.length() == 10) builderMeta.isbn10(value);
}
case "GOODREADS" -> builderMeta.goodreadsId(value);
case "COMICVINE" -> builderMeta.comicvineId(value);
case "GOOGLE" -> builderMeta.googleId(value);
case "AMAZON" -> builderMeta.asin(value);
case "HARDCOVER" -> builderMeta.hardcoverId(value);
}
} else {
if (text.toLowerCase().startsWith("isbn:")) {
if (value.length() == 13) builderMeta.isbn13(value);
else if (value.length() == 10) builderMeta.isbn10(value);
}
}
}
case "date" -> {
LocalDate parsed = parseDate(text);
if (parsed != null) builderMeta.publishedDate(parsed);
}
}
}
for (Map.Entry<String, String> entry : titlesById.entrySet()) {
String id = entry.getKey();
String value = entry.getValue();
String type = titleTypeById.getOrDefault(id, "main");
if ("main".equals(type)) builderMeta.title(value);
else if ("subtitle".equals(type)) builderMeta.subtitle(value);
}
if (builderMeta.build().getPublishedDate() == null) {
for (int i = 0; i < children.getLength(); i++) {
if (!(children.item(i) instanceof Element el)) continue;
if (!"meta".equals(el.getLocalName())) continue;
String prop = el.getAttribute("property").trim().toLowerCase();
String content = el.hasAttribute("content") ? el.getAttribute("content").trim() : el.getTextContent().trim();
if ("dcterms:modified".equals(prop)) {
@@ -201,7 +227,15 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
builderMeta.authors(authors);
builderMeta.categories(categories);
return builderMeta.build();
BookMetadata extractedMetadata = builderMeta.build();
if (StringUtils.isBlank(extractedMetadata.getTitle())) {
builderMeta.title(FilenameUtils.getBaseName(epubFile.getName()));
extractedMetadata = builderMeta.build();
}
return extractedMetadata;
}
}
@@ -211,7 +245,6 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
}
}
private void safeParseInt(String value, java.util.function.IntConsumer setter) {
try {
setter.accept(Integer.parseInt(value));
@@ -247,4 +280,4 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
log.warn("Failed to parse date from string: {}", value);
return null;
}
}
}

View File

@@ -3,6 +3,7 @@ package com.adityachandel.booklore.service.metadata.extractor;
import com.adityachandel.booklore.model.dto.BookMetadata;
import com.adityachandel.booklore.util.FileUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.pdfbox.Loader;
@@ -73,7 +74,7 @@ public class PdfMetadataExtractor implements FileMetadataExtractor {
if (StringUtils.isNotBlank(info.getTitle())) {
metadataBuilder.title(info.getTitle());
} else {
metadataBuilder.title(file.getName());
metadataBuilder.title(FilenameUtils.getBaseName(file.getName()));
}
if (StringUtils.isNotBlank(info.getAuthor())) {

View File

@@ -150,7 +150,7 @@ public class AppMigrationService {
ImageIO.write(originalImage, "jpg", coverFile.toFile());
// Resize and save thumbnail.jpg
BufferedImage resized = fileService.resizeImage(originalImage, 250, 350);
BufferedImage resized = FileService.resizeImage(originalImage, 250, 350);
Path thumbnailFile = bookDir.resolve("thumbnail.jpg");
ImageIO.write(resized, "jpg", thumbnailFile.toFile());

View File

@@ -55,11 +55,6 @@ public class BookFilePersistenceService {
notificationService.sendMessageToPermissions(Topic.BOOK_ADD, bookMapper.toBookWithDescription(book, false), Set.of(ADMIN, MANIPULATE_LIBRARY));
}
@Transactional(readOnly = true)
public Optional<BookEntity> findByHash(String hash) {
return bookRepository.findByCurrentHash(hash);
}
String findMatchingLibraryPath(LibraryEntity libraryEntity, Path filePath) {
return libraryEntity.getLibraryPaths().stream()
.map(lp -> Paths.get(lp.getPath()).toAbsolutePath().normalize())

View File

@@ -36,16 +36,9 @@ public class BookFileTransactionalHandler {
private final LibraryRepository libraryRepository;
@Transactional()
public void handleNewBookFile(long libraryId, Path path, String currentHash) {
public void handleNewBookFile(long libraryId, Path path) {
LibraryEntity libraryEntity = libraryRepository.findById(libraryId).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId));
Optional<BookEntity> existingOpt = bookFilePersistenceService.findByHash(currentHash);
if (existingOpt.isPresent()) {
BookEntity existingBook = existingOpt.get();
bookFilePersistenceService.updatePathIfChanged(existingBook, libraryEntity, path, currentHash);
return;
}
String filePath = path.toString();
String fileName = path.getFileName().toString();
String libraryPath = bookFilePersistenceService.findMatchingLibraryPath(libraryEntity, path);

View File

@@ -118,8 +118,7 @@ public class LibraryFileEventProcessor {
private void handleFileCreate(LibraryEntity library, Path path) {
log.info("[FILE_CREATE] '{}'", path);
String hash = FileFingerprint.generateHash(path);
bookFileTransactionalHandler.handleNewBookFile(library.getId(), path, hash);
bookFileTransactionalHandler.handleNewBookFile(library.getId(), path);
}
private void handleFileDelete(LibraryEntity library, Path path) {
@@ -153,8 +152,7 @@ public class LibraryFileEventProcessor {
.filter(p -> isBookFile(p.getFileName().toString()))
.forEach(p -> {
try {
String hash = FileFingerprint.generateHash(p);
bookFileTransactionalHandler.handleNewBookFile(library.getId(), p, hash);
bookFileTransactionalHandler.handleNewBookFile(library.getId(), p);
} catch (Exception e) {
log.warn("[ERROR] Processing file '{}': {}", p, e.getMessage());
}

View File

@@ -1,7 +1,10 @@
package com.adityachandel.booklore.util;
import lombok.experimental.UtilityClass;
import java.util.regex.Pattern;
@UtilityClass
public class BookUtils {
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
@@ -23,17 +26,25 @@ public class BookUtils {
}
public static String cleanAndTruncateSearchTerm(String term) {
if (term == null) {
return "";
}
String s = term;
s = SPECIAL_CHARACTERS_PATTERN.matcher(s).replaceAll("").trim();
s = WHITESPACE_PATTERN.matcher(s).replaceAll(" ");
if (s.length() > 60) {
String[] words = WHITESPACE_PATTERN.split(s);
StringBuilder truncated = new StringBuilder();
for (String word : words) {
if (truncated.length() + word.length() + 1 > 60) break;
if (!truncated.isEmpty()) truncated.append(" ");
truncated.append(word);
if (words.length > 1) {
StringBuilder truncated = new StringBuilder();
for (String word : words) {
if (truncated.length() + word.length() + 1 > 60) break;
if (!truncated.isEmpty()) truncated.append(" ");
truncated.append(word);
}
s = truncated.toString();
} else {
s = s.substring(0, Math.min(60, s.length()));
}
s = truncated.toString();
}
return s;
}

View File

@@ -2,22 +2,18 @@ package com.adityachandel.booklore.util;
import com.adityachandel.booklore.config.AppProperties;
import com.adityachandel.booklore.exception.ApiError;
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.BookMetadataEntity;
import com.adityachandel.booklore.repository.BookAdditionalFileRepository;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.mapper.BookMapper;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
@@ -26,15 +22,12 @@ import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.Comparator;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;
@@ -85,7 +78,7 @@ public class FileService {
return getBackgroundsFolder(null);
}
public String getBackgroundUrl(String filename, Long userId) {
public static String getBackgroundUrl(String filename, Long userId) {
if (userId != null) {
return Paths.get("/", BACKGROUNDS_DIR, "user-" + userId, filename).toString().replace("\\", "/");
}
@@ -116,7 +109,7 @@ public class FileService {
// VALIDATION
// ========================================
private void validateCoverFile(MultipartFile file) {
private static void validateCoverFile(MultipartFile file) {
if (file.isEmpty()) {
throw new IllegalArgumentException("Uploaded file is empty");
}
@@ -133,7 +126,7 @@ public class FileService {
// IMAGE OPERATIONS
// ========================================
public BufferedImage resizeImage(BufferedImage originalImage, int width, int height) {
public static BufferedImage resizeImage(BufferedImage originalImage, int width, int height) {
Image tmp = originalImage.getScaledInstance(width, height, Image.SCALE_SMOOTH);
BufferedImage resizedImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = resizedImage.createGraphics();
@@ -142,7 +135,7 @@ public class FileService {
return resizedImage;
}
public void saveImage(byte[] imageData, String filePath) throws IOException {
public static void saveImage(byte[] imageData, String filePath) throws IOException {
BufferedImage originalImage = ImageIO.read(new ByteArrayInputStream(imageData));
File outputFile = new File(filePath);
File parentDir = outputFile.getParentFile();
@@ -153,15 +146,33 @@ public class FileService {
log.info("Image saved successfully to: {}", filePath);
}
public BufferedImage downloadImageFromUrl(String imageUrl) throws IOException {
public static BufferedImage downloadImageFromUrl(String imageUrl) throws IOException {
try {
URI uri = URI.create(imageUrl);
URL url = uri.toURL();
BufferedImage image = ImageIO.read(url);
if (image == null) {
throw new IOException("Unable to read image from URL: " + imageUrl);
HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.USER_AGENT, "BookLore/1.0 (Metadata Fetcher)");
headers.set(HttpHeaders.ACCEPT, "image/*");
HttpEntity<String> entity = new HttpEntity<>(headers);
ResponseEntity<byte[]> response = new RestTemplate().exchange(
imageUrl,
HttpMethod.GET,
entity,
byte[].class
);
// Validate and convert
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
try (ByteArrayInputStream inputStream = new ByteArrayInputStream(response.getBody())) {
BufferedImage image = ImageIO.read(inputStream);
if (image == null) {
throw new IOException("Downloaded content is not a supported image format.");
}
return image;
}
} else {
throw new IOException("Failed to download image. HTTP Status: " + response.getStatusCode());
}
return image;
} catch (Exception e) {
log.error("Failed to download image from URL: {} - {}", imageUrl, e.getMessage());
throw new IOException("Failed to download image from URL: " + imageUrl, e);
@@ -175,7 +186,10 @@ public class FileService {
public void createThumbnailFromFile(long bookId, MultipartFile file) {
try {
validateCoverFile(file);
BufferedImage originalImage = ImageIO.read(file.getInputStream());
BufferedImage originalImage;
try (InputStream inputStream = file.getInputStream()) {
originalImage = ImageIO.read(inputStream);
}
if (originalImage == null) {
throw ApiError.IMAGE_NOT_FOUND.createException();
}
@@ -229,7 +243,7 @@ public class FileService {
return originalSaved && thumbnailSaved;
}
public void setBookCoverPath(BookMetadataEntity bookMetadataEntity) {
public static void setBookCoverPath(BookMetadataEntity bookMetadataEntity) {
bookMetadataEntity.setCoverUpdatedOn(Instant.now());
}
@@ -343,60 +357,9 @@ public class FileService {
// UTILITY METHODS
// ========================================
@Transactional
public Optional<Book> checkForDuplicateAndUpdateMetadataIfNeeded(LibraryFile libraryFile, String hash, BookRepository bookRepository, BookAdditionalFileRepository bookAdditionalFileRepository, BookMapper bookMapper) {
if (StringUtils.isBlank(hash)) {
log.warn("Skipping file due to missing hash: {}", libraryFile.getFullPath());
return Optional.empty();
}
// First check for soft-deleted books with the same hash
Optional<BookEntity> softDeletedBook = bookRepository.findByCurrentHashAndDeletedTrue(hash);
if (softDeletedBook.isPresent()) {
BookEntity book = softDeletedBook.get();
log.info("Found soft-deleted book with same hash, undeleting: bookId={} file='{}'",
book.getId(), libraryFile.getFileName());
// Undelete the book
book.setDeleted(false);
book.setDeletedAt(null);
// Update file information
book.setFileName(libraryFile.getFileName());
book.setFileSubPath(libraryFile.getFileSubPath());
book.setLibraryPath(libraryFile.getLibraryPathEntity());
book.setLibrary(libraryFile.getLibraryEntity());
return Optional.of(bookMapper.toBook(book));
}
Optional<BookEntity> existingByHash = bookRepository.findByCurrentHash(hash);
if (existingByHash.isPresent()) {
BookEntity book = existingByHash.get();
String fileName = libraryFile.getFullPath().getFileName().toString();
if (!book.getFileName().equals(fileName)) {
book.setFileName(fileName);
}
if (!Objects.equals(book.getLibraryPath().getId(), libraryFile.getLibraryPathEntity().getId())) {
book.setLibraryPath(libraryFile.getLibraryPathEntity());
book.setFileSubPath(libraryFile.getFileSubPath());
}
return Optional.of(bookMapper.toBook(book));
}
Optional<BookAdditionalFileEntity> existingAdditionalFile = bookAdditionalFileRepository.findByAltFormatCurrentHash(hash);
if (existingAdditionalFile.isPresent()) {
BookAdditionalFileEntity additionalFile = existingAdditionalFile.get();
BookEntity book = additionalFile.getBook();
// Additional file might have a different name or path, so there is no need
// to update the file name or library path here
return Optional.of(bookMapper.toBook(book));
}
return Optional.empty();
}
public static String truncate(String input, int maxLength) {
return input == null ? null : (input.length() <= maxLength ? input : input.substring(0, maxLength));
if (input == null) return null;
if (maxLength <= 0) return "";
return input.length() <= maxLength ? input : input.substring(0, maxLength);
}
}

View File

@@ -1,32 +1,30 @@
package com.adityachandel.booklore.util;
import com.adityachandel.booklore.model.entity.BookEntity;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.Optional;
@UtilityClass
@Slf4j
public class FileUtils {
private static final String FILE_NOT_FOUND_MESSAGE = "File does not exist: ";
private final String FILE_NOT_FOUND_MESSAGE = "File does not exist: ";
public static String getBookFullPath(BookEntity bookEntity) {
public String getBookFullPath(BookEntity bookEntity) {
return Path.of(bookEntity.getLibraryPath().getPath(), bookEntity.getFileSubPath(), bookEntity.getFileName())
.normalize()
.toString()
.replace("\\", "/");
}
public static String getRelativeSubPath(String basePath, Path fullFilePath) {
public String getRelativeSubPath(String basePath, Path fullFilePath) {
return Optional.ofNullable(Path.of(basePath)
.relativize(fullFilePath)
.getParent())
@@ -34,12 +32,12 @@ public class FileUtils {
.orElse("");
}
public static Long getFileSizeInKb(BookEntity bookEntity) {
public Long getFileSizeInKb(BookEntity bookEntity) {
Path filePath = Path.of(getBookFullPath(bookEntity));
return getFileSizeInKb(filePath);
}
public static Long getFileSizeInKb(Path filePath) {
public Long getFileSizeInKb(Path filePath) {
try {
if (!Files.exists(filePath)) {
log.warn(FILE_NOT_FOUND_MESSAGE + "{}", filePath.toAbsolutePath());
@@ -52,7 +50,7 @@ public class FileUtils {
}
}
public static void deleteDirectoryRecursively(Path path) throws IOException {
public void deleteDirectoryRecursively(Path path) throws IOException {
if (!Files.exists(path)) return;
try (var walk = Files.walk(path)) {

View File

@@ -1,14 +1,21 @@
package com.adityachandel.booklore.util;
import lombok.experimental.UtilityClass;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@UtilityClass
public class Md5Util {
public static String md5Hex(String input) {
public String md5Hex(String input) {
if (input == null) {
return null;
}
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes());
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b));

View File

@@ -3,14 +3,16 @@ package com.adityachandel.booklore.util;
import com.adityachandel.booklore.model.MetadataClearFlags;
import com.adityachandel.booklore.model.dto.BookMetadata;
import com.adityachandel.booklore.model.entity.*;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import java.util.*;
import java.util.function.Supplier;
import java.util.function.BooleanSupplier;
import java.util.stream.Collectors;
import static org.apache.commons.lang3.BooleanUtils.isTrue;
@UtilityClass
@Slf4j
public class MetadataChangeDetector {
@@ -54,12 +56,8 @@ public class MetadataChangeDetector {
changes.add("cover lock: [" + isTrue(coverLockedExisting) + "] → [" + isTrue(coverLockedNew) + "]");
}
if (!changes.isEmpty()) {
/*changes.forEach(change -> log.info("Metadata change: {}", change));*/
return true;
}
return false;
/*changes.forEach(change -> log.info("Metadata change: {}", change));*/
return !changes.isEmpty();
}
public static boolean hasValueChanges(BookMetadata newMeta, BookMetadataEntity existingMeta, MetadataClearFlags clear) {
@@ -119,7 +117,7 @@ public class MetadataChangeDetector {
return !diffs.isEmpty();
}
private static void compare(List<String> diffs, String field, boolean shouldClear, Object newVal, Object oldVal, Supplier<Boolean> isUnlocked, Boolean newLock, Boolean oldLock) {
private static void compare(List<String> diffs, String field, boolean shouldClear, Object newVal, Object oldVal, BooleanSupplier isUnlocked, Boolean newLock, Boolean oldLock) {
boolean valueChanged = differs(shouldClear, newVal, oldVal, isUnlocked);
boolean lockChanged = differsLock(newLock, oldLock);
@@ -133,18 +131,18 @@ public class MetadataChangeDetector {
}
private static <T> void compareValue(List<String> diffs,
String field,
boolean shouldClear,
T newVal,
T oldVal,
Supplier<Boolean> isUnlocked) {
String field,
boolean shouldClear,
T newVal,
T oldVal,
BooleanSupplier isUnlocked) {
if (differs(shouldClear, newVal, oldVal, isUnlocked)) {
diffs.add(field + " changed");
}
}
private static boolean differs(boolean shouldClear, Object newVal, Object oldVal, Supplier<Boolean> isUnlocked) {
if (!isUnlocked.get()) return false;
private static boolean differs(boolean shouldClear, Object newVal, Object oldVal, BooleanSupplier isUnlocked) {
if (!isUnlocked.getAsBoolean()) return false;
Object normNew = normalize(newVal);
Object normOld = normalize(oldVal);

View File

@@ -4,35 +4,38 @@ import com.adityachandel.booklore.model.dto.BookMetadata;
import com.adityachandel.booklore.model.entity.AuthorEntity;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
import lombok.experimental.UtilityClass;
import java.time.LocalDate;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@UtilityClass
public class PathPatternResolver {
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile(".*\\.[a-zA-Z0-9]+$");
private static final Pattern CONTROL_CHARACTER_PATTERN = Pattern.compile("[\\p{Cntrl}]");
private static final Pattern INVALID_CHARS_PATTERN = Pattern.compile("[\\\\/:*?\"<>|]");
private final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private final Pattern FILE_EXTENSION_PATTERN = Pattern.compile(".*\\.[a-zA-Z0-9]+$");
private final Pattern CONTROL_CHARACTER_PATTERN = Pattern.compile("[\\p{Cntrl}]");
private final Pattern INVALID_CHARS_PATTERN = Pattern.compile("[\\\\/:*?\"<>|]");
private final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{(.*?)}");
public static String resolvePattern(BookEntity book, String pattern) {
public String resolvePattern(BookEntity book, String pattern) {
String currentFilename = book.getFileName() != null ? book.getFileName().trim() : "";
return resolvePattern(book.getMetadata(), pattern, currentFilename);
}
public static String resolvePattern(BookMetadata metadata, String pattern, String filename) {
public String resolvePattern(BookMetadata metadata, String pattern, String filename) {
MetadataProvider metadataProvider = MetadataProvider.from(metadata);
return resolvePattern(metadataProvider, pattern, filename);
}
public static String resolvePattern(BookMetadataEntity metadata, String pattern, String filename) {
public String resolvePattern(BookMetadataEntity metadata, String pattern, String filename) {
MetadataProvider metadataProvider = MetadataProvider.from(metadata);
return resolvePattern(metadataProvider, pattern, filename);
}
private static String resolvePattern(MetadataProvider metadata, String pattern, String filename) {
private String resolvePattern(MetadataProvider metadata, String pattern, String filename) {
if (pattern == null || pattern.isBlank()) {
return filename;
}
@@ -89,7 +92,7 @@ public class PathPatternResolver {
return resolvePatternWithValues(pattern, values, filename);
}
private static String resolvePatternWithValues(String pattern, Map<String, String> values, String currentFilename) {
private String resolvePatternWithValues(String pattern, Map<String, String> values, String currentFilename) {
String extension = "";
int lastDot = currentFilename.lastIndexOf('.');
if (lastDot >= 0 && lastDot < currentFilename.length() - 1) {
@@ -105,7 +108,7 @@ public class PathPatternResolver {
while (matcher.find()) {
String block = matcher.group(1);
Matcher placeholderMatcher = Pattern.compile("\\{(.*?)}").matcher(block);
Matcher placeholderMatcher = PLACEHOLDER_PATTERN.matcher(block);
boolean allHaveValues = true;
// Check if all placeholders inside optional block have non-blank values
@@ -133,8 +136,7 @@ public class PathPatternResolver {
String result = resolved.toString();
// Replace known placeholders with values, preserve unknown ones
Pattern placeholderPattern = Pattern.compile("\\{(.*?)}");
Matcher placeholderMatcher = placeholderPattern.matcher(result);
Matcher placeholderMatcher = PLACEHOLDER_PATTERN.matcher(result);
StringBuilder finalResult = new StringBuilder();
while (placeholderMatcher.find()) {
@@ -165,7 +167,7 @@ public class PathPatternResolver {
return result;
}
private static String sanitize(String input) {
private String sanitize(String input) {
if (input == null) return "";
return WHITESPACE_PATTERN.matcher(CONTROL_CHARACTER_PATTERN.matcher(INVALID_CHARS_PATTERN.matcher(input).replaceAll("")).replaceAll("")).replaceAll(" ")
.trim();

View File

@@ -2,15 +2,14 @@ package com.adityachandel.booklore.util;
import jakarta.servlet.http.HttpServletRequest;
import lombok.experimental.UtilityClass;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
public final class RequestUtils {
@UtilityClass
public class RequestUtils {
private RequestUtils() {
}
public static HttpServletRequest getCurrentRequest() {
public HttpServletRequest getCurrentRequest() {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs == null) {
throw new IllegalStateException("No current HTTP request found");

View File

@@ -1,12 +1,14 @@
package com.adityachandel.booklore.util;
import lombok.experimental.UtilityClass;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
@UtilityClass
public class SecurityContextVirtualThread {
public static void runWithSecurityContext(Runnable task) {
public void runWithSecurityContext(Runnable task) {
Authentication currentAuth = SecurityContextHolder.getContext().getAuthentication();
Thread.startVirtualThread(() -> {
@@ -21,7 +23,7 @@ public class SecurityContextVirtualThread {
});
}
public static void runWithSecurityContext(SecurityContext parentContext, Runnable task) {
public void runWithSecurityContext(SecurityContext parentContext, Runnable task) {
Thread.startVirtualThread(() -> {
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(parentContext.getAuthentication());

View File

@@ -2,7 +2,9 @@ package com.adityachandel.booklore.util;
import com.adityachandel.booklore.model.entity.UserPermissionsEntity;
import com.adityachandel.booklore.model.enums.PermissionType;
import lombok.experimental.UtilityClass;
@UtilityClass
public class UserPermissionUtils {
public static boolean hasPermission(UserPermissionsEntity perms, PermissionType type) {

View File

@@ -50,6 +50,7 @@ public class BookloreSyncTokenGenerator {
}
public BookloreSyncToken fromRequestHeaders(HttpServletRequest request) {
if (request == null) return null;
String tokenB64 = request.getHeader(KoboHeaders.X_KOBO_SYNCTOKEN);
return tokenB64 != null ? fromBase64(tokenB64) : null;
}

View File

@@ -2,6 +2,7 @@ package com.adityachandel.booklore.util.kobo;
import com.adityachandel.booklore.util.RequestUtils;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@@ -12,6 +13,7 @@ import java.util.regex.Pattern;
@Component
@Slf4j
@RequiredArgsConstructor
public class KoboUrlBuilder {
private static final Pattern IP_ADDRESS_PATTERN = Pattern.compile("\\d+\\.\\d+\\.\\d+\\.\\d+");

View File

@@ -77,15 +77,15 @@ class BookdropMetadataServiceTest {
when(bookdropFileRepository.findById(1L)).thenReturn(Optional.of(sampleFile));
when(metadataExtractorFactory.extractMetadata(eq(BookFileExtension.EPUB), any(File.class))).thenReturn(metadata);
when(objectMapper.writeValueAsString(metadata)).thenReturn("{\"title\":\"Test Book\"}");
when(bookdropFileRepository.save(any())).thenReturn(sampleFile);
when(objectMapper.writeValueAsString(any(BookMetadata.class))).thenReturn("{\"title\":\"Test Book\"}");
when(bookdropFileRepository.save(any(BookdropFileEntity.class))).thenReturn(sampleFile);
BookdropFileEntity result = bookdropMetadataService.attachInitialMetadata(1L);
assertThat(result).isNotNull();
assertThat(result.getOriginalMetadata()).contains("Test Book");
assertThat(result.getUpdatedAt()).isBeforeOrEqualTo(Instant.now());
verify(bookdropFileRepository).save(result);
verify(bookdropFileRepository).save(any(BookdropFileEntity.class));
}
@Test
@@ -129,7 +129,6 @@ class BookdropMetadataServiceTest {
BookdropFileEntity result = bookdropMetadataService.attachInitialMetadata(1L);
assertThat(result.getOriginalMetadata()).contains("No Cover Book");
verify(fileService, never()).saveImage(any(), any());
verify(bookdropFileRepository).save(result);
}

View File

@@ -216,6 +216,50 @@ class KoboReadingStateServiceTest {
assertNotNull(savedProgress.getDateFinished());
}
@Test
@DisplayName("Should not overwrite existing finished date when syncing completed book")
void testSyncKoboProgressToUserBookProgress_PreserveExistingFinishedDate() {
String entitlementId = "100";
testSettings.setProgressMarkAsFinishedThreshold(99f);
Instant originalFinishedDate = Instant.parse("2025-01-15T10:30:00Z");
UserBookProgressEntity existingProgress = new UserBookProgressEntity();
existingProgress.setUser(testUserEntity);
existingProgress.setBook(testBook);
existingProgress.setKoboProgressPercent(99.5f);
existingProgress.setReadStatus(ReadStatus.READ);
existingProgress.setDateFinished(originalFinishedDate);
KoboReadingState.CurrentBookmark bookmark = KoboReadingState.CurrentBookmark.builder()
.progressPercent(100)
.build();
KoboReadingState readingState = KoboReadingState.builder()
.entitlementId(entitlementId)
.currentBookmark(bookmark)
.build();
KoboReadingStateEntity entity = new KoboReadingStateEntity();
when(mapper.toEntity(any())).thenReturn(entity);
when(mapper.toDto(any(KoboReadingStateEntity.class))).thenReturn(readingState);
when(repository.findByEntitlementId(entitlementId)).thenReturn(Optional.empty());
when(repository.save(any())).thenReturn(entity);
when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook));
when(userRepository.findById(1L)).thenReturn(Optional.of(testUserEntity));
when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(existingProgress));
ArgumentCaptor<UserBookProgressEntity> progressCaptor = ArgumentCaptor.forClass(UserBookProgressEntity.class);
when(progressRepository.save(progressCaptor.capture())).thenReturn(existingProgress);
service.saveReadingState(List.of(readingState));
UserBookProgressEntity savedProgress = progressCaptor.getValue();
assertEquals(100.0f, savedProgress.getKoboProgressPercent());
assertEquals(ReadStatus.READ, savedProgress.getReadStatus());
assertEquals(originalFinishedDate, savedProgress.getDateFinished(),
"Existing finished date should not be overwritten during sync");
}
@Test
@DisplayName("Should mark book as READING when progress exceeds reading threshold")
void testSyncKoboProgressToUserBookProgress_MarkAsReading() {

View File

@@ -1,511 +0,0 @@
package com.adityachandel.booklore.service.fileprocessor;
import com.adityachandel.booklore.mapper.BookMapper;
import com.adityachandel.booklore.model.DuplicateFileInfo;
import com.adityachandel.booklore.model.FileProcessResult;
import com.adityachandel.booklore.model.dto.Book;
import com.adityachandel.booklore.model.dto.settings.LibraryFile;
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.BookFileType;
import com.adityachandel.booklore.model.enums.FileProcessStatus;
import com.adityachandel.booklore.repository.BookAdditionalFileRepository;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.service.book.BookCreatorService;
import com.adityachandel.booklore.service.file.FileFingerprint;
import com.adityachandel.booklore.service.metadata.MetadataMatchService;
import com.adityachandel.booklore.util.FileService;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.*;
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.*;
class AbstractFileProcessorTest {
@Mock
BookRepository bookRepository;
@Mock
BookAdditionalFileRepository bookAdditionalFileRepository;
@Mock
BookCreatorService bookCreatorService;
@Mock
BookMapper bookMapper;
@Mock
MetadataMatchService metadataMatchService;
@Mock
FileService fileService;
@Mock
EntityManager entityManager;
TestFileProcessor processor;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
processor = new TestFileProcessor(
bookRepository,
bookAdditionalFileRepository,
bookCreatorService,
bookMapper,
fileService,
metadataMatchService
);
// Inject EntityManager via reflection
try {
var field = AbstractFileProcessor.class.getDeclaredField("entityManager");
field.setAccessible(true);
field.set(processor, entityManager);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Test
void processFile_shouldReturnDuplicate_whenDuplicateFoundByFileService() {
// Given
LibraryFile libraryFile = createMockLibraryFile();
Book duplicateBook = createMockBook(1L, "file.pdf");
try (MockedStatic<FileFingerprint> fingerprintMock = mockStatic(FileFingerprint.class)) {
fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash1");
when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(
eq(libraryFile), eq("hash1"), eq(bookRepository), eq(bookAdditionalFileRepository), eq(bookMapper)))
.thenReturn(Optional.of(duplicateBook));
BookEntity bookEntity = createMockBookEntity(1L, "file.pdf", "hash1", "sub", libraryFile.getLibraryPathEntity());
when(bookRepository.findById(1L)).thenReturn(Optional.of(bookEntity));
// When
FileProcessResult result = processor.processFile(libraryFile);
// Then
assertThat(result.getStatus()).isEqualTo(FileProcessStatus.DUPLICATE);
assertThat(result.getDuplicate()).isNotNull();
assertThat(result.getDuplicate().getBookId()).isEqualTo(1L);
}
}
@Test
void processFile_shouldReturnDuplicate_whenBookFoundByFileNameAndLibraryId() {
// Given
LibraryFile libraryFile = createMockLibraryFile();
BookEntity existingEntity = createMockBookEntity(2L, "file.pdf", "hash2", "sub", libraryFile.getLibraryPathEntity());
Book existingBook = createMockBook(2L, "file.pdf");
try (MockedStatic<FileFingerprint> fingerprintMock = mockStatic(FileFingerprint.class)) {
fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash2");
when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any()))
.thenReturn(Optional.empty());
when(bookRepository.findBookByFileNameAndLibraryId("file.pdf", 1L))
.thenReturn(Optional.of(existingEntity));
when(bookMapper.toBook(existingEntity)).thenReturn(existingBook);
// When
FileProcessResult result = processor.processFile(libraryFile);
// Then
assertThat(result.getStatus()).isEqualTo(FileProcessStatus.DUPLICATE);
assertThat(result.getBook()).isEqualTo(existingBook);
assertThat(result.getDuplicate()).isNotNull();
}
}
@Test
void processFile_shouldReturnNew_whenNoDuplicateFound() {
// Given
LibraryFile libraryFile = createMockLibraryFile();
BookEntity newEntity = createMockBookEntity(3L, "file.pdf", "hash3", "sub", libraryFile.getLibraryPathEntity());
Book newBook = createMockBook(3L, "file.pdf");
try (MockedStatic<FileFingerprint> fingerprintMock = mockStatic(FileFingerprint.class)) {
fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash3");
when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any()))
.thenReturn(Optional.empty());
when(bookRepository.findBookByFileNameAndLibraryId("file.pdf", 1L))
.thenReturn(Optional.empty());
when(metadataMatchService.calculateMatchScore(any())).thenReturn(85F);
when(bookMapper.toBook(newEntity)).thenReturn(newBook);
processor.setProcessNewFileResult(newEntity);
// When
FileProcessResult result = processor.processFile(libraryFile);
// Then
assertThat(result.getStatus()).isEqualTo(FileProcessStatus.NEW);
assertThat(result.getBook()).isEqualTo(newBook);
assertThat(result.getDuplicate()).isNull();
verify(bookCreatorService).saveConnections(newEntity);
verify(metadataMatchService).calculateMatchScore(newEntity);
}
}
@Test
void processFile_shouldReturnUpdated_whenDuplicateFoundWithDifferentMetadata() {
// Given
LibraryFile libraryFile = createMockLibraryFile();
libraryFile.setFileSubPath("new-sub");
Book duplicateBook = createMockBook(1L, "file.pdf");
BookEntity existingEntity = createMockBookEntity(1L, "file.pdf", "hash1", "old-sub", libraryFile.getLibraryPathEntity());
Book updatedBook = createMockBook(1L, "file.pdf");
try (MockedStatic<FileFingerprint> fingerprintMock = mockStatic(FileFingerprint.class)) {
fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash1");
when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any()))
.thenReturn(Optional.of(duplicateBook));
when(bookRepository.findById(1L)).thenReturn(Optional.of(existingEntity));
when(bookMapper.toBook(existingEntity)).thenReturn(updatedBook);
// When
FileProcessResult result = processor.processFile(libraryFile);
// Then
assertThat(result.getStatus()).isEqualTo(FileProcessStatus.UPDATED);
assertThat(result.getBook()).isEqualTo(updatedBook);
assertThat(existingEntity.getFileSubPath()).isEqualTo("new-sub");
verify(entityManager).flush();
verify(entityManager).detach(existingEntity);
}
}
@Test
void processFile_shouldReturnUpdated_whenDuplicateFoundWithDifferentFileName() {
// Given
LibraryFile libraryFile = createMockLibraryFile();
libraryFile.setFileName("new-file.pdf");
Book duplicateBook = createMockBook(1L, "old-file.pdf");
BookEntity existingEntity = createMockBookEntity(1L, "old-file.pdf", "hash1", "sub", libraryFile.getLibraryPathEntity());
Book updatedBook = createMockBook(1L, "new-file.pdf");
try (MockedStatic<FileFingerprint> fingerprintMock = mockStatic(FileFingerprint.class)) {
fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash1");
when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any()))
.thenReturn(Optional.of(duplicateBook));
when(bookRepository.findById(1L)).thenReturn(Optional.of(existingEntity));
when(bookMapper.toBook(existingEntity)).thenReturn(updatedBook);
// When
FileProcessResult result = processor.processFile(libraryFile);
// Then
assertThat(result.getStatus()).isEqualTo(FileProcessStatus.UPDATED);
assertThat(result.getBook()).isEqualTo(updatedBook);
assertThat(existingEntity.getFileName()).isEqualTo("new-file.pdf");
verify(entityManager).flush();
verify(entityManager).detach(existingEntity);
}
}
@Test
void processFile_shouldReturnUpdated_whenDuplicateFoundWithDifferentLibraryPath() {
// Given
LibraryEntity library = LibraryEntity.builder().id(2L).watch(false).build();
LibraryPathEntity newLibraryPath = LibraryPathEntity.builder()
.id(2L)
.library(library)
.path("/new-path")
.build();
LibraryFile libraryFile = LibraryFile.builder()
.fileName("file.pdf")
.fileSubPath("sub")
.bookFileType(BookFileType.PDF)
.libraryEntity(library)
.libraryPathEntity(newLibraryPath)
.build();
Book duplicateBook = createMockBook(1L, "file.pdf");
BookEntity existingEntity = createMockBookEntity(1L, "file.pdf", "hash1", "sub",
LibraryPathEntity.builder().id(1L).library(LibraryEntity.builder().id(1L).watch(false).build()).path("/old-path").build());
Book updatedBook = createMockBook(1L, "file.pdf");
try (MockedStatic<FileFingerprint> fingerprintMock = mockStatic(FileFingerprint.class)) {
fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash1");
when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any()))
.thenReturn(Optional.of(duplicateBook));
when(bookRepository.findById(1L)).thenReturn(Optional.of(existingEntity));
when(bookMapper.toBook(existingEntity)).thenReturn(updatedBook);
// When
FileProcessResult result = processor.processFile(libraryFile);
// Then
assertThat(result.getStatus()).isEqualTo(FileProcessStatus.UPDATED);
assertThat(result.getBook()).isEqualTo(updatedBook);
assertThat(existingEntity.getLibraryPath()).isEqualTo(newLibraryPath);
verify(entityManager).flush();
verify(entityManager).detach(existingEntity);
}
}
@Test
void processFile_shouldReturnDuplicate_whenDuplicateFoundWithSameMetadata() {
// Given
LibraryFile libraryFile = createMockLibraryFile();
Book duplicateBook = createMockBook(1L, "file.pdf");
BookEntity existingEntity = createMockBookEntity(1L, "file.pdf", "hash1", "sub", libraryFile.getLibraryPathEntity());
try (MockedStatic<FileFingerprint> fingerprintMock = mockStatic(FileFingerprint.class)) {
fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash1");
when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any()))
.thenReturn(Optional.of(duplicateBook));
when(bookRepository.findById(1L)).thenReturn(Optional.of(existingEntity));
// When
FileProcessResult result = processor.processFile(libraryFile);
// Then
assertThat(result.getStatus()).isEqualTo(FileProcessStatus.DUPLICATE);
assertThat(result.getBook()).isEqualTo(duplicateBook);
assertThat(result.getDuplicate()).isNotNull();
assertThat(result.getDuplicate().getBookId()).isEqualTo(1L);
verify(entityManager, never()).flush();
}
}
@Test
void processFile_shouldReturnDuplicateFromFallback_whenDuplicateBookNotFoundInRepository() {
// Given
LibraryFile libraryFile = createMockLibraryFile();
Book duplicateBook = createMockBook(1L, "file.pdf");
try (MockedStatic<FileFingerprint> fingerprintMock = mockStatic(FileFingerprint.class)) {
fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash1");
when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any()))
.thenReturn(Optional.of(duplicateBook));
when(bookRepository.findById(1L)).thenReturn(Optional.empty());
// When
FileProcessResult result = processor.processFile(libraryFile);
// Then
assertThat(result.getStatus()).isEqualTo(FileProcessStatus.DUPLICATE);
assertThat(result.getBook()).isEqualTo(duplicateBook);
assertThat(result.getDuplicate()).isNull();
}
}
@Test
void processFile_shouldUpdateHashEvenWhenOtherMetadataUnchanged() {
// Given
LibraryFile libraryFile = createMockLibraryFile();
Book duplicateBook = createMockBook(1L, "file.pdf");
BookEntity existingEntity = createMockBookEntity(1L, "file.pdf", "old-hash", "sub", libraryFile.getLibraryPathEntity());
try (MockedStatic<FileFingerprint> fingerprintMock = mockStatic(FileFingerprint.class)) {
fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("new-hash");
when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any()))
.thenReturn(Optional.of(duplicateBook));
when(bookRepository.findById(1L)).thenReturn(Optional.of(existingEntity));
when(bookMapper.toBook(existingEntity)).thenReturn(duplicateBook);
// When
FileProcessResult result = processor.processFile(libraryFile);
// Then
assertThat(existingEntity.getCurrentHash()).isEqualTo("new-hash");
verify(entityManager).detach(existingEntity);
}
}
@Test
void processFile_shouldHandleNullFileSubPath() {
// Given
LibraryEntity library = LibraryEntity.builder().id(1L).watch(false).build();
LibraryPathEntity libraryPath = LibraryPathEntity.builder()
.id(1L)
.library(library)
.path("/tmp")
.build();
LibraryFile libraryFile = LibraryFile.builder()
.fileName("file.pdf")
.fileSubPath("") // Use empty string instead of null to avoid NPE in Paths.get
.bookFileType(BookFileType.PDF)
.libraryEntity(library)
.libraryPathEntity(libraryPath)
.build();
BookEntity newEntity = createMockBookEntity(3L, "file.pdf", "hash3", "", libraryFile.getLibraryPathEntity());
Book newBook = createMockBook(3L, "file.pdf");
try (MockedStatic<FileFingerprint> fingerprintMock = mockStatic(FileFingerprint.class)) {
fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash3");
when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any()))
.thenReturn(Optional.empty());
when(bookRepository.findBookByFileNameAndLibraryId("file.pdf", 1L))
.thenReturn(Optional.empty());
when(metadataMatchService.calculateMatchScore(any())).thenReturn(90F);
when(bookMapper.toBook(newEntity)).thenReturn(newBook);
processor.setProcessNewFileResult(newEntity);
// When
FileProcessResult result = processor.processFile(libraryFile);
// Then
assertThat(result.getStatus()).isEqualTo(FileProcessStatus.NEW);
assertThat(result.getBook()).isEqualTo(newBook);
}
}
@Test
void processFile_shouldHandleMultipleMetadataChangesSimultaneously() {
// Given
LibraryEntity newLibrary = LibraryEntity.builder().id(2L).watch(false).build();
LibraryPathEntity newLibraryPath = LibraryPathEntity.builder()
.id(2L)
.library(newLibrary)
.path("/new-path")
.build();
LibraryFile libraryFile = LibraryFile.builder()
.fileName("new-file.pdf")
.fileSubPath("new-sub")
.bookFileType(BookFileType.PDF)
.libraryEntity(newLibrary)
.libraryPathEntity(newLibraryPath)
.build();
Book duplicateBook = createMockBook(1L, "old-file.pdf");
BookEntity existingEntity = createMockBookEntity(1L, "old-file.pdf", "hash1", "old-sub",
LibraryPathEntity.builder().id(1L).library(LibraryEntity.builder().id(1L).watch(false).build()).build());
Book updatedBook = createMockBook(1L, "new-file.pdf");
try (MockedStatic<FileFingerprint> fingerprintMock = mockStatic(FileFingerprint.class)) {
fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash1");
when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any()))
.thenReturn(Optional.of(duplicateBook));
when(bookRepository.findById(1L)).thenReturn(Optional.of(existingEntity));
when(bookMapper.toBook(existingEntity)).thenReturn(updatedBook);
// When
FileProcessResult result = processor.processFile(libraryFile);
// Then
assertThat(result.getStatus()).isEqualTo(FileProcessStatus.UPDATED);
assertThat(existingEntity.getFileName()).isEqualTo("new-file.pdf");
assertThat(existingEntity.getFileSubPath()).isEqualTo("new-sub");
assertThat(existingEntity.getLibraryPath()).isEqualTo(newLibraryPath);
verify(entityManager).flush();
verify(entityManager).detach(existingEntity);
}
}
@Test
void createDuplicateInfo_shouldCreateCorrectDuplicateInfo() {
// Given
LibraryFile libraryFile = createMockLibraryFile();
Book book = createMockBook(1L, "file.pdf");
try (MockedStatic<FileFingerprint> fingerprintMock = mockStatic(FileFingerprint.class)) {
fingerprintMock.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash1");
when(fileService.checkForDuplicateAndUpdateMetadataIfNeeded(any(), any(), any(), any(), any()))
.thenReturn(Optional.of(book));
when(bookRepository.findById(1L)).thenReturn(Optional.of(
createMockBookEntity(1L, "file.pdf", "hash1", "sub", libraryFile.getLibraryPathEntity())));
// When
FileProcessResult result = processor.processFile(libraryFile);
// Then
DuplicateFileInfo duplicateInfo = result.getDuplicate();
assertThat(duplicateInfo).isNotNull();
assertThat(duplicateInfo.getBookId()).isEqualTo(1L);
assertThat(duplicateInfo.getFileName()).isEqualTo("file.pdf");
assertThat(duplicateInfo.getFullPath()).contains("/tmp", "sub", "file.pdf");
}
}
// Helper methods
private LibraryFile createMockLibraryFile() {
LibraryEntity library = LibraryEntity.builder().id(1L).watch(false).build();
LibraryPathEntity libraryPath = LibraryPathEntity.builder()
.id(1L)
.library(library)
.path("/tmp")
.build();
return LibraryFile.builder()
.fileName("file.pdf")
.fileSubPath("sub")
.bookFileType(BookFileType.PDF)
.libraryEntity(library)
.libraryPathEntity(libraryPath)
.build();
}
private Book createMockBook(Long id, String fileName) {
return Book.builder()
.id(id)
.fileName(fileName)
.fileSubPath("sub")
.build();
}
private BookEntity createMockBookEntity(Long id, String fileName, String hash, String subPath, LibraryPathEntity libraryPath) {
return BookEntity.builder()
.id(id)
.fileName(fileName)
.currentHash(hash)
.fileSubPath(subPath)
.libraryPath(libraryPath)
.build();
}
// Test implementation of AbstractFileProcessor
static class TestFileProcessor extends AbstractFileProcessor {
private BookEntity processNewFileResult;
public TestFileProcessor(BookRepository bookRepository,
BookAdditionalFileRepository bookAdditionalFileRepository,
BookCreatorService bookCreatorService,
BookMapper bookMapper,
FileService fileService,
MetadataMatchService metadataMatchService) {
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService);
}
@Override
protected BookEntity processNewFile(LibraryFile libraryFile) {
return processNewFileResult;
}
@Override
public List<BookFileType> getSupportedTypes() {
return List.of(BookFileType.PDF);
}
@Override
public boolean generateCover(BookEntity bookEntity) {
return false;
}
public void setProcessNewFileResult(BookEntity entity) {
this.processNewFileResult = entity;
}
}
}

View File

@@ -95,9 +95,9 @@ class FileAsBookProcessorTest {
when(processorRegistry.getProcessorOrThrow(BookFileType.EPUB)).thenReturn(bookFileProcessor);
when(processorRegistry.getProcessorOrThrow(BookFileType.PDF)).thenReturn(bookFileProcessor);
when(bookFileProcessor.processFile(file1))
.thenReturn(new FileProcessResult(book1, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(book1, FileProcessStatus.NEW));
when(bookFileProcessor.processFile(file2))
.thenReturn(new FileProcessResult(book2, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(book2, FileProcessStatus.NEW));
// When
fileAsBookProcessor.processLibraryFiles(libraryFiles, libraryEntity);
@@ -145,7 +145,7 @@ class FileAsBookProcessorTest {
when(processorRegistry.getProcessorOrThrow(BookFileType.EPUB)).thenReturn(bookFileProcessor);
when(bookFileProcessor.processFile(validFile))
.thenReturn(new FileProcessResult(book, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(book, FileProcessStatus.NEW));
// When
fileAsBookProcessor.processLibraryFiles(libraryFiles, libraryEntity);
@@ -215,7 +215,7 @@ class FileAsBookProcessorTest {
when(processorRegistry.getProcessorOrThrow(BookFileType.EPUB)).thenReturn(bookFileProcessor);
when(bookFileProcessor.processFile(libraryFile))
.thenReturn(new FileProcessResult(expectedBook, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(expectedBook, FileProcessStatus.NEW));
// When
FileProcessResult result = fileAsBookProcessor.processLibraryFile(libraryFile);
@@ -351,13 +351,13 @@ class FileAsBookProcessorTest {
when(processorRegistry.getProcessorOrThrow(BookFileType.CBX)).thenReturn(bookFileProcessor);
when(bookFileProcessor.processFile(epubFile))
.thenReturn(new FileProcessResult(epubBook, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(epubBook, FileProcessStatus.NEW));
when(bookFileProcessor.processFile(pdfFile))
.thenReturn(new FileProcessResult(pdfBook, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(pdfBook, FileProcessStatus.NEW));
when(bookFileProcessor.processFile(cbzFile))
.thenReturn(new FileProcessResult(cbzBook, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(cbzBook, FileProcessStatus.NEW));
when(bookFileProcessor.processFile(cbrFile))
.thenReturn(new FileProcessResult(cbrBook, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(cbrBook, FileProcessStatus.NEW));
// When
fileAsBookProcessor.processLibraryFiles(libraryFiles, libraryEntity);

View File

@@ -120,7 +120,7 @@ class FolderAsBookFileProcessorTest {
when(bookFileProcessorRegistry.getProcessorOrThrow(BookFileType.PDF))
.thenReturn(mockBookFileProcessor);
when(mockBookFileProcessor.processFile(any(LibraryFile.class)))
.thenReturn(new FileProcessResult(createdBook, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(createdBook, FileProcessStatus.NEW));
when(bookRepository.getReferenceById(createdBook.getId()))
.thenReturn(bookEntity);
when(bookAdditionalFileRepository.findByLibraryPath_IdAndFileSubPathAndFileName(anyLong(), anyString(), anyString()))
@@ -232,7 +232,7 @@ class FolderAsBookFileProcessorTest {
when(bookFileProcessorRegistry.getProcessorOrThrow(BookFileType.EPUB))
.thenReturn(mockBookFileProcessor);
when(mockBookFileProcessor.processFile(argThat(file -> file.getFileName().equals("book.epub"))))
.thenReturn(new FileProcessResult(createdBook, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(createdBook, FileProcessStatus.NEW));
when(bookRepository.getReferenceById(createdBook.getId()))
.thenReturn(bookEntity);
when(bookAdditionalFileRepository.findByLibraryPath_IdAndFileSubPathAndFileName(anyLong(), anyString(), anyString()))
@@ -277,7 +277,7 @@ class FolderAsBookFileProcessorTest {
when(bookFileProcessorRegistry.getProcessorOrThrow(BookFileType.PDF))
.thenReturn(mockBookFileProcessor);
when(mockBookFileProcessor.processFile(argThat(file -> file.getFileName().equals("book.pdf"))))
.thenReturn(new FileProcessResult(createdBook, FileProcessStatus.NEW, null));
.thenReturn(new FileProcessResult(createdBook, FileProcessStatus.NEW));
when(bookRepository.getReferenceById(createdBook.getId()))
.thenReturn(bookEntity);
when(bookAdditionalFileRepository.findByLibraryPath_IdAndFileSubPathAndFileName(anyLong(), anyString(), anyString()))

View File

@@ -65,14 +65,14 @@ class MetadataManagementServiceTest {
AuthorEntity oldAuthor = new AuthorEntity();
oldAuthor.setName(oldName);
when(authorRepository.findByName(targetName)).thenReturn(Optional.empty());
when(authorRepository.findByNameIgnoreCase(targetName)).thenReturn(Optional.empty());
when(authorRepository.save(any(AuthorEntity.class))).thenAnswer(invocation -> {
AuthorEntity a = invocation.getArgument(0);
a.setName(a.getName());
return a;
});
when(authorRepository.findByName(oldName)).thenReturn(Optional.of(oldAuthor));
when(authorRepository.findByNameIgnoreCase(oldName)).thenReturn(Optional.of(oldAuthor));
BookMetadataEntity metadata = mock(BookMetadataEntity.class);
Set<AuthorEntity> authorsSet = new HashSet<>();
@@ -93,28 +93,6 @@ class MetadataManagementServiceTest {
verify(authorRepository).delete(oldAuthor);
}
@Test
void deleteAuthors_removesFromBooksAndDeletes() {
String name = "Author To Delete";
AuthorEntity author = new AuthorEntity();
author.setName(name);
when(authorRepository.findByName(name)).thenReturn(Optional.of(author));
BookMetadataEntity metadata = mock(BookMetadataEntity.class);
Set<AuthorEntity> authorsSet = new HashSet<>();
authorsSet.add(author);
when(metadata.getAuthors()).thenReturn(authorsSet);
when(bookMetadataRepository.findAllByAuthorsContaining(author)).thenReturn(List.of(metadata));
service.deleteMetadata(MergeMetadataType.authors, List.of(name));
assertThat(authorsSet).doesNotContain(author);
verify(bookMetadataRepository).saveAll(bookListCaptor.capture());
verify(authorRepository).delete(author);
}
@Test
void mergeCategories_movesAndDeletesOldCategory() {
String targetName = "New Category";
@@ -299,37 +277,6 @@ class MetadataManagementServiceTest {
() -> service.consolidateMetadata(MergeMetadataType.languages, List.of("L1", "L2"), List.of("Old")));
}
@Test
void mergeAuthors_targetExists_usesExistingTargetAndDeletesOld() {
String targetName = "Existing Author";
String oldName = "Old Author";
AuthorEntity existingAuthor = new AuthorEntity();
existingAuthor.setName(targetName);
AuthorEntity oldAuthor = new AuthorEntity();
oldAuthor.setName(oldName);
when(authorRepository.findByName(targetName)).thenReturn(Optional.of(existingAuthor));
when(authorRepository.findByName(oldName)).thenReturn(Optional.of(oldAuthor));
BookMetadataEntity metadata = mock(BookMetadataEntity.class);
Set<AuthorEntity> authorsSet = new HashSet<>();
authorsSet.add(oldAuthor);
when(metadata.getAuthors()).thenReturn(authorsSet);
when(bookMetadataRepository.findAllByAuthorsContaining(oldAuthor)).thenReturn(List.of(metadata));
service.consolidateMetadata(MergeMetadataType.authors, List.of(targetName), List.of(oldName));
assertThat(authorsSet).doesNotContain(oldAuthor);
assertThat(authorsSet).contains(existingAuthor);
verify(authorRepository, never()).save(any(AuthorEntity.class));
verify(authorRepository).delete(oldAuthor);
verify(bookMetadataRepository).saveAll(bookListCaptor.capture());
}
@Test
void mergeTags_mergesMultipleOldTagsIntoSingleTarget() {
String targetName = "UnifiedTag";
@@ -367,37 +314,6 @@ class MetadataManagementServiceTest {
verify(bookMetadataRepository, times(2)).saveAll(anyList());
}
@Test
void mergeAuthors_ignoresNonExistentOldAuthor() {
String targetName = "TargetAuthor";
String existingOld = "ExistingOld";
String missingOld = "MissingOld";
AuthorEntity target = new AuthorEntity();
target.setName(targetName);
AuthorEntity old = new AuthorEntity();
old.setName(existingOld);
when(authorRepository.findByName(targetName)).thenReturn(Optional.of(target));
when(authorRepository.findByName(existingOld)).thenReturn(Optional.of(old));
when(authorRepository.findByName(missingOld)).thenReturn(Optional.empty());
BookMetadataEntity metadata = mock(BookMetadataEntity.class);
Set<AuthorEntity> authors = new HashSet<>();
authors.add(old);
when(metadata.getAuthors()).thenReturn(authors);
when(bookMetadataRepository.findAllByAuthorsContaining(old)).thenReturn(List.of(metadata));
service.consolidateMetadata(MergeMetadataType.authors, List.of(targetName), List.of(existingOld, missingOld));
assertThat(authors).doesNotContain(old);
assertThat(authors).contains(target);
verify(authorRepository).delete(old);
verify(authorRepository, never()).delete(argThat(a -> missingOld.equals(a.getName())));
verify(bookMetadataRepository).saveAll(anyList());
}
@Test
void mergeCategories_doesNotDuplicateExistingTarget() {
String targetName = "CatTarget";
@@ -464,16 +380,6 @@ class MetadataManagementServiceTest {
verify(tagRepository).delete(old);
}
@Test
void deleteAuthors_noMatchingAuthor_noOps() {
when(authorRepository.findByName("NoAuthor")).thenReturn(Optional.empty());
service.deleteMetadata(MergeMetadataType.authors, List.of("NoAuthor"));
verify(bookMetadataRepository, never()).saveAll(anyList());
verify(authorRepository, never()).delete(any());
}
@Test
void deleteTags_partialMissing_ignoresMissing() {
String present = "PresentTag";

View File

@@ -0,0 +1,86 @@
package com.adityachandel.booklore.service.metadata.extractor;
import com.adityachandel.booklore.model.dto.BookMetadata;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.PDPage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
class PdfMetadataExtractorTest {
private PdfMetadataExtractor extractor;
@TempDir
Path tempDir;
@BeforeEach
void setUp() {
extractor = new PdfMetadataExtractor();
}
@Test
void extractMetadata_shouldUseTitleFromMetadata_whenAvailable() throws IOException {
// Arrange: Create a PDF with an explicit Title in metadata
File pdfFile = tempDir.resolve("ignored-filename.pdf").toFile();
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
PDDocumentInformation info = new PDDocumentInformation();
info.setTitle("The Real Book Title");
doc.setDocumentInformation(info);
doc.save(pdfFile);
}
// Act
BookMetadata result = extractor.extractMetadata(pdfFile);
// Assert: Metadata title takes precedence over filename
assertEquals("The Real Book Title", result.getTitle());
}
@Test
void extractMetadata_shouldUseFilenameWithoutExtension_whenMetadataMissing() throws IOException {
// Arrange: Create a PDF with NO metadata title
// Name the file "Dune.pdf"
File pdfFile = tempDir.resolve("Dune.pdf").toFile();
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
// explicitly leaving metadata empty
doc.save(pdfFile);
}
// Act
BookMetadata result = extractor.extractMetadata(pdfFile);
// Assert: The extension ".pdf" should be stripped
assertEquals("Dune", result.getTitle());
}
@Test
void extractMetadata_shouldHandleSpacesAndSpecialCharsInFilename() throws IOException {
// Arrange
File pdfFile = tempDir.resolve("Harry Potter and the Sorcerer's Stone.pdf").toFile();
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
doc.save(pdfFile);
}
// Act
BookMetadata result = extractor.extractMetadata(pdfFile);
// Assert
assertEquals("Harry Potter and the Sorcerer's Stone", result.getTitle());
}
}

View File

@@ -0,0 +1,123 @@
package com.adityachandel.booklore.util;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class BookUtilsTest {
@Test
void testCleanFileName_nullInput() {
String result = BookUtils.cleanFileName(null);
assertNull(result);
}
@Test
void testCleanFileName_simpleName() {
String result = BookUtils.cleanFileName("Test Book.pdf");
assertEquals("Test Book", result);
}
@Test
void testCleanFileName_withZLibrary() {
String result = BookUtils.cleanFileName("Test Book (Z-Library).pdf");
assertEquals("Test Book", result);
}
@Test
void testCleanFileName_withAuthorInParentheses() {
String result = BookUtils.cleanFileName("Test Book (John Doe).pdf");
assertEquals("Test Book", result);
}
@Test
void testCleanFileName_withMultipleExtensions() {
String result = BookUtils.cleanFileName("Test Book.epub.zip");
assertEquals("Test Book.epub", result);
}
@Test
void testCleanFileName_noExtension() {
String result = BookUtils.cleanFileName("Test Book");
assertEquals("Test Book", result);
}
@Test
void testCleanFileName_onlyExtension() {
// Hidden files (starting with dot) should keep their name to avoid empty strings
// which could cause DB constraint or filesystem errors
String result = BookUtils.cleanFileName(".pdf");
assertEquals(".pdf", result);
}
@Test
void testCleanFileName_complexCase() {
String result = BookUtils.cleanFileName("Advanced Calculus (Z-Library) (Michael Spivak).pdf");
assertEquals("Advanced Calculus", result);
}
@Test
void testCleanAndTruncateSearchTerm_nullInput() {
String result = BookUtils.cleanAndTruncateSearchTerm(null);
assertEquals("", result);
}
@Test
void testCleanAndTruncateSearchTerm_emptyString() {
String result = BookUtils.cleanAndTruncateSearchTerm("");
assertEquals("", result);
}
@Test
void testCleanAndTruncateSearchTerm_simpleText() {
String result = BookUtils.cleanAndTruncateSearchTerm("Hello World");
assertEquals("Hello World", result);
}
@Test
void testCleanAndTruncateSearchTerm_withSpecialChars() {
String result = BookUtils.cleanAndTruncateSearchTerm("Hello, World! How are you?");
assertEquals("Hello World How are you", result);
}
@Test
void testCleanAndTruncateSearchTerm_withBrackets() {
String result = BookUtils.cleanAndTruncateSearchTerm("Test [Book] {Series}");
assertEquals("Test Book Series", result);
}
@Test
void testCleanAndTruncateSearchTerm_longText() {
String longText = "This is a very long search term that should be truncated because it exceeds sixty characters in length and needs to be shortened";
String result = BookUtils.cleanAndTruncateSearchTerm(longText);
assertTrue(result.length() <= 60);
assertEquals("This is a very long search term that should be truncated", result);
}
@Test
void testCleanAndTruncateSearchTerm_longTextWithSpecialChars() {
String longText = "This-is,a@very#long$search%term^with&special*chars(that)should[be]truncated{because}it<exceeds>sixty?characters";
String result = BookUtils.cleanAndTruncateSearchTerm(longText);
assertTrue(result.length() <= 60);
assertEquals("Thisisaverylongsearchtermwithspecialcharsthatshouldbetruncat", result);
}
@Test
void testCleanAndTruncateSearchTerm_exactly60Chars() {
String text = "A".repeat(60);
String result = BookUtils.cleanAndTruncateSearchTerm(text);
assertEquals(text, result);
assertEquals(60, result.length());
}
@Test
void testCleanAndTruncateSearchTerm_whitespaceHandling() {
String result = BookUtils.cleanAndTruncateSearchTerm(" Multiple Spaces Here ");
assertEquals("Multiple Spaces Here", result);
}
@Test
void testCleanAndTruncateSearchTerm_onlySpecialChars() {
String result = BookUtils.cleanAndTruncateSearchTerm(",.!@#$%^&*()[]{}");
assertEquals("", result);
}
}

View File

@@ -0,0 +1,72 @@
package com.adityachandel.booklore.util;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class FileServiceTest {
@Test
void testTruncate_nullInput() {
String result = FileService.truncate(null, 10);
assertNull(result);
}
@Test
void testTruncate_emptyString() {
String result = FileService.truncate("", 10);
assertEquals("", result);
}
@Test
void testTruncate_shortString() {
String input = "short";
String result = FileService.truncate(input, 10);
assertEquals("short", result);
}
@Test
void testTruncate_exactLength() {
String input = "exactly10";
String result = FileService.truncate(input, 9);
assertEquals("exactly10", result);
}
@Test
void testTruncate_longString() {
String input = "this is a very long string that should be truncated";
String result = FileService.truncate(input, 20);
assertEquals("this is a very long ", result);
assertEquals(20, result.length());
}
@Test
void testTruncate_zeroMaxLength() {
String input = "test string";
String result = FileService.truncate(input, 0);
assertEquals("", result);
}
@Test
void testTruncate_negativeMaxLength() {
String input = "test string";
String result = FileService.truncate(input, -5);
assertEquals("", result);
}
@Test
void testTruncate_unicodeCharacters() {
String input = "héllo wörld with unicode";
String result = FileService.truncate(input, 15);
assertEquals("héllo wörld wit", result);
assertEquals(15, result.length());
}
@Test
void testTruncate_multibyteCharacters() {
String input = "🚀 rocket emoji test 🌟";
String result = FileService.truncate(input, 10);
// Note: This might not be exactly 10 characters due to how Java handles string length with emojis
assertTrue(result.length() <= input.length());
}
}

View File

@@ -0,0 +1,80 @@
package com.adityachandel.booklore.util;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class Md5UtilTest {
@Test
void testMd5Hex_emptyString() {
String result = Md5Util.md5Hex("");
assertEquals("d41d8cd98f00b204e9800998ecf8427e", result);
}
@Test
void testMd5Hex_simpleText() {
String result = Md5Util.md5Hex("hello");
assertEquals("5d41402abc4b2a76b9719d911017c592", result);
}
@Test
void testMd5Hex_helloWorld() {
String result = Md5Util.md5Hex("hello world");
assertEquals("5eb63bbbe01eeed093cb22bb8f5acdc3", result);
}
@Test
void testMd5Hex_numbers() {
String result = Md5Util.md5Hex("123456789");
assertEquals("25f9e794323b453885f5181f1b624d0b", result);
}
@Test
void testMd5Hex_specialCharacters() {
String result = Md5Util.md5Hex("!@#$%^&*()");
assertEquals("05b28d17a7b6e7024b6e5d8cc43a8bf7", result);
}
@Test
void testMd5Hex_unicode() {
String result = Md5Util.md5Hex("héllo wörld");
assertEquals("ed0c22cc110ede12327851863c078138", result);
}
@Test
void testMd5Hex_longText() {
String longText = "This is a longer text that should produce a consistent MD5 hash regardless of how many times we call the function with the same input.";
String result1 = Md5Util.md5Hex(longText);
String result2 = Md5Util.md5Hex(longText);
assertEquals(result1, result2);
assertEquals(32, result1.length());
}
@Test
void testMd5Hex_differentInputs() {
String result1 = Md5Util.md5Hex("hello");
String result2 = Md5Util.md5Hex("world");
assertNotEquals(result1, result2);
}
@Test
void testMd5Hex_caseSensitive() {
String result1 = Md5Util.md5Hex("Hello");
String result2 = Md5Util.md5Hex("hello");
assertNotEquals(result1, result2);
}
@Test
void testMd5Hex_nullInput() {
// MD5Util now handles null input safely by returning null
String result = Md5Util.md5Hex(null);
assertNull(result);
}
@Test
void testMd5Hex_length() {
String result = Md5Util.md5Hex("any input");
assertEquals(32, result.length()); // MD5 always produces 32 character hex string
assertTrue(result.matches("[a-f0-9]{32}")); // Only lowercase hex characters
}
}

View File

@@ -0,0 +1,314 @@
package com.adityachandel.booklore.util;
import com.adityachandel.booklore.model.dto.BookMetadata;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
class PathPatternResolverTest {
@Test
void testResolvePattern_nullPattern() {
BookMetadata metadata = BookMetadata.builder()
.title("Test Book")
.build();
String result = PathPatternResolver.resolvePattern(metadata, null, "test.pdf");
assertEquals("test.pdf", result);
}
@Test
void testResolvePattern_blankPattern() {
BookMetadata metadata = BookMetadata.builder()
.title("Test Book")
.build();
String result = PathPatternResolver.resolvePattern(metadata, "", "test.pdf");
assertEquals("test.pdf", result);
}
@Test
void testResolvePattern_whitespacePattern() {
BookMetadata metadata = BookMetadata.builder()
.title("Test Book")
.build();
String result = PathPatternResolver.resolvePattern(metadata, " ", "test.pdf");
assertEquals("test.pdf", result);
}
@Test
void testResolvePattern_simpleTitle() {
BookMetadata metadata = BookMetadata.builder()
.title("Test Book")
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{title}", "original.pdf");
assertEquals("Test Book.pdf", result);
}
@Test
void testResolvePattern_titleWithExtension() {
BookMetadata metadata = BookMetadata.builder()
.title("Test Book")
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{title}.{extension}", "original.pdf");
assertEquals("Test Book.pdf", result);
}
@Test
void testResolvePattern_multiplePlaceholders() {
BookMetadata metadata = BookMetadata.builder()
.title("Test Book")
.authors(Set.of("John Doe", "Jane Smith"))
.publishedDate(LocalDate.of(2023, 5, 15))
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{authors} - {title} ({year})", "original.pdf");
// Authors from a Set may be in any order
assertTrue(result.equals("John Doe, Jane Smith - Test Book (2023).pdf") ||
result.equals("Jane Smith, John Doe - Test Book (2023).pdf"));
}
@Test
void testResolvePattern_authorsList() {
BookMetadata metadata = BookMetadata.builder()
.authors(Set.of("Author One", "Author Two"))
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{authors}", "original.pdf");
// Authors from a Set may be in any order
assertTrue(result.equals("Author One, Author Two.pdf") || result.equals("Author Two, Author One.pdf"));
}
@Test
void testResolvePattern_seriesInfo() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.seriesName("Series Name")
.seriesNumber(2.0f)
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{series} #{seriesIndex} - {title}", "original.pdf");
assertEquals("Series Name #2 - Book Title.pdf", result);
}
@Test
void testResolvePattern_seriesNumberFloat() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.seriesName("Series Name")
.seriesNumber(2.5f)
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{series} #{seriesIndex} - {title}", "original.pdf");
assertEquals("Series Name #2.5 - Book Title.pdf", result);
}
@Test
void testResolvePattern_optionalBlock_allPresent() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.authors(Set.of("Author Name"))
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{title}< - {authors}>", "original.pdf");
assertEquals("Book Title - Author Name.pdf", result);
}
@Test
void testResolvePattern_optionalBlock_missingValue() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
// authors is missing/empty
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{title}< - {authors}>", "original.pdf");
assertEquals("Book Title.pdf", result);
}
@Test
void testResolvePattern_isbnPriority() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.isbn13("9781234567890")
.isbn10("1234567890")
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{title} - {isbn}", "original.pdf");
assertEquals("Book Title - 9781234567890.pdf", result);
}
@Test
void testResolvePattern_isbn10Fallback() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.isbn10("1234567890")
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{title} - {isbn}", "original.pdf");
assertEquals("Book Title - 1234567890.pdf", result);
}
@Test
void testResolvePattern_nullMetadata() {
String result = PathPatternResolver.resolvePattern((BookMetadata) null, "{title}", "original.pdf");
assertEquals("Untitled.pdf", result);
}
@Test
void testResolvePattern_nullTitle() {
BookMetadata metadata = BookMetadata.builder()
.title(null)
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{title}", "original.pdf");
assertEquals("Untitled.pdf", result);
}
@Test
void testResolvePattern_currentFilename() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{currentFilename}", "original.pdf");
assertEquals("original.pdf", result);
}
@Test
void testResolvePattern_withBookEntity() {
BookMetadataEntity metadata = new BookMetadataEntity();
metadata.setTitle("Book Title");
BookEntity book = new BookEntity();
book.setFileName("book.epub");
book.setMetadata(metadata);
String result = PathPatternResolver.resolvePattern(book, "{title}.{extension}");
assertEquals("Book Title.epub", result);
}
@Test
void testResolvePattern_withBookMetadataEntity() {
BookMetadataEntity metadata = new BookMetadataEntity();
metadata.setTitle("Book Title");
String result = PathPatternResolver.resolvePattern(metadata, "{title}", "original.pdf");
assertEquals("Book Title.pdf", result);
}
@Test
void testResolvePattern_specialCharacters() {
BookMetadata metadata = BookMetadata.builder()
.title("Book: Title? *With* Special/Chars")
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{title}", "original.pdf");
// Special characters should be sanitized
assertEquals("Book Title With SpecialChars.pdf", result);
}
@Test
void testResolvePattern_emptyAuthors() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.authors(Set.of())
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{title}< - {authors}>", "original.pdf");
assertEquals("Book Title.pdf", result);
}
@Test
void testResolvePattern_handlesNullPattern() {
BookMetadata metadata = BookMetadata.builder()
.title("Test Book")
.build();
String result = PathPatternResolver.resolvePattern(metadata, null, "original.pdf");
assertEquals("original.pdf", result);
}
@Test
void testResolvePattern_sanitizesIllegalCharacters() {
BookMetadata metadata = BookMetadata.builder()
.title("Book: The Sequel/Prequel? Illegal*Chars")
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{title}", "original.pdf");
// Should sanitize illegal filesystem characters
assertNotEquals("Book: The Sequel/Prequel? Illegal*Chars", result);
assertTrue(result.contains("Book") && result.contains("Sequel"));
}
@Test
void testResolvePattern_handlesMissingMetadataFields() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
// authors is intentionally missing/null
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{title}< - {authors}>", "original.pdf");
// Optional block should be omitted since authors is missing
assertEquals("Book Title.pdf", result);
}
@Test
void testResolvePattern_emptyOptionalBlocks() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{title}< [{series}]>< ({year})>", "original.pdf");
// Both optional blocks should be omitted since series and year are missing
assertEquals("Book Title.pdf", result);
}
@Test
void testResolvePattern_complexPattern() {
BookMetadata metadata = BookMetadata.builder()
.title("The Great Book")
.authors(Set.of("John Doe", "Jane Smith"))
.seriesName("Awesome Series")
.seriesNumber(3.0f)
.publishedDate(LocalDate.of(2023, 5, 15))
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{authors} - {title}< [{series} #{seriesIndex}]>< ({year})>", "original.pdf");
// Authors from a Set may be in any order
assertTrue(result.equals("John Doe, Jane Smith - The Great Book [Awesome Series #3] (2023).pdf") ||
result.equals("Jane Smith, John Doe - The Great Book [Awesome Series #3] (2023).pdf"));
}
}

View File

@@ -0,0 +1,59 @@
package com.adityachandel.booklore.util;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import static org.junit.jupiter.api.Assertions.*;
class RequestUtilsTest {
private HttpServletRequest mockRequest;
private ServletRequestAttributes requestAttributes;
@BeforeEach
void setUp() {
mockRequest = new MockHttpServletRequest();
requestAttributes = new ServletRequestAttributes(mockRequest);
RequestContextHolder.setRequestAttributes(requestAttributes);
}
@AfterEach
void tearDown() {
RequestContextHolder.resetRequestAttributes();
}
@Test
void testGetCurrentRequest_success() {
HttpServletRequest result = RequestUtils.getCurrentRequest();
assertNotNull(result);
assertEquals(mockRequest, result);
}
@Test
void testGetCurrentRequest_noRequestAttributes() {
RequestContextHolder.resetRequestAttributes();
assertThrows(IllegalStateException.class, RequestUtils::getCurrentRequest);
}
@Test
void testGetCurrentRequest_nonServletAttributes() {
RequestContextHolder.setRequestAttributes(null);
assertThrows(IllegalStateException.class, RequestUtils::getCurrentRequest);
}
@Test
void testGetCurrentRequest_multipleCalls() {
HttpServletRequest result1 = RequestUtils.getCurrentRequest();
HttpServletRequest result2 = RequestUtils.getCurrentRequest();
assertEquals(result1, result2);
assertEquals(mockRequest, result1);
}
}

View File

@@ -0,0 +1,156 @@
package com.adityachandel.booklore.util;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextImpl;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
class SecurityContextVirtualThreadTest {
private Authentication originalAuth;
private SecurityContext originalContext;
@BeforeEach
void setUp() {
originalAuth = new UsernamePasswordAuthenticationToken("testUser", "password", null);
originalContext = new SecurityContextImpl(originalAuth);
SecurityContextHolder.setContext(originalContext);
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
@Test
void testRunWithSecurityContext_executesRunnable() throws ExecutionException, InterruptedException {
AtomicReference<String> result = new AtomicReference<>();
CompletableFuture<Void> future = new CompletableFuture<>();
SecurityContextVirtualThread.runWithSecurityContext(() -> {
try {
result.set("executed");
future.complete(null);
} catch (Exception e) {
future.completeExceptionally(e);
}
});
// Wait for the virtual thread to complete
future.get();
assertEquals("executed", result.get());
}
@Test
void testRunWithSecurityContext_preservesSecurityContext() throws ExecutionException, InterruptedException {
AtomicReference<Authentication> capturedAuth = new AtomicReference<>();
CompletableFuture<Void> future = new CompletableFuture<>();
SecurityContextVirtualThread.runWithSecurityContext(() -> {
try {
capturedAuth.set(SecurityContextHolder.getContext().getAuthentication());
future.complete(null);
} catch (Exception e) {
future.completeExceptionally(e);
}
});
future.get();
assertNotNull(capturedAuth.get());
assertEquals(originalAuth.getName(), capturedAuth.get().getName());
}
@Test
void testRunWithSecurityContext_maintainsMainThreadContext() throws ExecutionException, InterruptedException {
CompletableFuture<Void> future = new CompletableFuture<>();
SecurityContextVirtualThread.runWithSecurityContext(() -> {
try {
// Context should be set here
assertNotNull(SecurityContextHolder.getContext().getAuthentication());
future.complete(null);
} catch (Exception e) {
future.completeExceptionally(e);
}
});
future.get();
// After the virtual thread completes, the main thread's context should still be intact
Authentication mainThreadAuth = SecurityContextHolder.getContext().getAuthentication();
assertNotNull(mainThreadAuth);
assertEquals(originalAuth.getName(), mainThreadAuth.getName());
}
@Test
void testRunWithSecurityContext_withProvidedContext() throws ExecutionException, InterruptedException {
Authentication differentAuth = new UsernamePasswordAuthenticationToken("differentUser", "password", null);
SecurityContext differentContext = new SecurityContextImpl(differentAuth);
AtomicReference<Authentication> capturedAuth = new AtomicReference<>();
CompletableFuture<Void> future = new CompletableFuture<>();
SecurityContextVirtualThread.runWithSecurityContext(differentContext, () -> {
try {
capturedAuth.set(SecurityContextHolder.getContext().getAuthentication());
future.complete(null);
} catch (Exception e) {
future.completeExceptionally(e);
}
});
future.get();
assertNotNull(capturedAuth.get());
assertEquals("differentUser", capturedAuth.get().getName());
}
@Test
void testRunWithSecurityContext_withProvidedContext_clearsAfterExecution() throws ExecutionException, InterruptedException {
Authentication differentAuth = new UsernamePasswordAuthenticationToken("differentUser", "password", null);
SecurityContext differentContext = new SecurityContextImpl(differentAuth);
CompletableFuture<Void> future = new CompletableFuture<>();
SecurityContextVirtualThread.runWithSecurityContext(differentContext, () -> {
try {
// Different context should be set here
assertEquals("differentUser", SecurityContextHolder.getContext().getAuthentication().getName());
future.complete(null);
} catch (Exception e) {
future.completeExceptionally(e);
}
});
future.get();
// Main thread should still have original context
Authentication mainThreadAuth = SecurityContextHolder.getContext().getAuthentication();
assertEquals("testUser", mainThreadAuth.getName());
}
@Test
void testRunWithSecurityContext_handlesException() throws ExecutionException, InterruptedException {
CompletableFuture<Exception> future = new CompletableFuture<>();
SecurityContextVirtualThread.runWithSecurityContext(() -> {
try {
throw new RuntimeException("Test exception");
} catch (Exception e) {
future.complete(e);
throw e;
}
});
Exception capturedException = future.get();
assertNotNull(capturedException);
assertEquals("Test exception", capturedException.getMessage());
}
}

View File

@@ -0,0 +1,94 @@
package com.adityachandel.booklore.util;
import com.adityachandel.booklore.model.entity.UserPermissionsEntity;
import com.adityachandel.booklore.model.enums.PermissionType;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import static org.junit.jupiter.api.Assertions.*;
class UserPermissionUtilsTest {
@ParameterizedTest
@EnumSource(PermissionType.class)
void testHasPermission_true(PermissionType permissionType) {
UserPermissionsEntity perms = createPermissionsWith(permissionType, true);
assertTrue(UserPermissionUtils.hasPermission(perms, permissionType));
}
@ParameterizedTest
@EnumSource(PermissionType.class)
void testHasPermission_false(PermissionType permissionType) {
UserPermissionsEntity perms = createPermissionsWith(permissionType, false);
assertFalse(UserPermissionUtils.hasPermission(perms, permissionType));
}
@Test
void testHasPermission_allPermissionsFalse() {
UserPermissionsEntity perms = UserPermissionsEntity.builder()
.permissionUpload(false)
.permissionDownload(false)
.permissionEditMetadata(false)
.permissionManipulateLibrary(false)
.permissionEmailBook(false)
.permissionDeleteBook(false)
.permissionAccessOpds(false)
.permissionSyncKoreader(false)
.permissionSyncKobo(false)
.permissionAdmin(false)
.build();
for (PermissionType type : PermissionType.values()) {
assertFalse(UserPermissionUtils.hasPermission(perms, type));
}
}
@Test
void testHasPermission_allPermissionsTrue() {
UserPermissionsEntity perms = UserPermissionsEntity.builder()
.permissionUpload(true)
.permissionDownload(true)
.permissionEditMetadata(true)
.permissionManipulateLibrary(true)
.permissionEmailBook(true)
.permissionDeleteBook(true)
.permissionAccessOpds(true)
.permissionSyncKoreader(true)
.permissionSyncKobo(true)
.permissionAdmin(true)
.build();
for (PermissionType type : PermissionType.values()) {
assertTrue(UserPermissionUtils.hasPermission(perms, type));
}
}
private UserPermissionsEntity createPermissionsWith(PermissionType permissionType, boolean value) {
UserPermissionsEntity.UserPermissionsEntityBuilder builder = UserPermissionsEntity.builder()
.permissionUpload(false)
.permissionDownload(false)
.permissionEditMetadata(false)
.permissionManipulateLibrary(false)
.permissionEmailBook(false)
.permissionDeleteBook(false)
.permissionAccessOpds(false)
.permissionSyncKoreader(false)
.permissionSyncKobo(false)
.permissionAdmin(false);
switch (permissionType) {
case UPLOAD -> builder.permissionUpload(value);
case DOWNLOAD -> builder.permissionDownload(value);
case EDIT_METADATA -> builder.permissionEditMetadata(value);
case MANIPULATE_LIBRARY -> builder.permissionManipulateLibrary(value);
case EMAIL_BOOK -> builder.permissionEmailBook(value);
case DELETE_BOOK -> builder.permissionDeleteBook(value);
case ACCESS_OPDS -> builder.permissionAccessOpds(value);
case SYNC_KOREADER -> builder.permissionSyncKoreader(value);
case SYNC_KOBO -> builder.permissionSyncKobo(value);
case ADMIN -> builder.permissionAdmin(value);
}
return builder.build();
}
}

View File

@@ -0,0 +1,170 @@
package com.adityachandel.booklore.util.kobo;
import com.adityachandel.booklore.model.dto.BookloreSyncToken;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.mock.web.MockHttpServletRequest;
import java.util.Base64;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class BookloreSyncTokenGeneratorTest {
private ObjectMapper objectMapper;
private BookloreSyncTokenGenerator generator;
@BeforeEach
void setUp() {
objectMapper = mock(ObjectMapper.class);
generator = new BookloreSyncTokenGenerator(objectMapper);
}
@Test
void testToBase64_success() throws Exception {
BookloreSyncToken token = BookloreSyncToken.builder()
.ongoingSyncPointId("ongoing123")
.lastSuccessfulSyncPointId("last456")
.rawKoboSyncToken("raw789")
.build();
String json = "{\"ongoingSyncPointId\":\"ongoing123\",\"lastSuccessfulSyncPointId\":\"last456\",\"rawKoboSyncToken\":\"raw789\"}";
when(objectMapper.writeValueAsString(token)).thenReturn(json);
String result = generator.toBase64(token);
assertTrue(result.startsWith("BOOKLORE."));
String base64Part = result.substring("BOOKLORE.".length());
String decodedJson = new String(Base64.getDecoder().decode(base64Part));
assertEquals(json, decodedJson);
}
@Test
void testToBase64_exception() throws Exception {
BookloreSyncToken token = BookloreSyncToken.builder().build();
when(objectMapper.writeValueAsString(any(BookloreSyncToken.class)))
.thenThrow(new RuntimeException("Serialization failed"));
String result = generator.toBase64(token);
assertEquals("BOOKLORE.", result);
}
@Test
void testFromBase64_booklorePrefix() throws Exception {
BookloreSyncToken token = BookloreSyncToken.builder()
.ongoingSyncPointId("test123")
.lastSuccessfulSyncPointId("test456")
.build();
String json = "{\"ongoingSyncPointId\":\"test123\",\"lastSuccessfulSyncPointId\":\"test456\"}";
String base64 = Base64.getEncoder().encodeToString(json.getBytes());
String tokenB64 = "BOOKLORE." + base64;
when(objectMapper.readValue(json.getBytes(), BookloreSyncToken.class)).thenReturn(token);
BookloreSyncToken result = generator.fromBase64(tokenB64);
assertNotNull(result);
assertEquals("test123", result.getOngoingSyncPointId());
assertEquals("test456", result.getLastSuccessfulSyncPointId());
}
@Test
void testFromBase64_withDot() throws Exception {
String rawToken = "some.raw.token";
BookloreSyncToken result = generator.fromBase64(rawToken);
assertNotNull(result);
assertEquals(rawToken, result.getRawKoboSyncToken());
assertNull(result.getOngoingSyncPointId());
assertNull(result.getLastSuccessfulSyncPointId());
}
@Test
void testFromBase64_invalidBase64() {
String invalidToken = "BOOKLORE.invalidbase64";
BookloreSyncToken result = generator.fromBase64(invalidToken);
assertNotNull(result);
assertNull(result.getOngoingSyncPointId());
assertNull(result.getLastSuccessfulSyncPointId());
assertNull(result.getRawKoboSyncToken());
}
@Test
void testFromBase64_deserializationException() throws Exception {
String json = "{\"invalid\":\"json\"}";
String base64 = Base64.getEncoder().encodeToString(json.getBytes());
String tokenB64 = "BOOKLORE." + base64;
when(objectMapper.readValue(any(byte[].class), eq(BookloreSyncToken.class)))
.thenThrow(new RuntimeException("Deserialization failed"));
BookloreSyncToken result = generator.fromBase64(tokenB64);
assertNotNull(result);
assertNull(result.getOngoingSyncPointId());
assertNull(result.getLastSuccessfulSyncPointId());
assertNull(result.getRawKoboSyncToken());
}
@Test
void testFromBase64_emptyString() {
BookloreSyncToken result = generator.fromBase64("");
assertNotNull(result);
assertNull(result.getOngoingSyncPointId());
assertNull(result.getLastSuccessfulSyncPointId());
assertNull(result.getRawKoboSyncToken());
}
@Test
void testFromBase64_null() {
BookloreSyncToken result = generator.fromBase64(null);
assertNotNull(result);
assertNull(result.getOngoingSyncPointId());
assertNull(result.getLastSuccessfulSyncPointId());
assertNull(result.getRawKoboSyncToken());
}
@Test
void testFromRequestHeaders_withHeader() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("X-Kobo-Synctoken", "BOOKLORE.test");
BookloreSyncToken expectedToken = new BookloreSyncToken();
when(objectMapper.readValue(any(byte[].class), eq(BookloreSyncToken.class))).thenReturn(expectedToken);
BookloreSyncToken result = generator.fromRequestHeaders(request);
assertNotNull(result);
assertEquals(expectedToken, result);
}
@Test
void testFromRequestHeaders_noHeader() {
HttpServletRequest request = new MockHttpServletRequest();
BookloreSyncToken result = generator.fromRequestHeaders(request);
assertNull(result);
}
@Test
void testFromRequestHeaders_nullRequest() {
BookloreSyncToken result = generator.fromRequestHeaders(null);
assertNull(result);
}
}

View File

@@ -0,0 +1,129 @@
package com.adityachandel.booklore.util.kobo;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import static org.junit.jupiter.api.Assertions.*;
class KoboUrlBuilderTest {
private KoboUrlBuilder koboUrlBuilder;
private MockHttpServletRequest mockRequest;
@BeforeEach
void setUp() {
// Mock the request attributes
mockRequest = new MockHttpServletRequest();
mockRequest.setScheme("http");
mockRequest.setServerName("localhost");
mockRequest.setServerPort(8080);
mockRequest.setContextPath("");
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(mockRequest));
// Manually instantiate KoboUrlBuilder
koboUrlBuilder = new KoboUrlBuilder();
// Set the @Value field using ReflectionTestUtils
ReflectionTestUtils.setField(koboUrlBuilder, "serverPort", 8080);
}
@AfterEach
void tearDown() {
RequestContextHolder.resetRequestAttributes();
}
@Test
void testDownloadUrl() {
String token = "testToken";
Long bookId = 123L;
String result = koboUrlBuilder.downloadUrl(token, bookId);
assertNotNull(result);
assertTrue(result.contains("/api/kobo/" + token + "/v1/books/" + bookId + "/download"),
"URL should contain the expected path segments");
assertTrue(result.startsWith("http://"), "URL should start with http://");
}
@Test
void testImageUrlTemplate() {
String token = "testToken";
String result = koboUrlBuilder.imageUrlTemplate(token);
assertNotNull(result);
assertTrue(result.contains("/api/kobo/" + token + "/v1/books/"),
"URL should contain the expected path segments");
assertTrue(result.contains("{ImageId}"), "URL should contain ImageId placeholder");
assertTrue(result.contains("{Width}"), "URL should contain Width placeholder");
assertTrue(result.contains("{Height}"), "URL should contain Height placeholder");
assertTrue(result.contains("image.jpg"), "URL should end with image.jpg");
}
@Test
void testImageUrlQualityTemplate() {
String token = "testToken";
String result = koboUrlBuilder.imageUrlQualityTemplate(token);
assertNotNull(result);
assertTrue(result.contains("/api/kobo/" + token + "/v1/books/"),
"URL should contain the expected path segments");
assertTrue(result.contains("{ImageId}"), "URL should contain ImageId placeholder");
assertTrue(result.contains("{Width}"), "URL should contain Width placeholder");
assertTrue(result.contains("{Height}"), "URL should contain Height placeholder");
assertTrue(result.contains("{Quality}"), "URL should contain Quality placeholder");
assertTrue(result.contains("{IsGreyscale}"), "URL should contain IsGreyscale placeholder");
assertTrue(result.contains("image.jpg"), "URL should end with image.jpg");
}
@Test
void testDownloadUrlWithXForwardedPort() {
// Set X-Forwarded-Port header
mockRequest.addHeader("X-Forwarded-Port", "443");
String token = "testToken";
Long bookId = 123L;
String result = koboUrlBuilder.downloadUrl(token, bookId);
assertNotNull(result);
assertTrue(result.contains("/api/kobo/" + token + "/v1/books/" + bookId + "/download"),
"URL should contain the expected path segments");
}
@Test
void testDownloadUrlWithInvalidXForwardedPort() {
// Set invalid X-Forwarded-Port header
mockRequest.addHeader("X-Forwarded-Port", "invalid");
String token = "testToken";
Long bookId = 123L;
String result = koboUrlBuilder.downloadUrl(token, bookId);
assertNotNull(result);
assertTrue(result.contains("/api/kobo/" + token + "/v1/books/" + bookId + "/download"),
"URL should contain the expected path segments even with invalid port header");
}
@Test
void testUrlBuilderWithIpAddress() {
// Test with IP address instead of localhost
mockRequest.setServerName("192.168.1.100");
mockRequest.addHeader("X-Forwarded-Port", "8443");
String token = "testToken";
Long bookId = 123L;
String result = koboUrlBuilder.downloadUrl(token, bookId);
assertNotNull(result);
assertTrue(result.contains("/api/kobo/" + token + "/v1/books/" + bookId + "/download"),
"URL should contain the expected path segments");
}
}

View File

@@ -11,8 +11,6 @@ import {AppConfigService} from './shared/service/app-config.service';
import {MetadataBatchProgressNotification} from './shared/model/metadata-batch-progress.model';
import {MetadataProgressService} from './shared/service/metadata-progress-service';
import {BookdropFileNotification, BookdropFileService} from './features/bookdrop/service/bookdrop-file.service';
import {DuplicateFileNotification} from './shared/websocket/model/duplicate-file-notification.model';
import {DuplicateFileService} from './shared/websocket/duplicate-file.service';
import {Subscription} from 'rxjs';
import {DownloadProgressDialogComponent} from './shared/components/download-progress-dialog/download-progress-dialog.component';
import {TaskService, TaskProgressPayload} from './features/settings/task-management/task.service';
@@ -35,7 +33,6 @@ export class AppComponent implements OnInit, OnDestroy {
private notificationEventService = inject(NotificationEventService);
private metadataProgressService = inject(MetadataProgressService);
private bookdropFileService = inject(BookdropFileService);
private duplicateFileService = inject(DuplicateFileService);
private taskService = inject(TaskService);
private appConfigService = inject(AppConfigService); // Keep it here to ensure the service is initialized
@@ -86,11 +83,6 @@ export class AppComponent implements OnInit, OnDestroy {
this.notificationEventService.handleNewNotification(logNotification);
})
);
this.subscriptions.push(
this.rxStompService.watch('/user/queue/duplicate-file').subscribe(msg =>
this.duplicateFileService.addDuplicateFile(JSON.parse(msg.body) as DuplicateFileNotification)
)
);
this.subscriptions.push(
this.rxStompService.watch('/user/queue/bookdrop-file').subscribe(msg => {
const notification = JSON.parse(msg.body) as BookdropFileNotification;

View File

@@ -7,6 +7,7 @@ import {BulkMetadataUpdateComponent} from '../../../metadata/component/bulk-meta
import {MultiBookMetadataEditorComponent} from '../../../metadata/component/multi-book-metadata-editor/multi-book-metadata-editor-component';
import {MultiBookMetadataFetchComponent} from '../../../metadata/component/multi-book-metadata-fetch/multi-book-metadata-fetch-component';
import {FileMoverComponent} from '../../../../shared/components/file-mover/file-mover-component';
import {ShelfCreatorComponent} from '../shelf-creator/shelf-creator.component';
@Injectable({providedIn: 'root'})
export class BookDialogHelperService {
@@ -15,7 +16,7 @@ export class BookDialogHelperService {
openShelfAssigner(bookIds: Set<number>): DynamicDialogRef | null {
return this.dialogService.open(ShelfAssignerComponent, {
header: `Update Books' Shelves`,
showHeader: false,
modal: true,
closable: true,
contentStyle: {overflow: 'auto'},
@@ -31,6 +32,22 @@ export class BookDialogHelperService {
});
}
openShelfCreator(): DynamicDialogRef {
return this.dialogService.open(ShelfCreatorComponent, {
showHeader: false,
modal: true,
draggable: false,
dismissableMask: true,
closable: true,
contentStyle: {overflow: 'auto'},
baseZIndex: 10,
style: {
position: 'absolute',
top: '15%',
},
})!;
}
openLockUnlockMetadataDialog(bookIds: Set<number>): DynamicDialogRef | null {
const count = bookIds.size;
return this.dialogService.open(LockUnlockMetadataDialogComponent, {

View File

@@ -276,10 +276,17 @@
@if (selectedBooks.size > 0) {
@if (userService.userState$ | async; as userState) {
<div class="book-browser-footer bg-[var(--card-background)] bg-opacity-10" [@slideInOut]>
<div class="flex justify-between items-center">
<div class="flex">
<div class="flex items-center">
<div class="flex items-center pl-2 w-1/4">
<div class="selected-count-badge text-zinc-200">
<i class="pi pi-check-circle mr-2"></i>
<span class="font-bold text-base" style="color: var(--primary-color)">{{ selectedBooks.size }}</span>
<span class="ml-1">selected</span>
</div>
</div>
<div class="flex justify-center w-2/4">
@if (entityType$ | async; as entityType) {
<div class="flex gap-2 md:gap-6 pr-2">
<div class="flex gap-2 md:gap-6">
@if (userState.user!.permissions.canEditMetadata) {
<p-menu #menu [model]="metadataMenuItems" [popup]="true" appendTo="body" class="hidden"/>
<p-button
@@ -291,23 +298,21 @@
icon="pi pi-database">
</p-button>
}
@if (entityType === EntityType.LIBRARY || entityType === EntityType.ALL_BOOKS || entityType === EntityType.UNSHELVED) {
<p-button
icon="pi pi-bookmark-fill"
outlined="true"
severity="info"
(onClick)="openShelfAssigner()"
pTooltip="Assign to shelf"
tooltipPosition="top">
</p-button>
}
<p-button
icon="pi pi-bookmark-fill"
outlined="true"
severity="info"
(onClick)="openShelfAssigner()"
pTooltip="Assign to shelf"
tooltipPosition="top">
</p-button>
@if (entityType === EntityType.SHELF) {
<p-button
icon="pi pi-bookmark"
outlined="true"
severity="info"
(click)="unshelfBooks()"
pTooltip="Remove from shelf"
pTooltip="Remove from this shelf"
tooltipPosition="top">
</p-button>
}
@@ -343,7 +348,7 @@
</div>
}
<p-divider layout="vertical"></p-divider>
<div class="flex gap-2 md:gap-6 px-2">
<div class="flex gap-2 md:gap-6">
<p-button
outlined="true"
icon="pi pi-check-square"
@@ -361,20 +366,19 @@
tooltipPosition="top">
</p-button>
</div>
@if (userState?.user?.permissions?.admin || userState?.user?.permissions?.canDeleteBook) {
<p-divider layout="vertical"></p-divider>
<p-button
outlined="true"
icon="pi pi-trash"
severity="danger"
(click)="confirmDeleteBooks()"
pTooltip="Delete selected books"
tooltipPosition="top">
</p-button>
}
</div>
@if (userState?.user?.permissions?.admin || userState?.user?.permissions?.canDeleteBook) {
<p-divider layout="vertical"></p-divider>
<p-button
outlined="true"
icon="pi pi-trash"
severity="danger"
(click)="confirmDeleteBooks()"
pTooltip="Delete selected books"
class="pl-2"
tooltipPosition="top">
</p-button>
}
<div class="w-1/4"></div>
</div>
</div>
}

View File

@@ -46,12 +46,27 @@
left: 10%;
right: 10%;
display: flex;
justify-content: center;
align-items: center;
padding: 1rem 0.5rem 0.5rem;
z-index: 1;
border-radius: 10px 10px 0 0;
border: 1px solid rgba(255, 255, 255, 0.5);
border-width: 1px 1px 0px 1px;
> .flex {
width: 100%;
}
}
.selected-count-badge {
display: inline-flex;
align-items: center;
padding: 0.5rem 1rem;
font-size: 0.9rem;
i {
font-size: 1rem;
}
}
@media (max-width: 768px) {
@@ -59,6 +74,15 @@
left: 0%;
right: 0%;
}
.selected-count-badge {
padding: 0.4rem 0.8rem;
font-size: 0.85rem;
i {
font-size: 0.9rem;
}
}
}
.topbar-item {

View File

@@ -554,9 +554,6 @@ export class BookBrowserComponent implements OnInit {
openShelfAssigner(): void {
this.dynamicDialogRef = this.dialogHelperService.openShelfAssigner(this.selectedBooks);
this.dynamicDialogRef?.onClose.subscribe(() => {
this.selectedBooks = new Set<number>();
});
}
lockUnlockMetadata(): void {

View File

@@ -47,8 +47,6 @@ export class BookTableComponent implements OnInit, OnDestroy, OnChanges {
private bookService = inject(BookService);
private messageService = inject(MessageService);
private userService = inject(UserService);
private dialogService = inject(DialogService);
private router = inject(Router);
private datePipe = inject(DatePipe);
private readStatusHelper = inject(ReadStatusHelper);
@@ -68,6 +66,7 @@ export class BookTableComponent implements OnInit, OnDestroy, OnChanges {
{field: 'addedOn', header: 'Added'},
{field: 'fileSizeKb', header: 'File Size'},
{field: 'language', header: 'Language'},
{field: 'isbn', header: 'ISBN'},
{field: 'pageCount', header: 'Pages'},
{field: 'amazonRating', header: 'Amazon'},
{field: 'amazonReviewCount', header: 'AZ #'},
@@ -140,22 +139,6 @@ export class BookTableComponent implements OnInit, OnDestroy, OnChanges {
this.selectedBooksChange.emit(this.selectedBookIds);
}
openMetadataCenter(id: number): void {
if (this.metadataCenterViewMode === 'route') {
this.router.navigate(['/book', id], {
queryParams: {tab: 'view'}
});
} else {
this.dialogService.open(BookMetadataCenterComponent, {
width: '95%',
data: {bookId: id},
modal: true,
dismissableMask: true,
showHeader: false
});
}
}
getStarColor(rating: number): string {
if (rating >= 4.5) {
return 'rgb(34, 197, 94)';
@@ -210,20 +193,17 @@ export class BookTableComponent implements OnInit, OnDestroy, OnChanges {
return this.readStatusHelper.shouldShowStatusIcon(readStatus);
}
getAuthors(metadata: BookMetadata): string[] {
return metadata.authors ?? []
}
getCellClickableValue(metadata: BookMetadata, book: Book, field: string){
const filterKeys:Record<string, string> = {
getCellClickableValue(metadata: BookMetadata, book: Book, field: string) {
const filterKeys: Record<string, string> = {
'authors': 'author',
'publisher': 'publisher',
'categories': 'category',
'language': 'language',
'title': 'title'
'title': 'title',
'isbn': 'isbn'
} as const;
let data:string[] =[metadata[field]];
let data: string[] = [metadata[field]];
switch (field) {
case 'title':
@@ -245,10 +225,17 @@ export class BookTableComponent implements OnInit, OnDestroy, OnChanges {
case 'seriesName':
return [
{
url: this.urlHelper.filterBooksBy('series', metadata.seriesName ?? '' ),
url: this.urlHelper.filterBooksBy('series', metadata.seriesName ?? ''),
anchor: metadata.seriesName
}
]
case 'isbn':
return [
{
url: '',
anchor: this.getCellValue(metadata, book, 'isbn')
}
];
}
return data.map(item => {
@@ -312,6 +299,9 @@ export class BookTableComponent implements OnInit, OnDestroy, OnChanges {
case 'hardcoverReviewCount':
return metadata[field] ?? '';
case 'isbn':
return metadata.isbn13 || metadata.isbn10 || '';
default:
return '';
}

View File

@@ -30,7 +30,7 @@ export class LockUnlockMetadataDialogComponent implements OnInit {
lockableFields: string[] = [
'titleLocked', 'subtitleLocked', 'publisherLocked', 'publishedDateLocked', 'descriptionLocked',
'isbn13Locked', 'isbn10Locked', 'asinLocked', 'pageCountLocked', 'thumbnailLocked', 'languageLocked', 'coverLocked',
'seriesNameLocked', 'seriesNumberLocked', 'seriesTotalLocked', 'authorsLocked', 'categoriesLocked', 'moodsLocked', 'TagsLocked',
'seriesNameLocked', 'seriesNumberLocked', 'seriesTotalLocked', 'authorsLocked', 'categoriesLocked', 'moodsLocked', 'tagsLocked',
'amazonRatingLocked', 'amazonReviewCountLocked', 'goodreadsRatingLocked', 'goodreadsReviewCountLocked',
'hardcoverRatingLocked', 'hardcoverReviewCountLocked', 'goodreadsIdLocked', 'hardcoverIdLocked', 'googleIdLocked', 'comicvineIdLocked'
];

View File

@@ -8,6 +8,7 @@ export class BookSorter {
{ label: 'Title + Series', field: 'titleSeries', direction: SortDirection.ASCENDING },
{ label: 'File Name', field: 'fileName', direction: SortDirection.ASCENDING },
{ label: 'Author', field: 'author', direction: SortDirection.ASCENDING },
{ label: 'Author + Series', field: 'authorSeries', direction: SortDirection.ASCENDING },
{ label: 'Last Read', field: 'lastReadTime', direction: SortDirection.ASCENDING },
{ label: 'Added On', field: 'addedOn', direction: SortDirection.ASCENDING },
{ label: 'File Size', field: 'fileSizeKb', direction: SortDirection.ASCENDING },

View File

@@ -26,13 +26,14 @@ export class TableColumnPreferenceService {
{field: 'addedOn', header: 'Added'},
{field: 'fileSizeKb', header: 'File Size'},
{field: 'language', header: 'Language'},
{field: 'isbn', header: 'ISBN'},
{field: 'pageCount', header: 'Pages'},
{field: 'amazonRating', header: 'Amazon'},
{field: 'amazonReviewCount', header: 'AZ #'},
{field: 'goodreadsRating', header: 'Goodreads'},
{field: 'goodreadsReviewCount', header: 'GR #'},
{field: 'hardcoverRating', header: 'Hardcover'},
{field: 'hardcoverReviewCount', header: 'HC #'}
{field: 'hardcoverReviewCount', header: 'HC #'},
];
private readonly fallbackPreferences: TableColumnPreference[] = this.allAvailableColumns.map((col, index) => ({

View File

@@ -1,47 +1,89 @@
<div class="p-4 min-w-[26rem] min-h-[30rem] flex flex-col">
<div class="flex flex-col flex-grow">
@for (shelf of (shelfState$ | async)?.shelves; track shelf) {
<div>
<div class="flex flex-row items-center gap-4 py-2">
<p-checkbox [inputId]="shelf.name" name="group" [value]="shelf" [(ngModel)]="selectedShelves"></p-checkbox>
<label [for]="shelf.id">{{ shelf.name }}</label>
</div>
</div>
}
<div class="flex justify-between items-center pt-4 mt-auto">
<p-button outlined="true" label="Create Shelf" (onClick)="createShelfDialog()"></p-button>
<div class="flex gap-4">
<p-button severity="secondary" label="Cancel" (onClick)="closeDialog()"></p-button>
<p-button label="Save" (onClick)="updateBooksShelves()"></p-button>
</div>
<div class="shelf-assigner">
<div class="panel-header">
<div class="header-icon-wrapper">
<i class="pi pi-bookmark header-icon"></i>
</div>
<div class="header-text">
<h2 class="panel-title">Assign Books to Shelves</h2>
<p class="panel-description">
@if (isMultiBooks) {
Select shelves for {{ bookIds.size }} {{ bookIds.size === 1 ? 'book' : 'books' }}
} @else {
Organize "{{ book.metadata?.title }}" into your shelves
}
</p>
</div>
</div>
<p-dialog [style]="{ width: '26rem', height: '30rem' }" [(visible)]="displayShelfDialog" header="Create Shelf" [modal]="true" [draggable]="false" [closable]="true" (onHide)="closeShelfDialog()">
<div class="py-2">
<label for="shelfName">Shelf Name</label>
<input id="shelfName" type="text" pInputText [(ngModel)]="shelfName" class="w-full"/>
</div>
<div class="py-4">
<label>Shelf Icon</label>
@if (!selectedIcon) {
<div>
<p-button label="Select Icon" icon="pi pi-search" (onClick)="openIconPicker()"></p-button>
<div class="shelves-container">
@if ((shelfState$ | async)?.shelves && (shelfState$ | async)!.shelves!.length > 0) {
<div class="shelves-list">
<div class="list-header">
<span class="shelf-count">
<i class="pi pi-bookmark"></i>
{{ (shelfState$ | async)!.shelves!.length }} {{ (shelfState$ | async)!.shelves!.length === 1 ? 'shelf' : 'shelves' }} available
</span>
</div>
}
@if (selectedIcon) {
<div class="flex items-center gap-4">
<i [class]="selectedIcon"></i>
<p-button icon="pi pi-times" text="true" outlined="true" severity="warn" (onClick)="clearSelectedIcon()"></p-button>
<div class="shelves-grid">
@for (shelf of (shelfState$ | async)?.shelves; track shelf.id) {
<div class="shelf-item" [class.selected]="isShelfSelected(shelf)">
<div class="shelf-checkbox">
<p-checkbox
[inputId]="'shelf-' + shelf.id"
name="shelves"
[value]="shelf"
[(ngModel)]="selectedShelves"
/>
</div>
<label [for]="'shelf-' + shelf.id" class="shelf-label">
<div class="shelf-icon-wrapper">
@if (shelf.icon) {
<i [class]="'pi pi-' + shelf.icon + ' shelf-icon'"></i>
} @else {
<i class="pi pi-bookmark shelf-icon"></i>
}
</div>
<span class="shelf-name">{{ shelf.name }}</span>
</label>
</div>
}
</div>
}
</div>
} @else {
<div class="empty-state">
<div class="empty-icon-wrapper">
<i class="pi pi-bookmark empty-icon"></i>
</div>
<h3 class="empty-title">No Shelves Available</h3>
<p class="empty-description">
Create your first shelf to start organizing your books.<br/>
Click the button below to get started.
</p>
</div>
}
</div>
<div class="dialog-footer">
<p-button
label="Create Shelf"
icon="pi pi-plus"
[outlined]="true"
(onClick)="createShelfDialog()"
/>
<div class="footer-actions">
<p-button
label="Cancel"
severity="secondary"
[outlined]="true"
(onClick)="closeDialog()"
/>
<p-button
label="Save Changes"
icon="pi pi-check"
severity="success"
(onClick)="updateBooksShelves()"
/>
</div>
<ng-template pTemplate="footer">
<p-button label="Cancel" severity="secondary" (onClick)="closeShelfDialog()" class="p-button-text"></p-button>
<p-button label="Save" (onClick)="saveNewShelf()"></p-button>
</ng-template>
</p-dialog>
</div>
</div>

View File

@@ -0,0 +1,358 @@
.shelf-assigner {
width: 550px;
max-width: 550px;
display: flex;
flex-direction: column;
padding-top: 20px;
@media (max-width: 640px) {
width: 100%;
max-width: 100%;
}
}
.panel-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.25rem;
background: linear-gradient(135deg, rgba(var(--primary-color-rgb), 0.1) 0%, rgba(var(--primary-color-rgb), 0.05) 100%);
border-radius: 10px 10px 0 0;
border: 1px solid var(--border-color);
border-bottom: none;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
.header-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background: var(--primary-color);
border-radius: 10px;
box-shadow: 0 3px 8px rgba(var(--primary-color-rgb), 0.3);
.header-icon {
font-size: 1.5rem;
color: var(--primary-contrast-color);
}
}
.header-text {
flex: 1;
.panel-title {
margin: 0 0 0.25rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color);
}
.panel-description {
margin: 0;
font-size: 0.875rem;
color: var(--text-secondary-color);
}
}
@media (max-width: 480px) {
padding: 1rem;
.header-icon-wrapper {
width: 40px;
height: 40px;
.header-icon {
font-size: 1.25rem;
}
}
.header-text {
.panel-title {
font-size: 1.125rem;
}
.panel-description {
font-size: 0.8rem;
}
}
}
}
.shelves-container {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.5rem;
background: var(--card-background);
border: 1px solid var(--border-color);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
min-height: 350px;
max-height: 450px;
@media (max-width: 480px) {
padding: 1rem;
min-height: 300px;
max-height: 400px;
}
}
.shelves-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
height: 100%;
}
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 0.875rem;
background: var(--overlay-background);
border-radius: 6px;
.shelf-count {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-color);
i {
color: var(--primary-color);
}
}
@media (max-width: 480px) {
padding: 0.5rem 0.625rem;
.shelf-count {
font-size: 0.85rem;
}
}
}
.shelves-grid {
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow-y: auto;
flex: 1;
padding-right: 0.25rem;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: var(--overlay-background);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
transition: background 0.2s ease;
&:hover {
background: var(--primary-color);
}
}
scrollbar-width: thin;
scrollbar-color: var(--border-color) var(--overlay-background);
}
.shelf-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: var(--ground-background);
border: 1px solid var(--border-color);
border-radius: 8px;
transition: all 0.3s ease;
cursor: pointer;
&:hover {
border-color: var(--primary-color);
transform: translateX(3px);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1);
.shelf-icon {
color: var(--primary-color);
transform: scale(1.08);
}
}
&.selected {
background: rgba(var(--primary-color-rgb), 0.08);
border-color: var(--primary-color);
.shelf-icon-wrapper {
background: var(--primary-color);
.shelf-icon {
color: var(--primary-contrast-color);
}
}
}
.shelf-checkbox {
display: flex;
align-items: center;
}
.shelf-label {
flex: 1;
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
min-width: 0;
.shelf-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
background: var(--overlay-background);
border-radius: 8px;
flex-shrink: 0;
.shelf-icon {
font-size: 1.125rem;
color: var(--text-secondary-color);
transition: all 0.3s ease;
}
}
.shelf-name {
font-size: 0.9375rem;
color: var(--text-color);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@media (max-width: 480px) {
padding: 0.625rem;
gap: 0.5rem;
.shelf-label {
gap: 0.5rem;
.shelf-icon-wrapper {
width: 32px;
height: 32px;
.shelf-icon {
font-size: 1rem;
}
}
.shelf-name {
font-size: 0.875rem;
}
}
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem 1rem;
flex: 1;
.empty-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
background: var(--overlay-background);
border-radius: 50%;
margin-bottom: 1rem;
.empty-icon {
font-size: 2.5rem;
color: var(--text-secondary-color);
opacity: 0.5;
}
}
.empty-title {
margin: 0 0 0.5rem 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-color);
}
.empty-description {
margin: 0;
font-size: 0.875rem;
color: var(--text-secondary-color);
max-width: 350px;
line-height: 1.5;
}
@media (max-width: 480px) {
padding: 1.5rem 0.75rem;
.empty-icon-wrapper {
width: 60px;
height: 60px;
.empty-icon {
font-size: 2rem;
}
}
.empty-title {
font-size: 1rem;
}
.empty-description {
font-size: 0.8rem;
max-width: 100%;
}
}
}
.dialog-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
padding: 1.25rem 1.5rem;
background: var(--card-background);
border: 1px solid var(--border-color);
border-top: none;
border-radius: 0 0 10px 10px;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
.footer-actions {
display: flex;
gap: 0.75rem;
}
@media (max-width: 480px) {
flex-direction: column;
padding: 1rem;
gap: 0.5rem;
.footer-actions {
width: 100%;
justify-content: flex-end;
gap: 0.5rem;
}
}
}

View File

@@ -1,7 +1,7 @@
import {Component, inject, OnInit} from '@angular/core';
import {DialogService, DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog';
import {DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog';
import {Book} from '../../model/book.model';
import {MessageService, PrimeTemplate} from 'primeng/api';
import {MessageService} from 'primeng/api';
import {ShelfService} from '../../service/shelf.service';
import {Observable} from 'rxjs';
import {BookService} from '../../service/book.service';
@@ -12,24 +12,19 @@ import {Button} from 'primeng/button';
import {AsyncPipe} from '@angular/common';
import {Checkbox} from 'primeng/checkbox';
import {FormsModule} from '@angular/forms';
import {Dialog} from 'primeng/dialog';
import {InputText} from 'primeng/inputtext';
import {IconPickerService} from '../../../../shared/service/icon-picker.service';
import {BookDialogHelperService} from '../book-browser/BookDialogHelperService';
@Component({
selector: 'app-shelf-assigner',
standalone: true,
templateUrl: './shelf-assigner.component.html',
styleUrl: './shelf-assigner.component.scss',
imports: [
Button,
Checkbox,
AsyncPipe,
FormsModule,
Dialog,
InputText,
PrimeTemplate
],
styleUrls: ['./shelf-assigner.component.scss']
FormsModule
]
})
export class ShelfAssignerComponent implements OnInit {
@@ -38,16 +33,13 @@ export class ShelfAssignerComponent implements OnInit {
private dynamicDialogRef = inject(DynamicDialogRef);
private messageService = inject(MessageService);
private bookService = inject(BookService);
private iconPickerService = inject(IconPickerService);
private bookDialogHelper = inject(BookDialogHelperService);
shelfState$: Observable<ShelfState> = this.shelfService.shelfState$;
book: Book = this.dynamicDialogConfig.data.book;
selectedShelves: Shelf[] = [];
displayShelfDialog: boolean = false;
shelfName: string = '';
bookIds: Set<number> = this.dynamicDialogConfig.data.bookIds;
isMultiBooks: boolean = this.dynamicDialogConfig.data.isMultiBooks;
selectedIcon: string | null = null;
ngOnInit(): void {
if (!this.isMultiBooks && this.book.shelves) {
@@ -62,23 +54,6 @@ export class ShelfAssignerComponent implements OnInit {
}
}
saveNewShelf(): void {
const newShelf: Partial<Shelf> = {
name: this.shelfName,
icon: this.selectedIcon ? this.selectedIcon.replace('pi pi-', '') : 'heart'
};
this.shelfService.createShelf(newShelf as Shelf).subscribe({
next: () => {
this.messageService.add({severity: 'info', summary: 'Success', detail: `Shelf created: ${this.shelfName}`});
this.displayShelfDialog = false;
},
error: (e) => {
this.messageService.add({severity: 'error', summary: 'Error', detail: 'Failed to create shelf'});
console.error('Error creating shelf:', e);
}
});
}
updateBooksShelves(): void {
const idsToAssign = new Set<number | undefined>(this.selectedShelves.map(shelf => shelf.id));
const idsToUnassign: Set<number> = this.isMultiBooks ? new Set() : this.getIdsToUnAssign(this.book, idsToAssign);
@@ -110,26 +85,20 @@ export class ShelfAssignerComponent implements OnInit {
}
createShelfDialog(): void {
this.displayShelfDialog = true;
}
const dialogRef = this.bookDialogHelper.openShelfCreator();
closeShelfDialog(): void {
this.displayShelfDialog = false;
dialogRef.onClose.subscribe((created: boolean) => {
if (created) {
this.shelfService.reloadShelves();
}
});
}
closeDialog(): void {
this.dynamicDialogRef.close();
}
openIconPicker() {
this.iconPickerService.open().subscribe(icon => {
if (icon) {
this.selectedIcon = icon;
}
})
}
clearSelectedIcon() {
this.selectedIcon = null;
isShelfSelected(shelf: Shelf): boolean {
return this.selectedShelves.some(s => s.id === shelf.id);
}
}

View File

@@ -0,0 +1,105 @@
<div class="shelf-creator">
<div class="panel-header">
<div class="header-icon-wrapper">
<i class="pi pi-bookmark header-icon"></i>
</div>
<div class="header-text">
<h2 class="panel-title">Create New Shelf</h2>
<p class="panel-description">Add a custom shelf to organize your books</p>
</div>
</div>
<div class="form-container">
<div class="form-group highlight-group">
<label for="shelfName" class="form-label">
<i class="pi pi-tag label-icon"></i>
Shelf Name
<span class="required-indicator">*</span>
</label>
<div class="input-wrapper">
<input
id="shelfName"
type="text"
pInputText
[(ngModel)]="shelfName"
class="input-full"
placeholder="e.g., Favorites, To Read, Currently Reading"
[class.filled]="shelfName.trim()"
autofocus
/>
@if (shelfName.trim()) {
<i class="pi pi-check-circle input-icon success"></i>
}
</div>
</div>
<div class="divider"></div>
<div class="form-group highlight-group">
<label class="form-label">
<i class="pi pi-palette label-icon"></i>
Shelf Icon (Optional)
</label>
@if (!selectedIcon) {
<button class="icon-select-btn" (click)="openIconPicker()" type="button">
<i class="pi pi-plus"></i>
<div class="btn-content">
<span class="btn-title">Choose an Icon</span>
<span class="btn-subtitle">Select from available icons</span>
</div>
</button>
} @else {
<div class="selected-icon-display">
<div class="icon-preview">
<i [class]="selectedIcon"></i>
</div>
<div class="icon-info">
<span class="icon-label">Selected Icon</span>
<span class="icon-name">{{ selectedIcon }}</span>
</div>
<p-button
icon="pi pi-times"
severity="danger"
[outlined]="true"
[rounded]="true"
size="small"
(onClick)="clearSelectedIcon()"
pTooltip="Remove icon"
tooltipPosition="left"
/>
</div>
}
</div>
</div>
<div class="dialog-footer">
<div class="validation-status">
@if (!shelfName.trim()) {
<div class="validation-message error">
<i class="pi pi-exclamation-circle"></i>
<span>Shelf name is required</span>
</div>
} @else {
<div class="validation-message success">
<i class="pi pi-check-circle"></i>
<span>Ready to create</span>
</div>
}
</div>
<div class="footer-actions">
<p-button
label="Cancel"
severity="secondary"
[outlined]="true"
(onClick)="cancel()"
/>
<p-button
label="Create Shelf"
icon="pi pi-plus"
severity="success"
(onClick)="saveNewShelf()"
[disabled]="!shelfName.trim()"
/>
</div>
</div>
</div>

View File

@@ -0,0 +1,384 @@
.shelf-creator {
display: flex;
flex-direction: column;
min-width: 600px;
padding-top: 20px;
@media (max-width: 640px) {
min-width: auto;
width: 100%;
}
}
.panel-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.25rem;
background: linear-gradient(135deg, rgba(var(--primary-color-rgb), 0.1) 0%, rgba(var(--primary-color-rgb), 0.05) 100%);
border-radius: 10px 10px 0 0;
border: 1px solid var(--border-color);
.header-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background: var(--primary-color);
border-radius: 10px;
box-shadow: 0 3px 8px rgba(var(--primary-color-rgb), 0.3);
.header-icon {
font-size: 1.5rem;
color: var(--primary-contrast-color);
}
}
.header-text {
flex: 1;
.panel-title {
margin: 0 0 0.25rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color);
}
.panel-description {
margin: 0;
font-size: 0.875rem;
color: var(--text-secondary-color);
}
}
@media (max-width: 480px) {
padding: 1rem;
.header-icon-wrapper {
width: 40px;
height: 40px;
.header-icon {
font-size: 1.25rem;
}
}
.header-text {
.panel-title {
font-size: 1.125rem;
}
.panel-description {
font-size: 0.8rem;
}
}
}
}
.form-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1.5rem;
background: var(--card-background);
border: 1px solid var(--border-color);
border-top: none;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
@media (max-width: 480px) {
padding: 1rem;
gap: 1rem;
}
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
&.highlight-group {
padding: 0.875rem;
}
@media (max-width: 480px) {
gap: 0.375rem;
&.highlight-group {
padding: 0.625rem;
}
}
}
.form-label {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-color);
.label-icon {
color: var(--primary-color);
}
.required-indicator {
color: var(--p-red-500);
margin-left: 0.125rem;
}
@media (max-width: 480px) {
font-size: 0.875rem;
}
}
.input-wrapper {
position: relative;
.input-full {
width: 100%;
padding-right: 2.5rem;
box-sizing: border-box;
}
.input-icon {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
font-size: 1rem;
pointer-events: none;
&.success {
color: var(--p-green-500);
}
}
}
.icon-select-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
width: 100%;
padding: 1rem;
background: var(--ground-background);
border: 1px dashed var(--border-color);
border-radius: 8px;
color: var(--text-color);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: var(--overlay-background);
border-color: var(--primary-color);
color: var(--primary-color);
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(var(--primary-color-rgb), 0.2);
}
i {
font-size: 1.25rem;
}
.btn-content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.125rem;
.btn-title {
font-size: 0.9375rem;
font-weight: 600;
}
.btn-subtitle {
font-size: 0.8rem;
opacity: 0.85;
}
}
@media (max-width: 480px) {
padding: 0.75rem;
gap: 0.5rem;
i {
font-size: 1.125rem;
}
.btn-content {
.btn-title {
font-size: 0.875rem;
}
.btn-subtitle {
font-size: 0.75rem;
}
}
}
}
.selected-icon-display {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--ground-background);
.icon-preview {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
background: var(--primary-color);
border-radius: 8px;
box-shadow: 0 3px 8px rgba(var(--primary-color-rgb), 0.3);
i {
font-size: 1.25rem;
color: var(--primary-contrast-color);
}
}
.icon-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.125rem;
.icon-label {
font-size: 0.8rem;
color: var(--text-secondary-color);
}
.icon-name {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-color);
font-family: monospace;
}
}
@media (max-width: 480px) {
padding: 0.625rem;
gap: 0.5rem;
.icon-preview {
width: 36px;
height: 36px;
i {
font-size: 1.125rem;
}
}
.icon-info {
.icon-label {
font-size: 0.75rem;
}
.icon-name {
font-size: 0.8rem;
}
}
}
}
.divider {
height: 1px;
background: linear-gradient(90deg, transparent, var(--border-color), transparent);
margin: 0.25rem 0;
}
.dialog-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1.25rem 1.5rem;
background: var(--card-background);
border: 1px solid var(--border-color);
border-top: none;
border-radius: 0 0 10px 10px;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
.validation-status {
flex: 1;
min-width: 0;
}
.footer-actions {
display: flex;
gap: 0.75rem;
flex-shrink: 0;
}
@media (max-width: 640px) {
flex-direction: column;
align-items: stretch;
padding: 1rem;
gap: 0.75rem;
.validation-status {
width: 100%;
}
.footer-actions {
width: 100%;
justify-content: flex-end;
gap: 0.5rem;
}
}
}
.validation-message {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
font-weight: 500;
border-radius: 6px;
font-size: 0.875rem;
width: fit-content;
max-width: 100%;
i {
font-size: 0.95rem;
}
&.error {
background: rgba(239, 68, 68, 0.1);
color: rgb(239, 68, 68);
border: 1px solid rgba(239, 68, 68, 0.3);
i {
color: rgb(239, 68, 68);
}
}
&.success {
background: rgba(34, 197, 94, 0.1);
color: rgb(34, 197, 94);
border: 1px solid rgba(34, 197, 94, 0.3);
i {
color: rgb(34, 197, 94);
}
}
@media (max-width: 640px) {
font-size: 0.8rem;
padding: 0.375rem 0.5rem;
width: 100%;
i {
font-size: 0.875rem;
}
}
}

View File

@@ -0,0 +1,65 @@
import {Component, inject} from '@angular/core';
import {DynamicDialogRef} from 'primeng/dynamicdialog';
import {MessageService} from 'primeng/api';
import {ShelfService} from '../../service/shelf.service';
import {IconPickerService} from '../../../../shared/service/icon-picker.service';
import {Shelf} from '../../model/shelf.model';
import {FormsModule} from '@angular/forms';
import {Button} from 'primeng/button';
import {InputText} from 'primeng/inputtext';
import {Tooltip} from 'primeng/tooltip';
@Component({
selector: 'app-shelf-creator',
standalone: true,
templateUrl: './shelf-creator.component.html',
imports: [
FormsModule,
Button,
InputText,
Tooltip
],
styleUrl: './shelf-creator.component.scss',
})
export class ShelfCreatorComponent {
private shelfService = inject(ShelfService);
private dynamicDialogRef = inject(DynamicDialogRef);
private messageService = inject(MessageService);
private iconPickerService = inject(IconPickerService);
shelfName: string = '';
selectedIcon: string | null = null;
saveNewShelf(): void {
const newShelf: Partial<Shelf> = {
name: this.shelfName,
icon: this.selectedIcon ? this.selectedIcon.replace('pi pi-', '') : 'heart'
};
this.shelfService.createShelf(newShelf as Shelf).subscribe({
next: () => {
this.messageService.add({severity: 'info', summary: 'Success', detail: `Shelf created: ${this.shelfName}`});
this.dynamicDialogRef.close(true);
},
error: (e) => {
this.messageService.add({severity: 'error', summary: 'Error', detail: 'Failed to create shelf'});
console.error('Error creating shelf:', e);
}
});
}
openIconPicker(): void {
this.iconPickerService.open().subscribe(icon => {
if (icon) {
this.selectedIcon = icon;
}
});
}
clearSelectedIcon(): void {
this.selectedIcon = null;
}
cancel(): void {
this.dynamicDialogRef.close();
}
}

View File

@@ -56,39 +56,6 @@ export class LibraryShelfMenuService {
});
}
},
{
label: 'Delete Library',
icon: 'pi pi-trash',
command: () => {
this.confirmationService.confirm({
message: `Are you sure you want to delete library: ${entity?.name}?`,
header: 'Confirmation',
rejectButtonProps: {
label: 'Cancel',
severity: 'secondary',
},
acceptButtonProps: {
label: 'Yes',
severity: 'success',
},
accept: () => {
this.libraryService.deleteLibrary(entity?.id!).subscribe({
complete: () => {
this.router.navigate(['/']);
this.messageService.add({severity: 'info', summary: 'Success', detail: 'Library was deleted'});
},
error: () => {
this.messageService.add({
severity: 'error',
summary: 'Failed',
detail: 'Failed to delete library',
});
}
});
}
});
}
},
{
label: 'Re-scan Library',
icon: 'pi pi-refresh',
@@ -145,6 +112,42 @@ export class LibraryShelfMenuService {
libraryId: entity?.id ?? undefined
}).subscribe();
}
},
{
separator: true
},
{
label: 'Delete Library',
icon: 'pi pi-trash',
command: () => {
this.confirmationService.confirm({
message: `Are you sure you want to delete library: ${entity?.name}?`,
header: 'Confirmation',
rejectButtonProps: {
label: 'Cancel',
severity: 'secondary',
},
acceptButtonProps: {
label: 'Yes',
severity: 'danger',
},
accept: () => {
this.libraryService.deleteLibrary(entity?.id!).subscribe({
complete: () => {
this.router.navigate(['/']);
this.messageService.add({severity: 'info', summary: 'Success', detail: 'Library was deleted'});
},
error: () => {
this.messageService.add({
severity: 'error',
summary: 'Failed',
detail: 'Failed to delete library',
});
}
});
}
});
}
}
]
}
@@ -174,6 +177,9 @@ export class LibraryShelfMenuService {
})
}
},
{
separator: true
},
{
label: 'Delete Shelf',
icon: 'pi pi-trash',
@@ -181,6 +187,9 @@ export class LibraryShelfMenuService {
this.confirmationService.confirm({
message: `Are you sure you want to delete shelf: ${entity?.name}?`,
header: 'Confirmation',
acceptButtonProps: {
severity: 'danger'
},
accept: () => {
this.shelfService.deleteShelf(entity?.id!).subscribe({
complete: () => {
@@ -228,6 +237,9 @@ export class LibraryShelfMenuService {
})
}
},
{
separator: true
},
{
label: 'Delete Magic Shelf',
icon: 'pi pi-trash',
@@ -236,6 +248,9 @@ export class LibraryShelfMenuService {
this.confirmationService.confirm({
message: `Are you sure you want to delete magic shelf: ${entity?.name}?`,
header: 'Confirmation',
acceptButtonProps: {
severity: 'danger'
},
accept: () => {
this.magicShelfService.deleteShelf(entity?.id!).subscribe({
complete: () => {

View File

@@ -7,7 +7,48 @@ import {SortDirection, SortOption} from "../model/sort.model";
})
export class SortService {
private readonly fieldExtractors: Record<string, (book: Book) => any> = {
private naturalCompare(a: string, b: string): number {
if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;
const aStr = a.toString();
const bStr = b.toString();
const chunkRegex = /(\d+|\D+)/g;
const aChunks = aStr.match(chunkRegex) || [aStr];
const bChunks = bStr.match(chunkRegex) || [bStr];
const maxLength = Math.max(aChunks.length, bChunks.length);
for (let i = 0; i < maxLength; i++) {
const aChunk = aChunks[i] || '';
const bChunk = bChunks[i] || '';
if (aChunk === '' && bChunk === '') continue;
const aIsNumeric = /^\d+$/.test(aChunk);
const bIsNumeric = /^\d+$/.test(bChunk);
if (aIsNumeric && bIsNumeric) {
const aNum = parseInt(aChunk, 10);
const bNum = parseInt(bChunk, 10);
if (aNum !== bNum) {
return aNum - bNum;
}
} else {
const comparison = aChunk.localeCompare(bChunk);
if (comparison !== 0) {
return comparison;
}
}
}
return aChunks.length - bChunks.length;
}
private readonly fieldExtractors: Record<string, (book: Book) => unknown> = {
title: (book) => (book.seriesCount ? (book.metadata?.seriesName?.toLowerCase() || null) : null)
?? (book.metadata?.title?.toLowerCase() || null),
titleSeries: (book) => {
@@ -20,7 +61,21 @@ export class SortService {
return [title, Number.MAX_SAFE_INTEGER];
},
author: (book) => book.metadata?.authors?.map(a => a.toLowerCase()).join(", ") || null,
publishedDate: (book) => book.metadata?.publishedDate === null ? null : new Date(book.metadata?.publishedDate!).getTime(),
authorSeries: (book) => {
const author = book.metadata?.authors?.map(a => a.toLowerCase()).join(", ") || null;
const series = book.metadata?.seriesName?.toLowerCase() || null;
const seriesNumber = book.metadata?.seriesNumber ?? Number.MAX_SAFE_INTEGER;
const title = book.metadata?.title?.toLowerCase() || '';
if (series) {
return [author, series, seriesNumber, title];
}
// For books without a series, use a very large string for series name to sort them last within an author.
return [author, '~~~~~~~~~~~~~~~~~', Number.MAX_SAFE_INTEGER, title];
},
publishedDate: (book) => {
const date = book.metadata?.publishedDate;
return date === null || date === undefined ? null : new Date(date).getTime();
},
publisher: (book) => book.metadata?.publisher || null,
pageCount: (book) => book.metadata?.pageCount || null,
rating: (book) => book.metadata?.rating || null,
@@ -39,7 +94,7 @@ export class SortService {
lastReadTime: (book) => book.lastReadTime ? new Date(book.lastReadTime).getTime() : null,
addedOn: (book) => book.addedOn ? new Date(book.addedOn).getTime() : null,
fileSizeKb: (book) => book.fileSizeKb || null,
fileName:(book) => book.fileName,
fileName: (book) => book.fileName,
};
applySort(books: Book[], selectedSort: SortOption | null): Book[] {
@@ -57,13 +112,33 @@ export class SortService {
const aValue = extractor(a);
const bValue = extractor(b);
let result: number;
let result = 0;
if (Array.isArray(aValue) && Array.isArray(bValue)) {
const nameCompare = aValue[0]?.localeCompare?.(bValue[0]) ?? 0;
result = nameCompare !== 0 ? nameCompare : (aValue[1] - bValue[1]);
for (let i = 0; i < aValue.length; i++) {
const valA = aValue[i];
const valB = bValue[i];
if (typeof valA === 'string' && typeof valB === 'string') {
result = this.naturalCompare(valA, valB);
if (result !== 0) break;
} else if (typeof valA === 'number' && typeof valB === 'number') {
result = valA - valB;
if (result !== 0) break;
} else {
if (valA == null && valB != null) {
result = 1;
break;
}
if (valA != null && valB == null) {
result = -1;
break;
}
result = 0;
}
}
} else if (typeof aValue === 'string' && typeof bValue === 'string') {
result = aValue.localeCompare(bValue);
result = this.naturalCompare(aValue, bValue);
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
result = aValue - bValue;
} else {

View File

@@ -20,7 +20,7 @@ import {BookRuleEvaluatorService} from '../../../magic-shelf/service/book-rule-e
import {GroupRule} from '../../../magic-shelf/component/magic-shelf-component';
import {DialogLauncherService} from '../../../../shared/services/dialog-launcher.service';
import {SortService} from '../../../book/service/sort.service';
import { PageTitleService } from "../../../../shared/service/page-title.service";
import {PageTitleService} from "../../../../shared/service/page-title.service";
import {SortDirection, SortOption} from '../../../book/model/sort.model';
const DEFAULT_MAX_ITEMS = 20;
@@ -106,7 +106,20 @@ export class MainDashboardComponent implements OnInit {
private getRandomBooks(maxItems: number, sortBy?: string): Observable<Book[]> {
return this.bookService.bookState$.pipe(
map((state: BookState) => {
return this.shuffleBooks(state.books || [], maxItems);
const excludedStatuses = new Set<ReadStatus>([
ReadStatus.READ,
ReadStatus.PARTIALLY_READ,
ReadStatus.READING,
ReadStatus.PAUSED,
ReadStatus.WONT_READ,
ReadStatus.ABANDONED
]);
const candidates = (state.books || []).filter(book =>
!book.readStatus || !excludedStatuses.has(book.readStatus)
);
return this.shuffleBooks(candidates, maxItems);
})
);
}

View File

@@ -46,7 +46,7 @@ export class BookRuleEvaluatorService {
case 'tags':
return (book.metadata?.tags ?? []).map(t => t.toLowerCase());
case 'readStatus':
return [String(book.readStatus).toLowerCase()];
return [String(book.readStatus ?? 'UNSET').toLowerCase()];
case 'fileType':
return [String(this.getFileExtension(book.fileName) ?? '').toLowerCase()];
case 'library':
@@ -193,7 +193,7 @@ export class BookRuleEvaluatorService {
case 'library':
return book.libraryId;
case 'readStatus':
return book.readStatus;
return book.readStatus ?? 'UNSET';
case 'fileType':
return this.getFileExtension(book.fileName)?.toLowerCase() ?? null;
case 'fileSize':

View File

@@ -208,7 +208,11 @@ export class MetadataEditorComponent implements OnInit {
this.originalMetadata = structuredClone(metadata);
this.populateFormFromMetadata(metadata);
});
this.prepareAutoComplete();
}
private prepareAutoComplete(): void {
this.bookService.bookState$
.pipe(
filter((bookState) => bookState.loaded),
@@ -388,6 +392,7 @@ export class MetadataEditorComponent implements OnInit {
summary: "Success",
detail: "Book metadata updated",
});
this.prepareAutoComplete();
},
error: (err) => {
this.isSaving = false;

View File

@@ -15,7 +15,10 @@
<p-drawer [(visible)]="isDrawerVisible" [modal]="true" [position]="'left'" header="Chapters">
<ul class="chapter-list">
@for (chapter of chapters; track chapter) {
<li (click)="navigateToChapter(chapter); $event.stopPropagation()" class="chapter-item">
<li (click)="navigateToChapter(chapter); $event.stopPropagation()"
class="chapter-item"
[class.current-chapter]="chapter.href === currentChapterHref"
[style.padding-left.rem]="chapter.level * 1.5 + 0.75">
{{ chapter.label }}
</li>
}

View File

@@ -140,16 +140,27 @@
}
.chapter-item {
padding: 0.5px 0.5px;
padding: 0;
font-size: 14px;
color: var(--text-color);
cursor: pointer;
transition: background-color 0.3s ease, color 0.3s ease;
border-radius: 5px;
transition: background-color 0.3s ease, color 0.3s ease, box-shadow 0.3s ease;
border-radius: 0.35rem;
background-color: transparent;
display: block;
&:hover {
color: var(--primary-color);
}
}
.chapter-item:hover {
.current-chapter {
color: var(--primary-color);
font-weight: bold;
background-color: color-mix(in srgb, var(--primary-color) 15%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary-color) 35%, transparent);
padding: 0.35rem 0.75rem;
border-radius: 0.45rem;
}
.location-indicator {

View File

@@ -31,8 +31,9 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
@ViewChild('epubContainer', {static: false}) epubContainer!: ElementRef;
isLoading = true;
chapters: { label: string; href: string }[] = [];
chapters: { label: string; href: string; level: number }[] = [];
currentChapter = '';
currentChapterHref: string | null = null;
isDrawerVisible = false;
isSettingsDrawerVisible = false;
@@ -104,10 +105,7 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
this.book = ePub(fileReader.result as ArrayBuffer);
this.book.loaded.navigation.then((nav: any) => {
this.chapters = nav.toc.map((chapter: any) => ({
label: chapter.label,
href: chapter.href,
}));
this.chapters = this.extractChapters(nav.toc, 0);
});
const settingScope = myself.userSettings.perBookSetting.epub;
@@ -163,6 +161,7 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
: this.rendition.display();
displayPromise.then(() => {
this.updateCurrentChapter(this.rendition.currentLocation());
this.setupKeyListener();
this.trackProgress();
this.setupTouchListener();
@@ -345,7 +344,7 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
}
}
navigateToChapter(chapter: { label: string; href: string }): void {
navigateToChapter(chapter: { label: string; href: string; level: number }): void {
if (this.book && chapter.href) {
this.book.rendition.display(chapter.href);
}
@@ -362,6 +361,7 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
private trackProgress(): void {
if (!this.book || !this.rendition) return;
this.rendition.on('relocated', (location: any) => {
this.updateCurrentChapter(location);
const cfi = location.end.cfi;
const currentIndex = location.start.index;
const totalSpineItems = this.book.spine.items.length;
@@ -381,7 +381,6 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
this.progressPercentage = Math.round(percentage * 1000) / 10;
}
this.currentChapter = getChapter(this.book, location)?.label;
this.bookService.saveEpubProgress(this.epub.id, cfi, Math.round(percentage * 1000) / 10).subscribe();
});
@@ -444,4 +443,33 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
}, 3000);
}
}
private updateCurrentChapter(location: any): void {
if (!location) return;
const chapter = getChapter(this.book, location);
if (chapter) {
if (chapter.label) {
this.currentChapter = chapter.label;
}
this.currentChapterHref = chapter.href;
}
}
private extractChapters(toc: any[], level: number): { label: string; href: string; level: number }[] {
const chapters: { label: string; href: string; level: number }[] = [];
for (const item of toc) {
chapters.push({
label: item.label,
href: item.href,
level: level
});
if (item.subitems && item.subitems.length > 0) {
chapters.push(...this.extractChapters(item.subitems, level + 1));
}
}
return chapters;
}
}

View File

@@ -181,49 +181,49 @@
<div class="example-category">
<h4 class="subsection-title">Examples with Full Metadata</h4>
<div class="metadata-sample">
<span>title: <code>Harry Potter and the Sorcerer's Stone</code></span>
<span>subtitle: <code>The Boy Who Lived</code></span>
<span>authors: <code>J.K. Rowling</code></span>
<span>series: <code>Harry Potter</code></span>
<span>title: <code>The Name of the Wind</code></span>
<span>subtitle: <code>Special Edition</code></span>
<span>authors: <code>Patrick Rothfuss</code></span>
<span>series: <code>The Kingkiller Chronicle</code></span>
<span>seriesIndex: <code>01</code></span>
<span>year: <code>1997</code></span>
<span>currentFilename: <code>harry1_original.epub</code></span>
<span>year: <code>2007</code></span>
<span>currentFilename: <code>name_of_the_wind_original.epub</code></span>
</div>
<div class="example-list">
<div class="example-item">
<p class="example-pattern"><strong>Basic pattern:</strong> <code>{{ '{authors} - {title}' }}</code></p>
<p class="example-output"><strong>Output:</strong> <code>J.K. Rowling - Harry Potter and the Sorcerer's Stone.epub</code></p>
<p class="example-output"><strong>Output:</strong> <code>Patrick Rothfuss - The Name of the Wind.epub</code></p>
</div>
<div class="example-item">
<p class="example-pattern"><strong>Pattern with punctuation:</strong> <code>{{ '{title}: {series}' }}</code></p>
<p class="example-output"><strong>Output:</strong> <code>Harry Potter and the Sorcerer's Stone: Harry Potter.epub</code></p>
<p class="example-output"><strong>Output:</strong> <code>The Name of the Wind: The Kingkiller Chronicle.epub</code></p>
</div>
<div class="example-item">
<p class="example-pattern"><strong>Series in folder path:</strong> <code>{{ '{authors}/{series}/{seriesIndex} - {title}' }}</code></p>
<p class="example-output"><strong>Output:</strong> <code>J.K. Rowling/Harry Potter/01 - Harry Potter and the Sorcerer's Stone.epub</code></p>
<p class="example-output"><strong>Output:</strong> <code>Patrick Rothfuss/The Kingkiller Chronicle/01 - The Name of the Wind.epub</code></p>
</div>
<div class="example-item">
<p class="example-pattern"><strong>Folder only:</strong> <code>{{ '{title}/' }}</code></p>
<p class="example-output"><strong>Output:</strong> <code>/Harry Potter and the Sorcerer's Stone/harry1_original.epub</code></p>
<p class="example-output"><strong>Output:</strong> <code>/The Name of the Wind/name_of_the_wind_original.epub</code></p>
</div>
<div class="example-item">
<p class="example-pattern"><strong>Absolute path:</strong> <code>{{ '/{authors}/{title}' }}</code></p>
<p class="example-output"><strong>Output:</strong> <code>/J.K. Rowling/Harry Potter and the Sorcerer's Stone.epub</code></p>
<p class="example-output"><strong>Output:</strong> <code>/Patrick Rothfuss/The Name of the Wind.epub</code></p>
</div>
<div class="example-item">
<p class="example-pattern"><strong>Reuse original filename in path:</strong> <code>{{ '{authors}/{series}/{currentFilename}' }}</code></p>
<p class="example-output"><strong>Output:</strong> <code>J.K. Rowling/Harry Potter/harry1_original.epub</code></p>
<p class="example-output"><strong>Output:</strong> <code>Patrick Rothfuss/The Kingkiller Chronicle/name_of_the_wind_original.epub</code></p>
</div>
<div class="example-item">
<p class="example-pattern"><strong>Title + Subtitle:</strong> <code>{{ '{title}: {subtitle}' }}</code></p>
<p class="example-output"><strong>Output:</strong> <code>Harry Potter and the Sorcerer's Stone: The Boy Who Lived.epub</code></p>
<p class="example-output"><strong>Output:</strong> <code>The Name of the Wind: The Kingkiller Chronicle: Day One.epub</code></p>
</div>
</div>
</div>

View File

@@ -38,7 +38,7 @@
.settings-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
font-size: 0.9rem;
line-height: 1.5;
margin-bottom: 1rem;
}
@@ -75,7 +75,7 @@
.section-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
font-size: 0.9rem;
line-height: 1.5;
margin: 0;
}
@@ -103,7 +103,7 @@
label {
font-weight: 600;
color: var(--p-text-color);
font-size: 0.875rem;
font-size: 0.9rem;
}
}
@@ -120,7 +120,7 @@
.error-message {
color: var(--p-red-500);
font-size: 0.875rem;
font-size: 0.9rem;
margin-top: 0.25rem;
}
@@ -138,7 +138,7 @@
.preview-label {
color: var(--p-text-muted-color);
font-size: 0.875rem;
font-size: 0.9rem;
}
.preview-value {
@@ -227,7 +227,7 @@
.placeholders-description {
color: var(--p-text-muted-color);
font-size: 0.875rem;
font-size: 0.9rem;
line-height: 1.5;
code {
@@ -263,7 +263,7 @@
li {
color: var(--p-text-color);
font-size: 0.875rem;
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
}
@@ -280,7 +280,7 @@
.optional-blocks-info {
p {
color: var(--p-text-color);
font-size: 0.875rem;
font-size: 0.9rem;
line-height: 1.5;
margin-bottom: 1rem;
}
@@ -307,7 +307,7 @@
li {
color: var(--p-text-color);
font-size: 0.875rem;
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
}
@@ -342,7 +342,7 @@
}
span {
font-size: 0.875rem;
font-size: 0.9rem;
color: var(--p-text-muted-color);
code {
@@ -376,7 +376,7 @@
}
.example-pattern {
font-size: 0.875rem;
font-size: 0.9rem;
margin-bottom: 0.5rem;
strong {
@@ -392,7 +392,7 @@
}
.example-output {
font-size: 0.875rem;
font-size: 0.9rem;
margin: 0;
strong {

View File

@@ -21,15 +21,15 @@ import {ExternalDocLinkComponent} from '../../../shared/components/external-doc-
})
export class FileNamingPatternComponent implements OnInit {
readonly exampleMetadata: Record<string, string> = {
title: "Harry Potter and the Sorcerer's Stone",
subtitle: 'The Boy Who Lived',
authors: 'J.K. Rowling',
year: '1997',
series: 'Harry Potter',
seriesIndex: '01',
language: 'en',
publisher: 'Bloomsbury',
isbn: '9780747532699',
title: "The Name of the Wind",
subtitle: "Special Edition",
authors: "Patrick Rothfuss",
year: "2007",
series: "The Kingkiller Chronicle",
seriesIndex: "01",
language: "English",
publisher: "DAW Books",
isbn: "9780756404741",
};
defaultPattern = '';
@@ -95,7 +95,7 @@ export class FileNamingPatternComponent implements OnInit {
}
validatePattern(pattern: string): boolean {
const validPatternRegex = /^[\w\s\-{}\/().<>.,:'"]*$/;
const validPatternRegex = /^[\w\s\-{}\[\]\/().<>.,:'"]*$/;
return validPatternRegex.test(pattern);
}

View File

@@ -1,84 +0,0 @@
<div class="dialog-container">
<div class="info-message">
<i class="pi pi-exclamation-triangle"></i>
<p>{{ totalRecords }} duplicate file groups have been detected in your library. To maintain data integrity and ensure optimal performance, please review and remove these duplicate entries from your file system.</p>
</div>
<div class="table-container">
<p-table
#dataTable
[value]="(duplicateFiles$ | async) || []"
[scrollable]="true"
scrollHeight="100%"
[paginator]="false"
[rows]="15"
[rowHover]="true">
<ng-template pTemplate="body" let-group>
<tr>
<td>
<div class="hash-group">
<div class="hash-header">
<i class="pi pi-copy"></i>
<span class="hash-label">Hash:</span>
<span class="hash-value">{{ group.hash }}</span>
<span class="file-count">({{ group.files.length }} files)</span>
</div>
<div class="files-list">
@for (file of (group.files || []); track file.fullPath) {
<div class="file-item-compact">
<div class="file-icon">
<i class="pi pi-file"></i>
</div>
<div class="file-details">
<div class="filename">{{ file.fileName }}</div>
<div class="file-path" [title]="file.fullPath">{{ file.fullPath }}</div>
<div class="library-info">
<i class="pi pi-database"></i>
<span>{{ file.libraryName }}</span>
<span class="timestamp">{{ file.timestamp | date:'short' }}</span>
</div>
</div>
</div>
}
</div>
</div>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td class="empty-message">
<div class="empty-content">
<i class="pi pi-info-circle"></i>
<span>No duplicate files detected</span>
</div>
</td>
</tr>
</ng-template>
</p-table>
</div>
<div class="bottom-fixed-area">
<div class="paginator-container">
<p-paginator
[first]="first"
[rows]="rows"
[totalRecords]="totalRecords"
[showCurrentPageReport]="true"
currentPageReportTemplate="Showing {first} to {last} of {totalRecords} duplicate file groups"
(onPageChange)="onPageChange($event)">
</p-paginator>
</div>
<div class="dialog-actions">
<p-button
label="Acknowledge"
icon="pi pi-check"
(click)="acknowledgeAndClose()">
</p-button>
</div>
</div>
</div>

View File

@@ -1,235 +0,0 @@
.dialog-container {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.table-container {
flex: 1;
overflow: hidden;
margin-bottom: 100px;
::ng-deep .p-datatable {
height: 100%;
.p-datatable-wrapper {
height: 100%;
}
.p-datatable-tbody > tr > td {
border: none !important;
padding: 0.5rem 0.5rem;
}
.p-datatable-tbody > tr {
border: none !important;
}
}
}
.bottom-fixed-area {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--surface-ground);
z-index: 1000;
display: flex;
flex-direction: column;
}
.paginator-container {
::ng-deep .p-paginator {
border: none;
border-bottom: 1px solid var(--border-color);
}
}
.dialog-actions {
display: flex;
justify-content: flex-end;
padding: 1rem;
}
.duplicate-files-content {
overflow: hidden;
}
.hash-group {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--p-content-border-color);
border-radius: 8px;
padding: 1rem;
margin: 0.5rem 0;
}
.hash-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
i {
color: var(--primary-color);
font-size: 0.9rem;
}
.hash-label {
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
font-size: 0.9rem;
}
.hash-value {
font-family: 'Courier New', monospace;
color: rgba(255, 255, 255, 0.7);
font-size: 1rem;
font-weight: 900;
background: rgba(255, 255, 255, 0.05);
padding: 0.25rem 0.5rem;
border-radius: 4px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-count {
background: var(--primary-color);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 500;
}
}
.files-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.file-item-compact {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--p-content-border-color);
transition: all 0.2s ease;
margin-left: 1rem;
}
.file-icon {
background: var(--primary-color);
color: white;
width: 32px;
height: 32px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
i {
font-size: 0.9rem;
}
}
.file-details {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.filename {
font-family: 'Courier New', monospace;
font-weight: 600;
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.9);
word-break: break-word;
line-height: 1.2;
}
.file-path {
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.7);
font-family: 'Courier New', monospace;
word-break: break-all;
line-height: 1.2;
max-height: 2.4em;
overflow: hidden;
}
.library-info {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.8);
margin-top: 0.1rem;
i {
color: var(--primary-color);
font-size: 0.85rem;
}
.timestamp {
margin-left: auto;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.6);
}
}
.empty-message {
text-align: center !important;
padding: 3rem 1rem !important;
.empty-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
opacity: 0.6;
i {
font-size: 1.5rem;
}
span {
font-size: 0.875rem;
}
}
}
.info-message {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
margin-bottom: 1rem;
background: rgba(234, 88, 12, 0.1);
border: 1px solid rgba(234, 88, 12, 0.3);
border-radius: 0.375rem;
color: #fbbf24;
i {
color: #f59e0b;
font-size: 1.125rem;
margin-top: 0.125rem;
flex-shrink: 0;
}
p {
margin: 0;
line-height: 1.5;
font-size: 1rem;
}
}

View File

@@ -1,86 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DynamicDialogRef, DynamicDialogConfig } from 'primeng/dynamicdialog';
import { TableModule } from 'primeng/table';
import { ButtonModule } from 'primeng/button';
import { TooltipModule } from 'primeng/tooltip';
import { PaginatorModule } from 'primeng/paginator';
import { Observable, map } from 'rxjs';
import {DuplicateFileNotification} from '../../../websocket/model/duplicate-file-notification.model';
import {DuplicateFileService} from '../../../websocket/duplicate-file.service';
@Component({
selector: 'app-duplicate-files-dialog',
standalone: true,
imports: [CommonModule, TableModule, ButtonModule, TooltipModule, PaginatorModule],
templateUrl: './duplicate-files-dialog.component.html',
styleUrls: ['./duplicate-files-dialog.component.scss']
})
export class DuplicateFilesDialogComponent implements OnInit {
duplicateFiles$: Observable<{ hash: string; files: DuplicateFileNotification[] }[]>;
totalRecords = 0;
first = 0;
rows = 15;
constructor(
public ref: DynamicDialogRef,
public config: DynamicDialogConfig,
private duplicateFileService: DuplicateFileService
) {
this.duplicateFiles$ = this.config.data.duplicateFiles$.pipe(
map((files: DuplicateFileNotification[] | null) => {
if (!files) return [];
// Group files by hash
const groupedFiles = files.reduce((acc, file) => {
if (!acc[file.hash]) {
acc[file.hash] = [];
}
acc[file.hash].push(file);
return acc;
}, {} as { [hash: string]: DuplicateFileNotification[] });
const groups = Object.entries(groupedFiles).map(([hash, files]) => ({
hash,
files: files.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
}));
this.totalRecords = groups.length;
return groups.slice(this.first, this.first + this.rows);
})
);
}
ngOnInit() {}
onPageChange(event: any) {
this.first = event.first;
this.rows = event.rows;
this.duplicateFiles$ = this.config.data.duplicateFiles$.pipe(
map((files: DuplicateFileNotification[] | null) => {
if (!files) return [];
const groupedFiles = files.reduce((acc, file) => {
if (!acc[file.hash]) {
acc[file.hash] = [];
}
acc[file.hash].push(file);
return acc;
}, {} as { [hash: string]: DuplicateFileNotification[] });
const groups = Object.entries(groupedFiles).map(([hash, files]) => ({
hash,
files: files.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
}));
this.totalRecords = groups.length;
return groups.slice(this.first, this.first + this.rows);
})
);
}
acknowledgeAndClose() {
this.duplicateFileService.clearDuplicateFiles();
this.ref.close();
}
}

View File

@@ -1,22 +0,0 @@
@if (duplicateFilesCount$ | async; as count) {
@if (count > 0) {
<div class="flex flex-col p-4 space-y-2 live-border">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<i class="pi pi-exclamation-triangle text-yellow-500"></i>
<span class="font-normal text-zinc-200">{{ count }} duplicate files detected</span>
</div>
<p-tag [value]="count.toString()" severity="warn" [rounded]="true"></p-tag>
</div>
<p-button
label="View Details"
icon="pi pi-list"
outlined
size="small"
severity="info"
fluid
(click)="openDialog()">
</p-button>
</div>
}
}

View File

@@ -1,10 +0,0 @@
.duplicate-files-content {
max-height: 500px;
overflow: hidden;
}
.live-border {
background: var(--card-background);
border: 1px solid var(--primary-color);
border-radius: 0.5rem;
}

View File

@@ -1,80 +0,0 @@
import {Component, inject, OnDestroy} from '@angular/core';
import {CommonModule} from '@angular/common';
import {DialogModule} from 'primeng/dialog';
import {ButtonModule} from 'primeng/button';
import {TableModule} from 'primeng/table';
import {TagModule} from 'primeng/tag';
import {Observable} from 'rxjs';
import {map} from 'rxjs/operators';
import {DuplicateFileService} from '../../websocket/duplicate-file.service';
import {DuplicateFileNotification} from '../../websocket/model/duplicate-file-notification.model';
import {DialogService, DynamicDialogRef} from 'primeng/dynamicdialog';
import {DuplicateFilesDialogComponent} from './duplicate-files-dialog/duplicate-files-dialog.component';
@Component({
selector: 'app-duplicate-files-notification',
standalone: true,
imports: [CommonModule, DialogModule, ButtonModule, TableModule, TagModule],
templateUrl: './duplicate-files-notification.component.html',
styleUrls: ['./duplicate-files-notification.component.scss'],
providers: [DialogService]
})
export class DuplicateFilesNotificationComponent implements OnDestroy {
displayDialog = false;
private duplicateFileService = inject(DuplicateFileService);
private ref: DynamicDialogRef | undefined | null;
duplicateFiles$ = this.duplicateFileService.duplicateFiles$;
duplicateFilesCount$: Observable<number> = this.duplicateFiles$.pipe(
map(files => files?.length || 0)
);
constructor(
private dialogService: DialogService
) {
}
openDialog() {
this.ref = this.dialogService.open(DuplicateFilesDialogComponent, {
header: 'Duplicate Files Detected',
width: '80dvw',
height: '75dvh',
contentStyle: {overflow: 'hidden'},
maximizable: true,
modal: true,
data: {
duplicateFiles$: this.duplicateFiles$
}
});
this.ref?.onClose.subscribe((result: any) => {
if (result) {
// Handle any result from dialog if needed
}
});
}
closeDialog() {
this.displayDialog = false;
}
clearAllDuplicates() {
this.duplicateFileService.clearDuplicateFiles();
this.closeDialog();
}
removeDuplicate(file: DuplicateFileNotification) {
this.duplicateFileService.removeDuplicateFile(file.fullPath, file.libraryId);
}
formatTimestamp(timestamp: string): string {
return new Date(timestamp).toLocaleString();
}
ngOnDestroy() {
if (this.ref) {
this.ref.close();
}
}
}

View File

@@ -1,8 +1,5 @@
<div class="metadata-progress-box flex gap-4 flex-col w-[25rem] max-h-[60vh] overflow-y-auto">
<app-live-notification-box/>
@if (hasDuplicateFiles$ | async) {
<app-duplicate-files-notification/>
}
@if (hasMetadataTasks$ | async) {
<app-metadata-progress-widget/>
}

View File

@@ -4,8 +4,6 @@ import {MetadataProgressService} from '../../service/metadata-progress-service';
import {map} from 'rxjs/operators';
import {AsyncPipe} from '@angular/common';
import {BookdropFileService} from '../../../features/bookdrop/service/bookdrop-file.service';
import {DuplicateFilesNotificationComponent} from '../duplicate-files-notification/duplicate-files-notification.component';
import {DuplicateFileService} from '../../websocket/duplicate-file.service';
import {BookdropFilesWidgetComponent} from '../../../features/bookdrop/component/bookdrop-files-widget/bookdrop-files-widget.component';
import {MetadataProgressWidgetComponent} from '../metadata-progress-widget/metadata-progress-widget-component';
@@ -15,8 +13,7 @@ import {MetadataProgressWidgetComponent} from '../metadata-progress-widget/metad
LiveNotificationBoxComponent,
MetadataProgressWidgetComponent,
AsyncPipe,
BookdropFilesWidgetComponent,
DuplicateFilesNotificationComponent
BookdropFilesWidgetComponent
],
templateUrl: './unified-notification-popover-component.html',
standalone: true,
@@ -25,15 +22,10 @@ import {MetadataProgressWidgetComponent} from '../metadata-progress-widget/metad
export class UnifiedNotificationBoxComponent {
metadataProgressService = inject(MetadataProgressService);
bookdropFileService = inject(BookdropFileService);
duplicateFileService = inject(DuplicateFileService);
hasMetadataTasks$ = this.metadataProgressService.activeTasks$.pipe(
map(tasks => Object.keys(tasks).length > 0)
);
hasPendingBookdropFiles$ = this.bookdropFileService.hasPendingFiles$;
hasDuplicateFiles$ = this.duplicateFileService.duplicateFiles$.pipe(
map(files => files && files.length > 0)
);
}

View File

@@ -178,7 +178,7 @@ export class AppMenuComponent implements OnInit {
type: 'shelf',
label: 'Shelves',
hasDropDown: true,
hasCreate: false,
hasCreate: true,
items,
},
];

View File

@@ -10,6 +10,8 @@ import {Button} from 'primeng/button';
import {Menu} from 'primeng/menu';
import {UserService} from '../../../../features/settings/user-management/user.service';
import {DialogLauncherService} from '../../../services/dialog-launcher.service';
import {ShelfCreatorComponent} from '../../../../features/book/components/shelf-creator/shelf-creator.component';
import {BookDialogHelperService} from '../../../../features/book/components/book-browser/BookDialogHelperService';
@Component({
selector: '[app-menuitem]',
@@ -56,7 +58,8 @@ export class AppMenuitemComponent implements OnInit, OnDestroy {
public router: Router,
private menuService: MenuService,
private userService: UserService,
private dialogLauncher: DialogLauncherService
private dialogLauncher: DialogLauncherService,
private bookDialogHelperService: BookDialogHelperService
) {
this.userService.userState$.subscribe(userState => {
if (userState?.user) {
@@ -151,6 +154,9 @@ export class AppMenuitemComponent implements OnInit, OnDestroy {
if (item.type === 'magicShelf') {
this.dialogLauncher.openMagicShelfDialog();
}
if (item.type === 'shelf') {
this.bookDialogHelperService.openShelfCreator();
}
}
triggerLink() {

View File

@@ -22,7 +22,6 @@ import {Subject} from 'rxjs';
import {MetadataBatchProgressNotification} from '../../../model/metadata-batch-progress.model';
import {BookdropFileService} from '../../../../features/bookdrop/service/bookdrop-file.service';
import {DialogLauncherService} from '../../../services/dialog-launcher.service';
import {DuplicateFileService} from '../../../websocket/duplicate-file.service';
import {UnifiedNotificationBoxComponent} from '../../../components/unified-notification-popover/unified-notification-popover-component';
import {Severity, LogNotification} from '../../../websocket/model/log-notification.model';
@@ -63,14 +62,12 @@ export class AppTopBarComponent implements OnDestroy {
showPulse = false;
hasAnyTasks = false;
hasPendingBookdropFiles = false;
hasDuplicateFiles = false;
private eventTimer: any;
private destroy$ = new Subject<void>();
private latestTasks: { [taskId: string]: MetadataBatchProgressNotification } = {};
private latestHasPendingFiles = false;
private latestHasDuplicateFiles = false;
private latestNotificationSeverity?: Severity;
constructor(
@@ -82,12 +79,10 @@ export class AppTopBarComponent implements OnDestroy {
protected userService: UserService,
private metadataProgressService: MetadataProgressService,
private bookdropFileService: BookdropFileService,
private dialogLauncher: DialogLauncherService,
private duplicateFileService: DuplicateFileService
private dialogLauncher: DialogLauncherService
) {
this.subscribeToMetadataProgress();
this.subscribeToNotifications();
this.subscribeToDuplicateFiles();
this.metadataProgressService.activeTasks$
.pipe(takeUntil(this.destroy$))
@@ -106,15 +101,6 @@ export class AppTopBarComponent implements OnDestroy {
this.updateCompletedTaskCount();
this.updateTaskVisibilityWithBookdrop();
});
this.duplicateFileService.duplicateFiles$
.pipe(takeUntil(this.destroy$))
.subscribe((duplicateFiles) => {
this.latestHasDuplicateFiles = duplicateFiles && duplicateFiles.length > 0;
this.hasDuplicateFiles = this.latestHasDuplicateFiles;
this.updateCompletedTaskCount();
this.updateTaskVisibilityWithDuplicates();
});
}
ngOnDestroy(): void {
@@ -182,16 +168,6 @@ export class AppTopBarComponent implements OnDestroy {
});
}
private subscribeToDuplicateFiles() {
this.duplicateFileService.duplicateFiles$
.pipe(takeUntil(this.destroy$))
.subscribe((duplicateFiles) => {
if (duplicateFiles && duplicateFiles.length > 0) {
this.triggerPulseEffect();
}
});
}
private triggerPulseEffect() {
this.showPulse = true;
clearTimeout(this.eventTimer);
@@ -203,8 +179,7 @@ export class AppTopBarComponent implements OnDestroy {
private updateCompletedTaskCount() {
const completedMetadataTasks = Object.values(this.latestTasks).length;
const bookdropFileTaskCount = this.latestHasPendingFiles ? 1 : 0;
const duplicateFileTaskCount = this.latestHasDuplicateFiles ? 1 : 0;
this.completedTaskCount = completedMetadataTasks + bookdropFileTaskCount + duplicateFileTaskCount;
this.completedTaskCount = completedMetadataTasks + bookdropFileTaskCount;
}
private updateTaskVisibility(tasks: { [taskId: string]: MetadataBatchProgressNotification }) {
@@ -215,11 +190,6 @@ export class AppTopBarComponent implements OnDestroy {
private updateTaskVisibilityWithBookdrop() {
this.hasActiveOrCompletedTasks = this.hasActiveOrCompletedTasks || this.hasPendingBookdropFiles;
this.updateTaskVisibilityWithDuplicates();
}
private updateTaskVisibilityWithDuplicates() {
this.hasActiveOrCompletedTasks = this.hasActiveOrCompletedTasks || this.hasDuplicateFiles;
}
get iconClass(): string {
@@ -243,7 +213,7 @@ export class AppTopBarComponent implements OnDestroy {
return 'orange';
}
}
if (this.completedTaskCount > 0 || this.hasPendingBookdropFiles || this.hasDuplicateFiles)
if (this.completedTaskCount > 0 || this.hasPendingBookdropFiles)
return 'limegreen';
return 'inherit';
}
@@ -254,7 +224,7 @@ export class AppTopBarComponent implements OnDestroy {
get shouldShowNotificationBadge(): boolean {
return (
(this.completedTaskCount > 0 || this.hasPendingBookdropFiles || this.hasDuplicateFiles) &&
(this.completedTaskCount > 0 || this.hasPendingBookdropFiles) &&
!this.progressHighlight &&
!this.showPulse
);

Some files were not shown because too many files have changed in this diff Show More