mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 22:28:11 -05:00
Merge pull request #1633 from booklore-app/develop
Merge develop into master for the release
This commit is contained in:
50
.github/ISSUE_TEMPLATE/bug_report.md
vendored
50
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -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! -->
|
||||
|
||||
31
README.md
31
README.md
@@ -6,13 +6,6 @@ # BookLore
|
||||

|
||||
[](https://discord.gg/Ee5hd458Uz)
|
||||
[](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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -25,5 +25,6 @@ public class KoboSnapshotBookEntity {
|
||||
private Long bookId;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private boolean synced = false;
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,6 +29,7 @@ public class RefreshTokenEntity {
|
||||
private Instant expiryDate;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private boolean revoked = false;
|
||||
|
||||
@Column(name = "revocation_date")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,6 +2,5 @@ package com.adityachandel.booklore.model.enums;
|
||||
|
||||
public enum FileProcessStatus {
|
||||
NEW,
|
||||
DUPLICATE,
|
||||
UPDATED
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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+");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
];
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ export class AppMenuComponent implements OnInit {
|
||||
type: 'shelf',
|
||||
label: 'Shelves',
|
||||
hasDropDown: true,
|
||||
hasCreate: false,
|
||||
hasCreate: true,
|
||||
items,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user