From 41f68a6075fa6c3ae1bc129872550c98a7bfff5e Mon Sep 17 00:00:00 2001 From: Aditya Chandel <8075870+adityachandelgit@users.noreply.github.com> Date: Sat, 4 Oct 2025 19:02:25 -0600 Subject: [PATCH 01/11] Add support for moods and tags in book metadata (#1262) --- .../controller/BookMediaController.java | 12 -- .../controller/MetadataController.java | 16 -- .../booklore/mapper/BookMapper.java | 20 +- .../booklore/mapper/BookMetadataMapper.java | 4 +- .../mapper/MetadataClearFlagsMapper.java | 32 +-- .../booklore/mapper/MoodMapper.java | 23 +++ .../booklore/mapper/TagMapper.java | 23 +++ .../booklore/mapper/v2/BookMapperV2.java | 14 ++ .../booklore/model/MetadataClearFlags.java | 2 + .../booklore/model/dto/BookMetadata.java | 4 + .../request/BulkMetadataUpdateRequest.java | 6 + .../dto/request/MetadataRefreshOptions.java | 2 + .../model/entity/BookMetadataEntity.java | 29 ++- .../booklore/model/entity/CategoryEntity.java | 3 +- .../booklore/model/entity/MoodEntity.java | 40 ++++ .../booklore/model/entity/TagEntity.java | 39 ++++ .../repository/CategoryRepository.java | 4 - .../booklore/repository/MoodRepository.java | 14 ++ .../booklore/repository/TagRepository.java | 14 ++ .../appsettings/SettingPersistenceHelper.java | 10 +- .../service/metadata/BookMetadataService.java | 28 +-- .../service/metadata/BookMetadataUpdater.java | 77 ++++++- .../metadata/MetadataRefreshService.java | 48 +++++ .../AbstractMetadataBackupRestoreService.java | 72 ------- .../backuprestore/BookMetadataRestorer.java | 132 ------------ .../EpubMetadataBackupRestoreService.java | 109 ---------- .../backuprestore/MetadataBackupRestore.java | 21 -- .../MetadataBackupRestoreFactory.java | 29 --- .../PdfMetadataBackupRestoreService.java | 67 ------ .../metadata/parser/HardcoverParser.java | 41 +++- .../parser/hardcover/GraphQLRequest.java | 3 + .../parser/hardcover/GraphQLResponse.java | 138 +++++++++++-- .../hardcover/HardcoverBookSearchService.java | 18 +- .../booklore/util/MetadataChangeDetector.java | 6 + .../V53__Add_Mood_And_Tag_Tables.sql | 33 +++ .../metadata/MetadataRefreshServiceTest.java | 20 +- .../book-browser/filters/SidebarFilter.ts | 8 + booklore-ui/src/app/book/model/book.model.ts | 6 + .../metadata-editor.component.html | 57 ++++++ .../metadata-editor.component.ts | 127 ++++-------- .../metadata-picker.component.ts | 40 +++- .../metadata-viewer.component.html | 95 +++++---- .../metadata-viewer.component.ts | 85 ++++---- .../shared/components/tag/tag.component.scss | 190 ++++++++++++++++++ .../shared/components/tag/tag.component.ts | 44 ++++ 45 files changed, 1066 insertions(+), 739 deletions(-) create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/mapper/MoodMapper.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/mapper/TagMapper.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/entity/MoodEntity.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/entity/TagEntity.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/repository/MoodRepository.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/repository/TagRepository.java delete mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/AbstractMetadataBackupRestoreService.java delete mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/BookMetadataRestorer.java delete mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/EpubMetadataBackupRestoreService.java delete mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/MetadataBackupRestore.java delete mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/MetadataBackupRestoreFactory.java delete mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/PdfMetadataBackupRestoreService.java create mode 100644 booklore-api/src/main/resources/db/migration/V53__Add_Mood_And_Tag_Tables.sql create mode 100644 booklore-ui/src/app/shared/components/tag/tag.component.scss create mode 100644 booklore-ui/src/app/shared/components/tag/tag.component.ts diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java index 8c4f25e19..5d42ff6ee 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java @@ -40,18 +40,6 @@ public class BookMediaController { return ResponseEntity.ok(bookService.getBookCover(bookId)); } - @GetMapping("/book/{bookId}/backup-cover") - public ResponseEntity getBackupBookCover(@PathVariable long bookId) { - Resource file = bookMetadataService.getBackupCoverForBook(bookId); - if (file == null) { - return ResponseEntity.noContent().build(); - } - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=cover.jpg") - .contentType(MediaType.IMAGE_JPEG) - .body(file); - } - @GetMapping("/book/{bookId}/pdf/pages/{pageNumber}") public void getPdfPage(@PathVariable Long bookId, @PathVariable int pageNumber, HttpServletResponse response) throws IOException { response.setContentType(MediaType.IMAGE_JPEG_VALUE); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/MetadataController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/MetadataController.java index fec26bc50..136ee50eb 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/MetadataController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/MetadataController.java @@ -118,22 +118,6 @@ public class MetadataController { return ResponseEntity.noContent().build(); } - @GetMapping("/{bookId}/metadata/restore") - @PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()") - @CheckBookAccess(bookIdParam = "bookId") - public ResponseEntity getBackedUpMetadata(@PathVariable Long bookId) { - BookMetadata restoredMetadata = bookMetadataService.getBackedUpMetadata(bookId); - return ResponseEntity.ok(restoredMetadata); - } - - @PostMapping("/{bookId}/metadata/restore") - @PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()") - @CheckBookAccess(bookIdParam = "bookId") - public ResponseEntity restoreMetadata(@PathVariable Long bookId) throws IOException { - BookMetadata restoredMetadata = bookMetadataService.restoreMetadataFromBackup(bookId); - return ResponseEntity.ok(restoredMetadata); - } - @PostMapping("/{bookId}/metadata/covers") public ResponseEntity> getImages(@RequestBody CoverFetchRequest request) { return ResponseEntity.ok(duckDuckGoCoverService.getCovers(request)); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMapper.java index ecfc17cf5..31d682b6e 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMapper.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMapper.java @@ -3,11 +3,7 @@ package com.adityachandel.booklore.mapper; import com.adityachandel.booklore.model.dto.AdditionalFile; import com.adityachandel.booklore.model.dto.Book; import com.adityachandel.booklore.model.dto.LibraryPath; -import com.adityachandel.booklore.model.entity.AuthorEntity; -import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; -import com.adityachandel.booklore.model.entity.BookEntity; -import com.adityachandel.booklore.model.entity.CategoryEntity; -import com.adityachandel.booklore.model.entity.LibraryPathEntity; +import com.adityachandel.booklore.model.entity.*; import com.adityachandel.booklore.model.enums.AdditionalFileType; import org.mapstruct.Context; import org.mapstruct.Mapper; @@ -53,6 +49,20 @@ public interface BookMapper { .collect(Collectors.toSet()); } + default Set mapMoods(Set moods) { + if (moods == null) return null; + return moods.stream() + .map(MoodEntity::getName) + .collect(Collectors.toSet()); + } + + default Set mapTags(Set tags) { + if (tags == null) return null; + return tags.stream() + .map(TagEntity::getName) + .collect(Collectors.toSet()); + } + @Named("mapLibraryPathIdOnly") default LibraryPath mapLibraryPathIdOnly(LibraryPathEntity entity) { if (entity == null) return null; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMetadataMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMetadataMapper.java index 45fe4d43f..be5c5953b 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMetadataMapper.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMetadataMapper.java @@ -4,7 +4,7 @@ import com.adityachandel.booklore.model.dto.BookMetadata; import com.adityachandel.booklore.model.entity.BookMetadataEntity; import org.mapstruct.*; -@Mapper(componentModel = "spring", uses = {AuthorMapper.class, CategoryMapper.class}) +@Mapper(componentModel = "spring", uses = {AuthorMapper.class, CategoryMapper.class, MoodMapper.class, TagMapper.class}) public interface BookMetadataMapper { @AfterMapping @@ -24,6 +24,8 @@ public interface BookMetadataMapper { @Mapping(target = "description", ignore = true) @Mapping(target = "authors", ignore = true) @Mapping(target = "categories", ignore = true) + @Mapping(target = "moods", ignore = true) + @Mapping(target = "tags", ignore = true) BookMetadata toBookMetadataWithoutRelations(BookMetadataEntity bookMetadataEntity, @Context boolean includeDescription); } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/MetadataClearFlagsMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/MetadataClearFlagsMapper.java index 95544ec0d..6950dd142 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/MetadataClearFlagsMapper.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/MetadataClearFlagsMapper.java @@ -8,36 +8,6 @@ import org.mapstruct.Mapping; @Mapper(componentModel = "spring") public interface MetadataClearFlagsMapper { - /*@Mapping(target = "title", source = "clearTitle") - @Mapping(target = "subtitle", source = "clearSubtitle") - @Mapping(target = "publisher", source = "clearPublisher") - @Mapping(target = "publishedDate", source = "clearPublishedDate") - @Mapping(target = "description", source = "clearDescription") - @Mapping(target = "seriesName", source = "clearSeriesName") - @Mapping(target = "seriesNumber", source = "clearSeriesNumber") - @Mapping(target = "seriesTotal", source = "clearSeriesTotal") - @Mapping(target = "isbn13", source = "clearIsbn13") - @Mapping(target = "isbn10", source = "clearIsbn10") - @Mapping(target = "asin", source = "clearAsin") - @Mapping(target = "goodreadsId", source = "clearGoodreadsId") - @Mapping(target = "comicvineId", source = "clearComicvineId") - @Mapping(target = "hardcoverId", source = "clearHardcoverId") - @Mapping(target = "googleId", source = "clearGoogleId") - @Mapping(target = "pageCount", source = "clearPageCount") - @Mapping(target = "language", source = "clearLanguage") - @Mapping(target = "amazonRating", source = "clearAmazonRating") - @Mapping(target = "amazonReviewCount", source = "clearAmazonReviewCount") - @Mapping(target = "goodreadsRating", source = "clearGoodreadsRating") - @Mapping(target = "goodreadsReviewCount", source = "clearGoodreadsReviewCount") - @Mapping(target = "hardcoverRating", source = "clearHardcoverRating") - @Mapping(target = "hardcoverReviewCount", source = "clearHardcoverReviewCount") - @Mapping(target = "personalRating", source = "clearPersonalRating") - @Mapping(target = "authors", source = "clearAuthors") - @Mapping(target = "categories", source = "clearGenres") - @Mapping(target = "cover", source = "clearCover") - MetadataClearFlags toClearFlags(BulkMetadataUpdateRequest request);*/ - - @Mapping(target = "publisher", source = "clearPublisher") @Mapping(target = "publishedDate", source = "clearPublishedDate") @Mapping(target = "seriesName", source = "clearSeriesName") @@ -45,5 +15,7 @@ public interface MetadataClearFlagsMapper { @Mapping(target = "language", source = "clearLanguage") @Mapping(target = "authors", source = "clearAuthors") @Mapping(target = "categories", source = "clearGenres") + @Mapping(target = "moods", source = "clearMoods") + @Mapping(target = "tags", source = "clearTags") MetadataClearFlags toClearFlags(BulkMetadataUpdateRequest request); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/MoodMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/MoodMapper.java new file mode 100644 index 000000000..c87e08152 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/MoodMapper.java @@ -0,0 +1,23 @@ +package com.adityachandel.booklore.mapper; + +import com.adityachandel.booklore.model.entity.MoodEntity; +import org.mapstruct.Mapper; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface MoodMapper { + + default String toMoodName(MoodEntity moodEntity) { + return moodEntity != null ? moodEntity.getName() : null; + } + + default List toMoodNamesList(List moodEntities) { + if (moodEntities == null || moodEntities.isEmpty()) { + return List.of(); + } + return moodEntities.stream() + .map(this::toMoodName) + .toList(); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/TagMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/TagMapper.java new file mode 100644 index 000000000..f5315aae6 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/TagMapper.java @@ -0,0 +1,23 @@ +package com.adityachandel.booklore.mapper; + +import com.adityachandel.booklore.model.entity.TagEntity; +import org.mapstruct.Mapper; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface TagMapper { + + default String toTagName(TagEntity tagEntity) { + return tagEntity != null ? tagEntity.getName() : null; + } + + default List toTagNamesList(List tagEntities) { + if (tagEntities == null || tagEntities.isEmpty()) { + return List.of(); + } + return tagEntities.stream() + .map(this::toTagName) + .toList(); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/v2/BookMapperV2.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/v2/BookMapperV2.java index 0d48421a1..32d5bad61 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/v2/BookMapperV2.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/v2/BookMapperV2.java @@ -24,6 +24,8 @@ public interface BookMapperV2 { @Named("mapMetadata") @Mapping(target = "authors", source = "authors", qualifiedByName = "mapAuthors") @Mapping(target = "categories", source = "categories", qualifiedByName = "mapCategories") + @Mapping(target = "moods", source = "moods", qualifiedByName = "mapMoods") + @Mapping(target = "tags", source = "tags", qualifiedByName = "mapTags") BookMetadata mapMetadata(BookMetadataEntity metadataEntity); @Named("mapAuthors") @@ -38,6 +40,18 @@ public interface BookMapperV2 { categories.stream().map(CategoryEntity::getName).collect(Collectors.toSet()); } + @Named("mapMoods") + default Set mapMoods(Set moods) { + return moods == null ? Set.of() : + moods.stream().map(MoodEntity::getName).collect(Collectors.toSet()); + } + + @Named("mapTags") + default Set mapTags(Set tags) { + return tags == null ? Set.of() : + tags.stream().map(TagEntity::getName).collect(Collectors.toSet()); + } + @Named("mapLibraryPathIdOnly") default LibraryPath mapLibraryPathIdOnly(LibraryPathEntity entity) { if (entity == null) return null; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/MetadataClearFlags.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/MetadataClearFlags.java index 762acc33d..aabc82094 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/MetadataClearFlags.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/MetadataClearFlags.java @@ -31,6 +31,8 @@ public class MetadataClearFlags { private boolean personalRating; private boolean authors; private boolean categories; + private boolean moods; + private boolean tags; private boolean cover; private boolean reviews; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookMetadata.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookMetadata.java index e681f3e53..3b2986ed0 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookMetadata.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookMetadata.java @@ -48,6 +48,8 @@ public class BookMetadata { private Instant coverUpdatedOn; private Set authors; private Set categories; + private Set moods; + private Set tags; private MetadataProvider provider; private String thumbnailUrl; private List bookReviews; @@ -82,5 +84,7 @@ public class BookMetadata { private Boolean coverLocked; private Boolean authorsLocked; private Boolean categoriesLocked; + private Boolean moodsLocked; + private Boolean tagsLocked; private Boolean reviewsLocked; } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/BulkMetadataUpdateRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/BulkMetadataUpdateRequest.java index 6cd5d3502..a9c445564 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/BulkMetadataUpdateRequest.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/BulkMetadataUpdateRequest.java @@ -29,4 +29,10 @@ public class BulkMetadataUpdateRequest { private Set genres; private boolean clearGenres; + + private Set moods; + private boolean clearMoods; + + private Set tags; + private boolean clearTags; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/MetadataRefreshOptions.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/MetadataRefreshOptions.java index e4790b0ce..f7293e0f1 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/MetadataRefreshOptions.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/MetadataRefreshOptions.java @@ -41,6 +41,8 @@ public class MetadataRefreshOptions { private FieldProvider isbn10; private FieldProvider language; private FieldProvider categories; + private FieldProvider moods; + private FieldProvider tags; private FieldProvider cover; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookMetadataEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookMetadataEntity.java index f4afce2e3..feca02f4d 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookMetadataEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookMetadataEntity.java @@ -105,7 +105,6 @@ public class BookMetadataEntity { @Column(name = "comicvine_id", length = 100) private String comicvineId; - // Locking fields @Column(name = "title_locked") private Boolean titleLocked = Boolean.FALSE; @@ -176,6 +175,12 @@ public class BookMetadataEntity { @Column(name = "categories_locked") private Boolean categoriesLocked = Boolean.FALSE; + @Column(name = "moods_locked") + private Boolean moodsLocked = Boolean.FALSE; + + @Column(name = "tags_locked") + private Boolean tagsLocked = Boolean.FALSE; + @Column(name = "goodreads_id_locked") private Boolean goodreadsIdLocked = Boolean.FALSE; @@ -214,6 +219,24 @@ public class BookMetadataEntity { @Fetch(FetchMode.SUBSELECT) private Set categories; + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "book_metadata_mood_mapping", + joinColumns = @JoinColumn(name = "book_id"), + inverseJoinColumns = @JoinColumn(name = "mood_id") + ) + @Fetch(FetchMode.SUBSELECT) + private Set moods; + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "book_metadata_tag_mapping", + joinColumns = @JoinColumn(name = "book_id"), + inverseJoinColumns = @JoinColumn(name = "tag_id") + ) + @Fetch(FetchMode.SUBSELECT) + private Set tags; + @OneToMany(mappedBy = "bookMetadata", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) @Fetch(FetchMode.SUBSELECT) private Set reviews = new HashSet<>(); @@ -235,6 +258,8 @@ public class BookMetadataEntity { this.seriesTotalLocked = lock; this.authorsLocked = lock; this.categoriesLocked = lock; + this.moodsLocked = lock; + this.tagsLocked = lock; this.amazonRatingLocked = lock; this.amazonReviewCountLocked = lock; this.goodreadsRatingLocked = lock; @@ -266,6 +291,8 @@ public class BookMetadataEntity { && Boolean.TRUE.equals(this.seriesTotalLocked) && Boolean.TRUE.equals(this.authorsLocked) && Boolean.TRUE.equals(this.categoriesLocked) + && Boolean.TRUE.equals(this.moodsLocked) + && Boolean.TRUE.equals(this.tagsLocked) && Boolean.TRUE.equals(this.amazonRatingLocked) && Boolean.TRUE.equals(this.amazonReviewCountLocked) && Boolean.TRUE.equals(this.goodreadsRatingLocked) diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/CategoryEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/CategoryEntity.java index 1477e7b14..f01760dc0 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/CategoryEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/CategoryEntity.java @@ -28,8 +28,7 @@ public class CategoryEntity { @Override public boolean equals(Object o) { if (this == o) return true; - if (!(o instanceof CategoryEntity)) return false; - CategoryEntity that = (CategoryEntity) o; + if (!(o instanceof CategoryEntity that)) return false; return name != null && name.equalsIgnoreCase(that.name); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/MoodEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/MoodEntity.java new file mode 100644 index 000000000..46f5619ec --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/MoodEntity.java @@ -0,0 +1,40 @@ +package com.adityachandel.booklore.model.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.util.HashSet; +import java.util.Set; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "mood") +public class MoodEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name", nullable = false, unique = true) + private String name; + + @ManyToMany(mappedBy = "moods", fetch = FetchType.LAZY) + private Set bookMetadataEntityList = new HashSet<>(); + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof MoodEntity that)) return false; + return name != null && name.equalsIgnoreCase(that.name); + } + + @Override + public int hashCode() { + return name != null ? name.toLowerCase().hashCode() : 0; + } +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/TagEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/TagEntity.java new file mode 100644 index 000000000..cd2ad5005 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/TagEntity.java @@ -0,0 +1,39 @@ +package com.adityachandel.booklore.model.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.util.HashSet; +import java.util.Set; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "tag") +public class TagEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name", nullable = false, unique = true) + private String name; + + @ManyToMany(mappedBy = "tags", fetch = FetchType.LAZY) + private Set bookMetadataEntityList = new HashSet<>(); + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof TagEntity that)) return false; + return name != null && name.equalsIgnoreCase(that.name); + } + + @Override + public int hashCode() { + return name != null ? name.toLowerCase().hashCode() : 0; + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/CategoryRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/CategoryRepository.java index 00b655fed..61137b68e 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/CategoryRepository.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/CategoryRepository.java @@ -4,15 +4,11 @@ import com.adityachandel.booklore.model.entity.CategoryEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import java.util.List; import java.util.Optional; -import java.util.Set; @Repository public interface CategoryRepository extends JpaRepository { Optional findByName(String categoryName); - - List findAllByIdIn(Set ids); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/MoodRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/MoodRepository.java new file mode 100644 index 000000000..1e8bda0ec --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/MoodRepository.java @@ -0,0 +1,14 @@ +package com.adityachandel.booklore.repository; + +import com.adityachandel.booklore.model.entity.MoodEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface MoodRepository extends JpaRepository { + + Optional findByName(String moodName); +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/TagRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/TagRepository.java new file mode 100644 index 000000000..69a729ed0 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/TagRepository.java @@ -0,0 +1,14 @@ +package com.adityachandel.booklore.repository; + +import com.adityachandel.booklore.model.entity.TagEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface TagRepository extends JpaRepository { + + Optional findByName(String tagName); +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java index 03d9f6728..2f7fc5441 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java @@ -38,12 +38,12 @@ public class SettingPersistenceHelper { public T getJsonSetting(Map settingsMap, AppSettingKey key, Class clazz, T defaultValue, boolean persistDefault) { return getJsonSettingInternal(settingsMap, key, defaultValue, persistDefault, - json -> objectMapper.readValue(json, clazz)); + json -> objectMapper.readValue(json, clazz)); } public T getJsonSetting(Map settingsMap, AppSettingKey key, TypeReference typeReference, T defaultValue, boolean persistDefault) { return getJsonSettingInternal(settingsMap, key, defaultValue, persistDefault, - json -> objectMapper.readValue(json, typeReference)); + json -> objectMapper.readValue(json, typeReference)); } private T getJsonSettingInternal(Map settingsMap, AppSettingKey key, T defaultValue, boolean persistDefault, JsonDeserializer deserializer) { @@ -136,6 +136,10 @@ public class SettingPersistenceHelper { new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads); MetadataRefreshOptions.FieldProvider categoriesProviders = new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads); + MetadataRefreshOptions.FieldProvider moodsProviders = + new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads); + MetadataRefreshOptions.FieldProvider tagsProviders = + new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads); MetadataRefreshOptions.FieldProvider coverProviders = new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads); @@ -153,6 +157,8 @@ public class SettingPersistenceHelper { isbn10Providers, languageProviders, categoriesProviders, + moodsProviders, + tagsProviders, coverProviders ); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java index eb9ca8974..714a02886 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java @@ -27,8 +27,6 @@ import com.adityachandel.booklore.util.SecurityContextVirtualThread; import com.adityachandel.booklore.service.appsettings.AppSettingService; import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor; import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry; -import com.adityachandel.booklore.service.metadata.backuprestore.MetadataBackupRestore; -import com.adityachandel.booklore.service.metadata.backuprestore.MetadataBackupRestoreFactory; import com.adityachandel.booklore.service.metadata.extractor.CbxMetadataExtractor; import com.adityachandel.booklore.service.metadata.parser.BookParser; import com.adityachandel.booklore.service.metadata.writer.MetadataWriterFactory; @@ -71,7 +69,6 @@ public class BookMetadataService { private final BookFileProcessorRegistry processorRegistry; private final BookQueryService bookQueryService; private final Map parserMap; - private final MetadataBackupRestoreFactory metadataBackupRestoreFactory; private final CbxMetadataExtractor cbxMetadataExtractor; private final MetadataWriterFactory metadataWriterFactory; private final MetadataClearFlagsMapper metadataClearFlagsMapper; @@ -238,18 +235,6 @@ public class BookMetadataService { return cbxMetadataExtractor.extractMetadata(new File(FileUtils.getBookFullPath(bookEntity))); } - public BookMetadata restoreMetadataFromBackup(Long bookId) throws IOException { - BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); - metadataBackupRestoreFactory.getService(bookEntity.getBookType()).restoreEmbeddedMetadata(bookEntity); - bookRepository.saveAndFlush(bookEntity); - return bookMetadataMapper.toBookMetadata(bookEntity.getMetadata(), true); - } - - public BookMetadata getBackedUpMetadata(Long bookId) { - BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); - return metadataBackupRestoreFactory.getService(bookEntity.getBookType()).getBackedUpMetadata(bookId); - } - @Transactional public List bulkUpdateMetadata(BulkMetadataUpdateRequest request, boolean mergeCategories) { List books = bookRepository.findAllWithMetadataByIds(request.getBookIds()); @@ -265,6 +250,8 @@ public class BookMetadataService { .seriesTotal(request.getSeriesTotal()) .publishedDate(request.getPublishedDate()) .categories(request.getGenres()) + .moods(request.getMoods()) + .tags(request.getTags()) .build(); MetadataUpdateWrapper metadataUpdateWrapper = MetadataUpdateWrapper.builder() @@ -280,16 +267,5 @@ public class BookMetadataService { .map(m -> bookMetadataMapper.toBookMetadata(m, false)) .toList(); } - - public Resource getBackupCoverForBook(long bookId) { - BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); - MetadataBackupRestore backupRestore = metadataBackupRestoreFactory.getService(bookEntity.getBookType()); - try { - return backupRestore.getBackupCover(bookId); - } catch (UnsupportedOperationException e) { - log.info("Cover backup not supported for file type: {}", bookEntity.getBookType()); - return null; - } - } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java index dc22c87a1..3e32277aa 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java @@ -4,18 +4,15 @@ import com.adityachandel.booklore.model.MetadataClearFlags; import com.adityachandel.booklore.model.MetadataUpdateWrapper; import com.adityachandel.booklore.model.dto.BookMetadata; import com.adityachandel.booklore.model.dto.settings.MetadataPersistenceSettings; -import com.adityachandel.booklore.model.entity.AuthorEntity; -import com.adityachandel.booklore.model.entity.BookEntity; -import com.adityachandel.booklore.model.entity.BookMetadataEntity; -import com.adityachandel.booklore.model.entity.CategoryEntity; +import com.adityachandel.booklore.model.entity.*; import com.adityachandel.booklore.model.enums.BookFileType; import com.adityachandel.booklore.repository.AuthorRepository; import com.adityachandel.booklore.repository.CategoryRepository; +import com.adityachandel.booklore.repository.MoodRepository; +import com.adityachandel.booklore.repository.TagRepository; import com.adityachandel.booklore.service.FileFingerprint; import com.adityachandel.booklore.service.appsettings.AppSettingService; import com.adityachandel.booklore.service.file.UnifiedFileMoveService; -import com.adityachandel.booklore.service.metadata.backuprestore.MetadataBackupRestore; -import com.adityachandel.booklore.service.metadata.backuprestore.MetadataBackupRestoreFactory; import com.adityachandel.booklore.service.metadata.writer.MetadataWriterFactory; import com.adityachandel.booklore.util.FileService; import com.adityachandel.booklore.util.MetadataChangeDetector; @@ -44,11 +41,12 @@ public class BookMetadataUpdater { private final AuthorRepository authorRepository; private final CategoryRepository categoryRepository; + private final MoodRepository moodRepository; + private final TagRepository tagRepository; private final FileService fileService; private final MetadataMatchService metadataMatchService; private final AppSettingService appSettingService; private final MetadataWriterFactory metadataWriterFactory; - private final MetadataBackupRestoreFactory metadataBackupRestoreFactory; private final BookReviewUpdateService bookReviewUpdateService; private final UnifiedFileMoveService unifiedFileMoveService; @@ -82,6 +80,8 @@ public class BookMetadataUpdater { updateBasicFields(newMetadata, metadata, clearFlags); updateAuthorsIfNeeded(newMetadata, metadata, clearFlags); updateCategoriesIfNeeded(newMetadata, metadata, clearFlags, mergeCategories); + updateMoodsIfNeeded(newMetadata, metadata, clearFlags, mergeCategories); + updateTagsIfNeeded(newMetadata, metadata, clearFlags, mergeCategories); bookReviewUpdateService.updateBookReviews(newMetadata, metadata, clearFlags, mergeCategories); updateThumbnailIfNeeded(bookId, newMetadata, metadata, setThumbnail); @@ -233,6 +233,67 @@ public class BookMetadataUpdater { } } + private void updateMoodsIfNeeded(BookMetadata m, BookMetadataEntity e, MetadataClearFlags clear, boolean merge) { + if (Boolean.TRUE.equals(e.getMoodsLocked())) { + return; + } + if (e.getMoods() == null) { + e.setMoods(new HashSet<>()); + } + if (clear.isMoods()) { + e.getMoods().clear(); + } else if (shouldUpdateField(false, m.getMoods()) && m.getMoods() != null) { + if (merge) { + Set existing = e.getMoods(); + for (String name : m.getMoods()) { + if (name == null || name.isBlank()) continue; + MoodEntity entity = moodRepository.findByName(name) + .orElseGet(() -> moodRepository.save(MoodEntity.builder().name(name).build())); + existing.add(entity); + } + } else { + Set existing = e.getMoods(); + existing.clear(); + Set result = m.getMoods().stream() + .filter(n -> n != null && !n.isBlank()) + .map(name -> moodRepository.findByName(name) + .orElseGet(() -> moodRepository.save(MoodEntity.builder().name(name).build()))) + .collect(Collectors.toSet()); + existing.addAll(result); + } + } + } + + private void updateTagsIfNeeded(BookMetadata m, BookMetadataEntity e, MetadataClearFlags clear, boolean merge) { + if (Boolean.TRUE.equals(e.getTagsLocked())) { + return; + } + if (e.getTags() == null) { + e.setTags(new HashSet<>()); + } + if (clear.isTags()) { + e.getTags().clear(); + } else if (shouldUpdateField(false, m.getTags()) && m.getTags() != null) { + if (merge) { + Set existing = e.getTags(); + for (String name : m.getTags()) { + if (name == null || name.isBlank()) continue; + TagEntity entity = tagRepository.findByName(name) + .orElseGet(() -> tagRepository.save(TagEntity.builder().name(name).build())); + existing.add(entity); + } + } else { + Set existing = e.getTags(); + existing.clear(); + Set result = m.getTags().stream() + .filter(n -> n != null && !n.isBlank()) + .map(name -> tagRepository.findByName(name) + .orElseGet(() -> tagRepository.save(TagEntity.builder().name(name).build()))) + .collect(Collectors.toSet()); + existing.addAll(result); + } + } + } private void updateThumbnailIfNeeded(long bookId, BookMetadata m, BookMetadataEntity e, boolean set) { if (Boolean.TRUE.equals(e.getCoverLocked())) { @@ -273,6 +334,8 @@ public class BookMetadataUpdater { Pair.of(m.getCoverLocked(), e::setCoverLocked), Pair.of(m.getAuthorsLocked(), e::setAuthorsLocked), Pair.of(m.getCategoriesLocked(), e::setCategoriesLocked), + Pair.of(m.getMoodsLocked(), e::setMoodsLocked), + Pair.of(m.getTagsLocked(), e::setTagsLocked), Pair.of(m.getReviewsLocked(), e::setReviewsLocked) ); lockMappings.forEach(pair -> { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java index cb1cf022c..addfd019e 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java @@ -326,6 +326,8 @@ public class MetadataRefreshService { addProviderToSet(fieldOptions.getDescription(), uniqueProviders); addProviderToSet(fieldOptions.getAuthors(), uniqueProviders); addProviderToSet(fieldOptions.getCategories(), uniqueProviders); + addProviderToSet(fieldOptions.getMoods(), uniqueProviders); + addProviderToSet(fieldOptions.getTags(), uniqueProviders); addProviderToSet(fieldOptions.getCover(), uniqueProviders); } @@ -396,8 +398,12 @@ public class MetadataRefreshService { if (refreshOptions.isMergeCategories()) { metadata.setCategories(getAllCategories(metadataMap, fieldOptions.getCategories(), BookMetadata::getCategories)); + metadata.setMoods(getAllMoods(metadataMap, fieldOptions.getMoods(), BookMetadata::getMoods)); + metadata.setTags(getAllTags(metadataMap, fieldOptions.getTags(), BookMetadata::getTags)); } else { metadata.setCategories(resolveFieldAsList(metadataMap, fieldOptions.getCategories(), BookMetadata::getCategories)); + metadata.setMoods(resolveFieldAsList(metadataMap, fieldOptions.getMoods(), BookMetadata::getMoods)); + metadata.setTags(resolveFieldAsList(metadataMap, fieldOptions.getTags(), BookMetadata::getTags)); } metadata.setThumbnailUrl(resolveFieldAsString(metadataMap, fieldOptions.getCover(), BookMetadata::getThumbnailUrl)); @@ -516,6 +522,48 @@ public class MetadataRefreshService { return new HashSet<>(uniqueCategories); } + Set getAllMoods(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractorList fieldValueExtractor) { + Set uniqueMoods = new HashSet<>(); + if (fieldProvider.getP4() != null && metadataMap.containsKey(fieldProvider.getP4())) { + Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP4())); + if (extracted != null) uniqueMoods.addAll(extracted); + } + if (fieldProvider.getP3() != null && metadataMap.containsKey(fieldProvider.getP3())) { + Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP3())); + if (extracted != null) uniqueMoods.addAll(extracted); + } + if (fieldProvider.getP2() != null && metadataMap.containsKey(fieldProvider.getP2())) { + Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP2())); + if (extracted != null) uniqueMoods.addAll(extracted); + } + if (fieldProvider.getP1() != null && metadataMap.containsKey(fieldProvider.getP1())) { + Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP1())); + if (extracted != null) uniqueMoods.addAll(extracted); + } + return new HashSet<>(uniqueMoods); + } + + Set getAllTags(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractorList fieldValueExtractor) { + Set uniqueTags = new HashSet<>(); + if (fieldProvider.getP4() != null && metadataMap.containsKey(fieldProvider.getP4())) { + Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP4())); + if (extracted != null) uniqueTags.addAll(extracted); + } + if (fieldProvider.getP3() != null && metadataMap.containsKey(fieldProvider.getP3())) { + Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP3())); + if (extracted != null) uniqueTags.addAll(extracted); + } + if (fieldProvider.getP2() != null && metadataMap.containsKey(fieldProvider.getP2())) { + Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP2())); + if (extracted != null) uniqueTags.addAll(extracted); + } + if (fieldProvider.getP1() != null && metadataMap.containsKey(fieldProvider.getP1())) { + Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP1())); + if (extracted != null) uniqueTags.addAll(extracted); + } + return new HashSet<>(uniqueTags); + } + protected Set getBookEntities(MetadataRefreshRequest request) { MetadataRefreshRequest.RefreshType refreshType = request.getRefreshType(); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/AbstractMetadataBackupRestoreService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/AbstractMetadataBackupRestoreService.java deleted file mode 100644 index b14730251..000000000 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/AbstractMetadataBackupRestoreService.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.adityachandel.booklore.service.metadata.backuprestore; - -import com.adityachandel.booklore.exception.ApiError; -import com.adityachandel.booklore.model.dto.BookMetadata; -import com.adityachandel.booklore.model.entity.BookEntity; -import com.adityachandel.booklore.repository.BookRepository; -import com.adityachandel.booklore.util.FileService; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.ObjectReader; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; - -@Slf4j -@RequiredArgsConstructor -public abstract class AbstractMetadataBackupRestoreService implements MetadataBackupRestore { - - protected final FileService fileService; - protected final ObjectMapper objectMapper; - protected final BookRepository bookRepository; - protected final BookMetadataRestorer bookMetadataRestorer; - - protected Path resolveBackupDir(BookEntity bookEntity) { - return Path.of(fileService.getMetadataBackupPath(), String.valueOf(bookEntity.getId())); - } - - protected void writeMetadata(BookEntity bookEntity, BookMetadata metadata, Path backupDir) throws IOException { - Path metadataFile = backupDir.resolve("metadata.json"); - Path filenameCheckFile = backupDir.resolve("original-filename.txt"); - String json = objectMapper.writer().writeValueAsString(metadata); - Files.writeString(metadataFile, json, StandardOpenOption.CREATE_NEW); - Files.writeString(filenameCheckFile, bookEntity.getFileName(), StandardOpenOption.CREATE_NEW); - } - - protected void validateBackupIntegrity(BookEntity bookEntity, Path metadataFile, Path filenameCheckFile) throws IOException { - if (Files.notExists(metadataFile)) { - throw ApiError.INTERNAL_SERVER_ERROR.createException("Metadata backup file not found."); - } - if (Files.notExists(filenameCheckFile)) { - throw ApiError.INTERNAL_SERVER_ERROR.createException("Filename check file is missing."); - } - String backedUpFilename = Files.readString(filenameCheckFile).trim(); - String currentFilename = bookEntity.getFileName().trim(); - if (!currentFilename.equals(backedUpFilename)) { - throw ApiError.INTERNAL_SERVER_ERROR.createException("The backup is for a different file."); - } - } - - protected BookMetadata readMetadata(Path metadataFile, Long bookId) { - try { - ObjectReader reader = objectMapper.readerFor(BookMetadata.class); - return reader.readValue(metadataFile.toFile()); - } catch (IOException e) { - log.error("Failed to read metadata backup for book ID {}: {}", bookId, e.getMessage(), e); - throw ApiError.INTERNAL_SERVER_ERROR.createException("Failed to read metadata backup file."); - } - } - - @Override - public BookMetadata getBackedUpMetadata(Long bookId) { - BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); - Path metadataFile = resolveBackupDir(bookEntity).resolve("metadata.json"); - if (Files.notExists(metadataFile)) { - throw ApiError.INTERNAL_SERVER_ERROR.createException("Metadata backup file not found."); - } - return readMetadata(metadataFile, bookId); - } -} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/BookMetadataRestorer.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/BookMetadataRestorer.java deleted file mode 100644 index f70c257ec..000000000 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/BookMetadataRestorer.java +++ /dev/null @@ -1,132 +0,0 @@ -package com.adityachandel.booklore.service.metadata.backuprestore; - -import com.adityachandel.booklore.model.MetadataClearFlags; -import com.adityachandel.booklore.model.dto.BookMetadata; -import com.adityachandel.booklore.model.dto.settings.MetadataPersistenceSettings; -import com.adityachandel.booklore.model.enums.BookFileType; -import com.adityachandel.booklore.model.entity.AuthorEntity; -import com.adityachandel.booklore.model.entity.BookEntity; -import com.adityachandel.booklore.model.entity.BookMetadataEntity; -import com.adityachandel.booklore.model.entity.CategoryEntity; -import com.adityachandel.booklore.repository.AuthorRepository; -import com.adityachandel.booklore.repository.BookMetadataRepository; -import com.adityachandel.booklore.repository.BookRepository; -import com.adityachandel.booklore.repository.CategoryRepository; -import com.adityachandel.booklore.service.appsettings.AppSettingService; -import com.adityachandel.booklore.service.metadata.MetadataMatchService; -import com.adityachandel.booklore.service.metadata.writer.MetadataWriterFactory; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.io.File; -import java.util.HashSet; -import java.util.Set; -import java.util.stream.Collectors; - -@Slf4j -@Service -@RequiredArgsConstructor -public class BookMetadataRestorer { - - private final AuthorRepository authorRepository; - private final CategoryRepository categoryRepository; - private final BookMetadataRepository bookMetadataRepository; - private final BookRepository bookRepository; - private final MetadataMatchService metadataMatchService; - private final AppSettingService appSettingService; - private final MetadataWriterFactory metadataWriterFactory; - - @Transactional - public void restoreMetadata(BookEntity bookEntity, BookMetadata backup, String coverPath) { - BookMetadataEntity metadata = bookEntity.getMetadata(); - - if (!isLocked(metadata.getTitleLocked())) metadata.setTitle(backup.getTitle()); - if (!isLocked(metadata.getSubtitleLocked())) metadata.setSubtitle(backup.getSubtitle()); - if (!isLocked(metadata.getPublisherLocked())) metadata.setPublisher(backup.getPublisher()); - if (!isLocked(metadata.getPublishedDateLocked())) metadata.setPublishedDate(backup.getPublishedDate()); - if (!isLocked(metadata.getDescriptionLocked())) metadata.setDescription(backup.getDescription()); - if (!isLocked(metadata.getLanguageLocked())) metadata.setLanguage(backup.getLanguage()); - if (!isLocked(metadata.getPageCountLocked())) metadata.setPageCount(backup.getPageCount()); - - if (!isLocked(metadata.getSeriesNameLocked())) metadata.setSeriesName(backup.getSeriesName()); - if (!isLocked(metadata.getSeriesNumberLocked())) metadata.setSeriesNumber(backup.getSeriesNumber()); - if (!isLocked(metadata.getSeriesTotalLocked())) metadata.setSeriesTotal(backup.getSeriesTotal()); - - if (!isLocked(metadata.getIsbn13Locked())) metadata.setIsbn13(backup.getIsbn13()); - if (!isLocked(metadata.getIsbn10Locked())) metadata.setIsbn10(backup.getIsbn10()); - if (!isLocked(metadata.getAsinLocked())) metadata.setAsin(backup.getAsin()); - if (!isLocked(metadata.getGoodreadsIdLocked())) metadata.setGoodreadsId(backup.getGoodreadsId()); - if (!isLocked(metadata.getComicvineIdLocked())) metadata.setComicvineId(backup.getComicvineId()); - if (!isLocked(metadata.getHardcoverIdLocked())) metadata.setHardcoverId(backup.getHardcoverId()); - if (!isLocked(metadata.getGoogleIdLocked())) metadata.setGoogleId(backup.getGoogleId()); - - if (!isLocked(metadata.getAuthorsLocked())) { - Set authors = new HashSet<>(); - if (backup.getAuthors() != null) { - authors = backup.getAuthors().stream() - .map(name -> authorRepository.findByName(name) - .orElseGet(() -> authorRepository.save(AuthorEntity.builder().name(name).build()))) - .collect(Collectors.toSet()); - } - metadata.setAuthors(authors); - } - - if (!isLocked(metadata.getCategoriesLocked())) { - Set categories = new HashSet<>(); - if (backup.getCategories() != null) { - categories = backup.getCategories().stream() - .map(name -> categoryRepository.findByName(name) - .orElseGet(() -> categoryRepository.save(CategoryEntity.builder().name(name).build()))) - .collect(Collectors.toSet()); - } - metadata.setCategories(categories); - } - - if (!isLocked(metadata.getPersonalRatingLocked())) metadata.setPersonalRating(backup.getPersonalRating()); - if (!isLocked(metadata.getAmazonRatingLocked())) metadata.setAmazonRating(backup.getAmazonRating()); - if (!isLocked(metadata.getAmazonReviewCountLocked())) metadata.setAmazonReviewCount(backup.getAmazonReviewCount()); - - if (!isLocked(metadata.getGoodreadsRatingLocked())) metadata.setGoodreadsRating(backup.getGoodreadsRating()); - if (!isLocked(metadata.getGoodreadsReviewCountLocked())) metadata.setGoodreadsReviewCount(backup.getGoodreadsReviewCount()); - - if (!isLocked(metadata.getHardcoverRatingLocked())) metadata.setHardcoverRating(backup.getHardcoverRating()); - if (!isLocked(metadata.getHardcoverReviewCountLocked())) metadata.setHardcoverReviewCount(backup.getHardcoverReviewCount()); - - bookMetadataRepository.save(metadata); - - try { - Float score = metadataMatchService.calculateMatchScore(bookEntity); - bookEntity.setMetadataMatchScore(score); - bookRepository.save(bookEntity); - } catch (Exception e) { - log.warn("Failed to calculate/save metadata match score for book ID {}: {}", bookEntity.getId(), e.getMessage()); - } - - try { - MetadataPersistenceSettings settings = appSettingService.getAppSettings().getMetadataPersistenceSettings(); - boolean saveToOriginal = settings.isSaveToOriginalFile(); - boolean convertCbrCb7ToCbz = settings.isConvertCbrCb7ToCbz(); - if (saveToOriginal && (bookEntity.getBookType() != BookFileType.CBX || convertCbrCb7ToCbz)) { - metadataWriterFactory.getWriter(bookEntity.getBookType()).ifPresent(writer -> { - try { - File file = new File(bookEntity.getFullFilePath().toUri()); - writer.writeMetadataToFile(file, metadata, coverPath, true, new MetadataClearFlags()); - log.info("Embedded metadata written to file for book ID {}", bookEntity.getId()); - } catch (Exception e) { - log.warn("Failed to write metadata to file for book ID {}: {}", bookEntity.getId(), e.getMessage()); - } - }); - } - } catch (Exception e) { - log.warn("Error during embedded metadata write: {}", e.getMessage()); - } - - log.info("Metadata fully restored from backup for book ID {}", bookEntity.getId()); - } - - private boolean isLocked(Boolean locked) { - return locked != null && locked; - } -} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/EpubMetadataBackupRestoreService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/EpubMetadataBackupRestoreService.java deleted file mode 100644 index 6abab4819..000000000 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/EpubMetadataBackupRestoreService.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.adityachandel.booklore.service.metadata.backuprestore; - -import com.adityachandel.booklore.exception.ApiError; -import com.adityachandel.booklore.model.dto.BookMetadata; -import com.adityachandel.booklore.model.entity.BookEntity; -import com.adityachandel.booklore.model.entity.BookMetadataEntity; -import com.adityachandel.booklore.model.enums.BookFileType; -import com.adityachandel.booklore.repository.BookRepository; -import com.adityachandel.booklore.service.metadata.extractor.FileMetadataExtractor; -import com.adityachandel.booklore.util.FileService; -import com.fasterxml.jackson.databind.ObjectMapper; -import io.documentnode.epub4j.domain.Book; -import io.documentnode.epub4j.domain.Resource; -import io.documentnode.epub4j.epub.EpubReader; -import lombok.extern.slf4j.Slf4j; -import org.springframework.core.io.FileSystemResource; -import org.springframework.stereotype.Service; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.time.Instant; - -@Slf4j -@Service -public class EpubMetadataBackupRestoreService extends AbstractMetadataBackupRestoreService { - - private final FileMetadataExtractor epubMetadataExtractor; - - public EpubMetadataBackupRestoreService(FileService fileService, ObjectMapper objectMapper, BookRepository bookRepository, BookMetadataRestorer bookMetadataRestorer, FileMetadataExtractor epubMetadataExtractor) { - super(fileService, objectMapper, bookRepository, bookMetadataRestorer); - this.epubMetadataExtractor = epubMetadataExtractor; - } - - @Override - public void backupEmbeddedMetadataIfNotExists(BookEntity bookEntity, boolean backupCover) { - File bookFile = new File(bookEntity.getFullFilePath().toUri()); - Path backupDir = resolveBackupDir(bookEntity); - Path metadataFile = backupDir.resolve("metadata.json"); - Path coverFile = backupDir.resolve("cover.jpg"); - - if (Files.exists(metadataFile)) return; - - try { - Files.createDirectories(backupDir); - BookMetadata metadata = epubMetadataExtractor.extractMetadata(bookFile); - writeMetadata(bookEntity, metadata, backupDir); - if (backupCover) { - try (FileInputStream fis = new FileInputStream(bookFile)) { - Book epubBook = new EpubReader().readEpub(fis); - Resource coverImage = epubBook.getCoverImage(); - if (coverImage != null) { - Files.write(coverFile, coverImage.getData(), StandardOpenOption.CREATE_NEW); - log.info("Backup cover image saved for book ID {} at {}", bookEntity.getId(), coverFile); - } else { - log.warn("No cover image found in EPUB for book ID {}", bookEntity.getId()); - } - } - } - log.info("Created EPUB metadata backup for book ID {} at {}", bookEntity.getId(), backupDir); - } catch (Exception e) { - log.warn("Failed to backup EPUB metadata for book ID {}", bookEntity.getId(), e); - } - } - - @Override - public void restoreEmbeddedMetadata(BookEntity bookEntity) throws IOException { - Path backupDir = resolveBackupDir(bookEntity); - Path metadataFile = backupDir.resolve("metadata.json"); - Path coverFile = backupDir.resolve("cover.jpg"); - Path filenameCheckFile = backupDir.resolve("original-filename.txt"); - - validateBackupIntegrity(bookEntity, metadataFile, filenameCheckFile); - - BookMetadata backupMetadata = readMetadata(metadataFile, bookEntity.getId()); - bookMetadataRestorer.restoreMetadata(bookEntity, backupMetadata, coverFile.toString()); - - updateThumbnailIfNeeded(bookEntity.getMetadata(), coverFile, bookEntity.getId()); - - log.info("Successfully restored embedded metadata for EPUB book ID {}", bookEntity.getId()); - } - - @Override - public org.springframework.core.io.Resource getBackupCover(long bookId) { - BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); - - Path coverPath = resolveBackupDir(bookEntity).resolve("cover.jpg"); - - if (Files.notExists(coverPath)) { - log.warn("No cover image found in backup for book ID {} at {}", bookId, coverPath); - throw ApiError.INTERNAL_SERVER_ERROR.createException("Backup cover image not found."); - } - - return new FileSystemResource(coverPath); - } - - @Override - public BookFileType getSupportedBookType() { - return BookFileType.EPUB; - } - - private void updateThumbnailIfNeeded(BookMetadataEntity metadata, Path coverFile, long bookId) throws IOException { - /*String thumbnailPath = fileService.createThumbnailFromFile(bookId, coverFile.toString());*/ - metadata.setCoverUpdatedOn(Instant.now()); - } -} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/MetadataBackupRestore.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/MetadataBackupRestore.java deleted file mode 100644 index 35265a9df..000000000 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/MetadataBackupRestore.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.adityachandel.booklore.service.metadata.backuprestore; - -import com.adityachandel.booklore.model.dto.BookMetadata; -import com.adityachandel.booklore.model.entity.BookEntity; -import com.adityachandel.booklore.model.enums.BookFileType; -import org.springframework.core.io.Resource; - -import java.io.IOException; - -public interface MetadataBackupRestore { - - void backupEmbeddedMetadataIfNotExists(BookEntity bookEntity, boolean backupCover); - - void restoreEmbeddedMetadata(BookEntity bookEntity) throws IOException; - - BookMetadata getBackedUpMetadata(Long bookId); - - Resource getBackupCover(long bookId); - - BookFileType getSupportedBookType(); -} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/MetadataBackupRestoreFactory.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/MetadataBackupRestoreFactory.java deleted file mode 100644 index e4317c31f..000000000 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/MetadataBackupRestoreFactory.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.adityachandel.booklore.service.metadata.backuprestore; - -import com.adityachandel.booklore.exception.ApiError; -import com.adityachandel.booklore.model.enums.BookFileType; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; - -@Component -public class MetadataBackupRestoreFactory { - - private final Map serviceMap; - - public MetadataBackupRestoreFactory(List services) { - serviceMap = services.stream() - .collect(Collectors.toMap(MetadataBackupRestore::getSupportedBookType, Function.identity())); - } - - public MetadataBackupRestore getService(BookFileType bookType) { - MetadataBackupRestore service = serviceMap.get(bookType); - if (service == null) { - throw ApiError.UNSUPPORTED_FILE_TYPE.createException("No backup service for file type: " + bookType); - } - return service; - } -} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/PdfMetadataBackupRestoreService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/PdfMetadataBackupRestoreService.java deleted file mode 100644 index 5bd624b22..000000000 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/PdfMetadataBackupRestoreService.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.adityachandel.booklore.service.metadata.backuprestore; - -import com.adityachandel.booklore.model.dto.BookMetadata; -import com.adityachandel.booklore.model.entity.BookEntity; -import com.adityachandel.booklore.model.enums.BookFileType; -import com.adityachandel.booklore.repository.BookRepository; -import com.adityachandel.booklore.service.metadata.extractor.FileMetadataExtractor; -import com.adityachandel.booklore.util.FileService; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.extern.slf4j.Slf4j; -import org.springframework.core.io.Resource; -import org.springframework.stereotype.Service; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -@Slf4j -@Service -public class PdfMetadataBackupRestoreService extends AbstractMetadataBackupRestoreService { - - private final FileMetadataExtractor pdfMetadataExtractor; - - public PdfMetadataBackupRestoreService(FileService fileService, ObjectMapper objectMapper, BookRepository bookRepository, BookMetadataRestorer bookMetadataRestorer, FileMetadataExtractor pdfMetadataExtractor) { - super(fileService, objectMapper, bookRepository, bookMetadataRestorer); - this.pdfMetadataExtractor = pdfMetadataExtractor; - } - - @Override - public void backupEmbeddedMetadataIfNotExists(BookEntity bookEntity, boolean backupCover) { - Path backupDir = resolveBackupDir(bookEntity); - Path metadataFile = backupDir.resolve("metadata.json"); - if (Files.exists(metadataFile)) return; - - try { - Files.createDirectories(backupDir); - BookMetadata metadata = pdfMetadataExtractor.extractMetadata(new File(bookEntity.getFullFilePath().toUri())); - writeMetadata(bookEntity, metadata, backupDir); - log.info("Created PDF metadata backup for book ID {}", bookEntity.getId()); - } catch (Exception e) { - log.warn("Failed to backup metadata for PDF book ID {}", bookEntity.getId(), e); - } - } - - @Override - public void restoreEmbeddedMetadata(BookEntity bookEntity) throws IOException { - Path backupDir = resolveBackupDir(bookEntity); - Path metadataFile = backupDir.resolve("metadata.json"); - Path filenameCheckFile = backupDir.resolve("original-filename.txt"); - - validateBackupIntegrity(bookEntity, metadataFile, filenameCheckFile); - BookMetadata backupMetadata = readMetadata(metadataFile, bookEntity.getId()); - bookMetadataRestorer.restoreMetadata(bookEntity, backupMetadata, null); - log.info("Restored PDF metadata for book ID {}", bookEntity.getId()); - } - - @Override - public Resource getBackupCover(long bookId) { - throw new UnsupportedOperationException("Cover backup not supported for PDF files."); - } - - @Override - public BookFileType getSupportedBookType() { - return BookFileType.PDF; - } -} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/HardcoverParser.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/HardcoverParser.java index 5bb700683..40122fc1e 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/HardcoverParser.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/HardcoverParser.java @@ -8,15 +8,18 @@ import com.adityachandel.booklore.service.metadata.parser.hardcover.GraphQLRespo import com.adityachandel.booklore.service.metadata.parser.hardcover.HardcoverBookSearchService; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; +import org.apache.commons.text.WordUtils; import org.apache.commons.text.similarity.FuzzyScore; - -import java.util.Locale; +import org.springframework.stereotype.Service; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; +import java.util.HashSet; import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; @Slf4j @Service @@ -48,10 +51,13 @@ public class HardcoverParser implements BookParser { String searchAuthor = fetchMetadataRequest.getAuthor() != null ? fetchMetadataRequest.getAuthor() : ""; return hits.stream() - .filter(hit -> { + .map(GraphQLResponse.Hit::getDocument) + .filter(doc -> { if (searchByIsbn || searchAuthor.isBlank()) return true; - List actualAuthorTokens = hit.getDocument().getAuthorNames().stream() + if (doc.getAuthorNames() == null || doc.getAuthorNames().isEmpty()) return false; + + List actualAuthorTokens = doc.getAuthorNames().stream() .flatMap(name -> List.of(name.toLowerCase().split("\\s+")).stream()) .toList(); List searchAuthorTokens = List.of(searchAuthor.toLowerCase().split("\\s+")); @@ -66,13 +72,15 @@ public class HardcoverParser implements BookParser { } return false; }) - .map(hit -> { - GraphQLResponse.Document doc = hit.getDocument(); + .map(doc -> { BookMetadata metadata = new BookMetadata(); metadata.setHardcoverId(doc.getSlug()); metadata.setTitle(doc.getTitle()); + metadata.setSubtitle(doc.getSubtitle()); metadata.setDescription(doc.getDescription()); - metadata.setAuthors(doc.getAuthorNames()); + if (doc.getAuthorNames() != null) { + metadata.setAuthors(Set.copyOf(doc.getAuthorNames())); + } if (doc.getFeaturedSeries() != null) { if (doc.getFeaturedSeries().getSeries() != null) { @@ -93,7 +101,22 @@ public class HardcoverParser implements BookParser { metadata.setHardcoverReviewCount(doc.getRatingsCount()); metadata.setPageCount(doc.getPages()); metadata.setPublishedDate(doc.getReleaseDate() != null ? LocalDate.parse(doc.getReleaseDate()) : null); - metadata.setCategories(doc.getGenres()); + + if (doc.getGenres() != null && !doc.getGenres().isEmpty()) { + metadata.setCategories(doc.getGenres().stream() + .map(WordUtils::capitalizeFully) + .collect(Collectors.toSet())); + } + if (doc.getMoods() != null && !doc.getMoods().isEmpty()) { + metadata.setMoods(doc.getMoods().stream() + .map(WordUtils::capitalizeFully) + .collect(Collectors.toSet())); + } + if (doc.getTags() != null && !doc.getTags().isEmpty()) { + metadata.setTags(doc.getTags().stream() + .map(WordUtils::capitalizeFully) + .collect(Collectors.toSet())); + } if (doc.getIsbns() != null) { String inputIsbn = fetchMetadataRequest.getIsbn(); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/GraphQLRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/GraphQLRequest.java index e5c5ff940..dad099ea9 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/GraphQLRequest.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/GraphQLRequest.java @@ -2,8 +2,11 @@ package com.adityachandel.booklore.service.metadata.parser.hardcover; import lombok.Data; +import java.util.Map; + @Data public class GraphQLRequest { private String query; private String operationName; + private Map variables; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/GraphQLResponse.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/GraphQLResponse.java index 8a54a47f4..11dfa52a6 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/GraphQLResponse.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/GraphQLResponse.java @@ -2,46 +2,92 @@ package com.adityachandel.booklore.service.metadata.parser.hardcover; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.Data; +import lombok.*; import java.util.List; +import java.util.Map; import java.util.Set; -@Data +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public class GraphQLResponse { - private DataWrapper data; + private Data data; - @Data + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) - public static class DataWrapper { - private SearchWrapper search; + public static class Data { + private Search search; } - @Data + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) - public static class SearchWrapper { - private ResultsWrapper results; + public static class Search { + private Results results; } - @Data + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) - public static class ResultsWrapper { + public static class Results { + @JsonProperty("facet_counts") + private List facetCounts; + + private Integer found; private List hits; + + @JsonProperty("out_of") + private Integer outOf; + + private Integer page; + + @JsonProperty("request_params") + private Map requestParams; + + @JsonProperty("search_cutoff") + private Boolean searchCutoff; + + @JsonProperty("search_time_ms") + private Integer searchTimeMs; } - @Data + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public static class Hit { private Document document; + private Map highlight; + private List> highlights; + + @JsonProperty("text_match") + private Long textMatch; + + @JsonProperty("text_match_info") + private Map textMatchInfo; } - @Data + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public static class Document { private String id; private String slug; private String title; + private String subtitle; @JsonProperty("author_names") private Set authorNames; @@ -53,33 +99,90 @@ public class GraphQLResponse { @JsonProperty("ratings_count") private Integer ratingsCount; + @JsonProperty("reviews_count") + private Integer reviewsCount; + private Integer pages; @JsonProperty("release_date") private String releaseDate; - private Set genres; + @JsonProperty("release_year") + private Integer releaseYear; + + private List genres; + private List moods; + private List tags; @JsonProperty("featured_series") private FeaturedSeries featuredSeries; private Image image; + + @JsonProperty("alternative_titles") + private List alternativeTitles; + + @JsonProperty("activities_count") + private Integer activitiesCount; + + private Boolean compilation; + + @JsonProperty("content_warnings") + private List contentWarnings; + + @JsonProperty("contribution_types") + private List contributionTypes; + + private List> contributions; + + @JsonProperty("cover_color") + private String coverColor; + + @JsonProperty("has_audiobook") + private Boolean hasAudiobook; + + @JsonProperty("has_ebook") + private Boolean hasEbook; + + @JsonProperty("lists_count") + private Integer listsCount; + + @JsonProperty("prompts_count") + private Integer promptsCount; + + @JsonProperty("series_names") + private List seriesNames; + + @JsonProperty("users_count") + private Integer usersCount; + + @JsonProperty("users_read_count") + private Integer usersReadCount; } - @Data + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public static class Image { private String url; } - @Data + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public static class FeaturedSeries { private Integer position; private Series series; } - @Data + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public static class Series { private String name; @@ -87,4 +190,3 @@ public class GraphQLResponse { private Integer booksCount; } } - diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/HardcoverBookSearchService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/HardcoverBookSearchService.java index f7b41793c..a7bcf2f5f 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/HardcoverBookSearchService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/HardcoverBookSearchService.java @@ -35,22 +35,14 @@ public class HardcoverBookSearchService { return Collections.emptyList(); } - String graphqlQuery = """ - query SearchBooks { - search( - query: "%s", - query_type: "Book", - per_page: 5, - page: 1 - ) { - results - } - } - """.formatted(query); + String graphqlQuery = String.format( + "query SearchBooks { search(query: \"%s\", query_type: \"Book\", per_page: 5, page: 1) { results } }", + query.replace("\"", "\\\"") + ); GraphQLRequest body = new GraphQLRequest(); body.setQuery(graphqlQuery); - body.setOperationName("SearchBooks"); + body.setVariables(Collections.emptyMap()); try { GraphQLResponse response = restClient.post() diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/util/MetadataChangeDetector.java b/booklore-api/src/main/java/com/adityachandel/booklore/util/MetadataChangeDetector.java index 2d90f3b5f..4d8e44a4e 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/util/MetadataChangeDetector.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/util/MetadataChangeDetector.java @@ -47,6 +47,8 @@ public class MetadataChangeDetector { compare(changes, "hardcoverReviewCount", clear.isHardcoverReviewCount(), newMeta.getHardcoverReviewCount(), existingMeta.getHardcoverReviewCount(), () -> !isTrue(existingMeta.getHardcoverReviewCountLocked()), newMeta.getHardcoverReviewCountLocked(), existingMeta.getHardcoverReviewCountLocked()); compare(changes, "authors", clear.isAuthors(), newMeta.getAuthors(), toNameSet(existingMeta.getAuthors()), () -> !isTrue(existingMeta.getAuthorsLocked()), newMeta.getAuthorsLocked(), existingMeta.getAuthorsLocked()); compare(changes, "categories", clear.isCategories(), newMeta.getCategories(), toNameSet(existingMeta.getCategories()), () -> !isTrue(existingMeta.getCategoriesLocked()), newMeta.getCategoriesLocked(), existingMeta.getCategoriesLocked()); + compare(changes, "moods", clear.isMoods(), newMeta.getMoods(), toNameSet(existingMeta.getMoods()), () -> !isTrue(existingMeta.getMoodsLocked()), newMeta.getMoodsLocked(), existingMeta.getMoodsLocked()); + compare(changes, "tags", clear.isTags(), newMeta.getTags(), toNameSet(existingMeta.getTags()), () -> !isTrue(existingMeta.getTagsLocked()), newMeta.getTagsLocked(), existingMeta.getTagsLocked()); Boolean coverLockedNew = newMeta.getCoverLocked(); Boolean coverLockedExisting = existingMeta.getCoverLocked(); @@ -90,6 +92,8 @@ public class MetadataChangeDetector { compareValue(diffs, "hardcoverReviewCount", clear.isHardcoverReviewCount(), newMeta.getHardcoverReviewCount(), existingMeta.getHardcoverReviewCount(), () -> !isTrue(existingMeta.getHardcoverReviewCountLocked())); compareValue(diffs, "authors", clear.isAuthors(), newMeta.getAuthors(), toNameSet(existingMeta.getAuthors()), () -> !isTrue(existingMeta.getAuthorsLocked())); compareValue(diffs, "categories", clear.isCategories(), newMeta.getCategories(), toNameSet(existingMeta.getCategories()), () -> !isTrue(existingMeta.getCategoriesLocked())); + compareValue(diffs, "moods", clear.isMoods(), newMeta.getMoods(), toNameSet(existingMeta.getMoods()), () -> !isTrue(existingMeta.getMoodsLocked())); + compareValue(diffs, "tags", clear.isTags(), newMeta.getTags(), toNameSet(existingMeta.getTags()), () -> !isTrue(existingMeta.getTagsLocked())); return !diffs.isEmpty(); } @@ -121,6 +125,8 @@ public class MetadataChangeDetector { if (differsLock(newMeta.getCoverLocked(), existingMeta.getCoverLocked())) return true; if (differsLock(newMeta.getAuthorsLocked(), existingMeta.getAuthorsLocked())) return true; if (differsLock(newMeta.getCategoriesLocked(), existingMeta.getCategoriesLocked())) return true; + if (differsLock(newMeta.getMoodsLocked(), existingMeta.getMoodsLocked())) return true; + if (differsLock(newMeta.getTagsLocked(), existingMeta.getTagsLocked())) return true; if (differsLock(newMeta.getReviewsLocked(), existingMeta.getReviewsLocked())) return true; return false; } diff --git a/booklore-api/src/main/resources/db/migration/V53__Add_Mood_And_Tag_Tables.sql b/booklore-api/src/main/resources/db/migration/V53__Add_Mood_And_Tag_Tables.sql new file mode 100644 index 000000000..da289cc64 --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V53__Add_Mood_And_Tag_Tables.sql @@ -0,0 +1,33 @@ +CREATE TABLE IF NOT EXISTS mood +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS tag +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE +); + +ALTER TABLE book_metadata +ADD COLUMN moods_locked BOOLEAN DEFAULT FALSE, +ADD COLUMN tags_locked BOOLEAN DEFAULT FALSE; + +CREATE TABLE IF NOT EXISTS book_metadata_mood_mapping +( + book_id BIGINT NOT NULL, + mood_id BIGINT NOT NULL, + PRIMARY KEY (book_id, mood_id), + CONSTRAINT fk_book_metadata_mood_mapping_book FOREIGN KEY (book_id) REFERENCES book_metadata (book_id) ON DELETE CASCADE, + CONSTRAINT fk_book_metadata_mood_mapping_mood FOREIGN KEY (mood_id) REFERENCES mood (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS book_metadata_tag_mapping +( + book_id BIGINT NOT NULL, + tag_id BIGINT NOT NULL, + PRIMARY KEY (book_id, tag_id), + CONSTRAINT fk_book_metadata_tag_mapping_book FOREIGN KEY (book_id) REFERENCES book_metadata (book_id) ON DELETE CASCADE, + CONSTRAINT fk_book_metadata_tag_mapping_tag FOREIGN KEY (tag_id) REFERENCES tag (id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/MetadataRefreshServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/MetadataRefreshServiceTest.java index e5ce766c7..1fce67766 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/MetadataRefreshServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/MetadataRefreshServiceTest.java @@ -91,12 +91,16 @@ class MetadataRefreshServiceTest { null, null, null, MetadataProvider.GoodReads); MetadataRefreshOptions.FieldProvider categoriesProvider = new MetadataRefreshOptions.FieldProvider( null, null, null, MetadataProvider.Google); + MetadataRefreshOptions.FieldProvider moodProvider = new MetadataRefreshOptions.FieldProvider( + null, null, null, MetadataProvider.Google); + MetadataRefreshOptions.FieldProvider tagProvider = new MetadataRefreshOptions.FieldProvider( + null, null, null, MetadataProvider.Google); MetadataRefreshOptions.FieldProvider coverProvider = new MetadataRefreshOptions.FieldProvider( null, null, null, MetadataProvider.GoodReads); MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions( titleProvider, null, descriptionProvider, authorsProvider, null, null, - null, null, null, null, null, null, categoriesProvider, coverProvider); + null, null, null, null, null, null, categoriesProvider, moodProvider, tagProvider, coverProvider); defaultOptions = new MetadataRefreshOptions( null, MetadataProvider.GoodReads, MetadataProvider.Google, null, null, @@ -109,7 +113,7 @@ class MetadataRefreshServiceTest { MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions( titleProvider, null, null, null, null, null, - null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null); libraryOptions = new MetadataRefreshOptions( 1L, MetadataProvider.Google, null, null, null, @@ -225,7 +229,7 @@ class MetadataRefreshServiceTest { null, null, null, MetadataProvider.Hardcover); MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions( titleProvider, null, null, null, null, null, - null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null); MetadataRefreshOptions requestOptions = new MetadataRefreshOptions( null, MetadataProvider.Hardcover, null, null, null, @@ -340,7 +344,7 @@ class MetadataRefreshServiceTest { when(bookRepository.findAllWithMetadataByIds(Set.of(999L))).thenReturn(Collections.emptyList()); assertThrows(RuntimeException.class, () -> - metadataRefreshService.refreshMetadata(request, 1L, "job-1")); + metadataRefreshService.refreshMetadata(request, 1L, "job-1")); } @Test @@ -354,7 +358,7 @@ class MetadataRefreshServiceTest { when(libraryRepository.findById(999L)).thenReturn(Optional.empty()); assertThrows(RuntimeException.class, () -> - metadataRefreshService.refreshMetadata(request, 1L, "job-1")); + metadataRefreshService.refreshMetadata(request, 1L, "job-1")); } @Test @@ -451,6 +455,10 @@ class MetadataRefreshServiceTest { null, null, null, MetadataProvider.Google); MetadataRefreshOptions.FieldProvider authorsProvider = new MetadataRefreshOptions.FieldProvider( null, null, null, MetadataProvider.Google); + MetadataRefreshOptions.FieldProvider moodProvider = new MetadataRefreshOptions.FieldProvider( + null, null, null, MetadataProvider.Google); + MetadataRefreshOptions.FieldProvider tagProvider = new MetadataRefreshOptions.FieldProvider( + null, null, null, MetadataProvider.Google); MetadataRefreshOptions.FieldProvider categoriesProvider = new MetadataRefreshOptions.FieldProvider( null, null, MetadataProvider.Google, MetadataProvider.GoodReads); MetadataRefreshOptions.FieldProvider coverProvider = new MetadataRefreshOptions.FieldProvider( @@ -458,7 +466,7 @@ class MetadataRefreshServiceTest { MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions( titleProvider, null, descriptionProvider, authorsProvider, null, null, - null, null, null, null, null, null, categoriesProvider, coverProvider); + null, null, null, null, null, null, categoriesProvider, moodProvider, tagProvider, coverProvider); MetadataRefreshOptions mergeOptions = new MetadataRefreshOptions( null, MetadataProvider.GoodReads, MetadataProvider.Google, null, null, diff --git a/booklore-ui/src/app/book/components/book-browser/filters/SidebarFilter.ts b/booklore-ui/src/app/book/components/book-browser/filters/SidebarFilter.ts index 2fa5ba94d..38dc226bf 100644 --- a/booklore-ui/src/app/book/components/book-browser/filters/SidebarFilter.ts +++ b/booklore-ui/src/app/book/components/book-browser/filters/SidebarFilter.ts @@ -66,6 +66,14 @@ export class SideBarFilter implements BookFilter { return mode === 'and' ? filterValues.every(val => book.metadata?.categories?.includes(val)) : filterValues.some(val => book.metadata?.categories?.includes(val)); + case 'mood': + return mode === 'and' + ? filterValues.every(val => book.metadata?.moods?.includes(val)) + : filterValues.some(val => book.metadata?.moods?.includes(val)); + case 'tag': + return mode === 'and' + ? filterValues.every(val => book.metadata?.tags?.includes(val)) + : filterValues.some(val => book.metadata?.tags?.includes(val)); case 'publisher': return mode === 'and' ? filterValues.every(val => book.metadata?.publisher === val) diff --git a/booklore-ui/src/app/book/model/book.model.ts b/booklore-ui/src/app/book/model/book.model.ts index 337504672..081235f9f 100644 --- a/booklore-ui/src/app/book/model/book.model.ts +++ b/booklore-ui/src/app/book/model/book.model.ts @@ -96,6 +96,8 @@ export interface BookMetadata { coverUpdatedOn?: string; authors?: string[]; categories?: string[]; + moods?: string[]; + tags?: string[]; provider?: string; providerBookId?: string; thumbnailUrl?: string | null; @@ -128,6 +130,8 @@ export interface BookMetadata { coverUpdatedOnLocked?: boolean; authorsLocked?: boolean; categoriesLocked?: boolean; + moodsLocked?: boolean; + tagsLocked?: boolean; coverLocked?: boolean; reviewsLocked?: boolean; @@ -161,6 +165,8 @@ export interface MetadataClearFlags { personalRating?: boolean; authors?: boolean; categories?: boolean; + moods?: boolean; + tags?: boolean; cover?: boolean; } diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.html b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.html index f6cbaad9e..3021232ee 100644 --- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.html +++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.html @@ -176,6 +176,63 @@ +
+
+
+ +
+
+ + +
+ @if (!book.metadata!['moodsLocked']) { + + } + @if (book.metadata!['moodsLocked']) { + + } +
+
+
+
+
+ +
+
+ + +
+ @if (!book.metadata!['tagsLocked']) { + + } + @if (book.metadata!['tagsLocked']) { + + } +
+
+
+
+
diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.ts b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.ts index 2ffb7ad0f..fc0498f89 100644 --- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.ts +++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.ts @@ -1,42 +1,19 @@ -import { - Component, - DestroyRef, - EventEmitter, - inject, - Input, - OnInit, - Output, -} from "@angular/core"; +import {Component, DestroyRef, EventEmitter, inject, Input, OnInit, Output,} from "@angular/core"; import {InputText} from "primeng/inputtext"; import {Button} from "primeng/button"; import {Divider} from "primeng/divider"; -import { - FormControl, - FormGroup, - FormsModule, - ReactiveFormsModule, -} from "@angular/forms"; +import {FormControl, FormGroup, FormsModule, ReactiveFormsModule,} from "@angular/forms"; import {Observable} from "rxjs"; import {AsyncPipe} from "@angular/common"; import {MessageService} from "primeng/api"; -import { - Book, - BookMetadata, - MetadataClearFlags, - MetadataUpdateWrapper, -} from "../../../book/model/book.model"; +import {Book, BookMetadata, MetadataClearFlags, MetadataUpdateWrapper,} from "../../../book/model/book.model"; import {UrlHelperService} from "../../../utilities/service/url-helper.service"; -import { - FileUpload, - FileUploadErrorEvent, - FileUploadEvent, -} from "primeng/fileupload"; +import {FileUpload, FileUploadErrorEvent, FileUploadEvent,} from "primeng/fileupload"; import {HttpResponse} from "@angular/common/http"; import {BookService} from "../../../book/service/book.service"; import {ProgressSpinner} from "primeng/progressspinner"; import {Tooltip} from "primeng/tooltip"; import {filter, take} from "rxjs/operators"; -import {MetadataRestoreDialogComponent} from "../../../book/components/book-browser/metadata-restore-dialog-component/metadata-restore-dialog-component"; import {DialogService} from "primeng/dynamicdialog"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {MetadataRefreshRequest} from "../../model/request/metadata-refresh-request.model"; @@ -99,8 +76,12 @@ export class MetadataEditorComponent implements OnInit { allAuthors!: string[]; allCategories!: string[]; + allMoods!: string[]; + allTags!: string[]; filteredCategories: string[] = []; filteredAuthors: string[] = []; + filteredMoods: string[] = []; + filteredTags: string[] = []; filterCategories(event: { query: string }) { const query = event.query.toLowerCase(); @@ -116,12 +97,28 @@ export class MetadataEditorComponent implements OnInit { ); } + filterMoods(event: { query: string }) { + const query = event.query.toLowerCase(); + this.filteredMoods = this.allMoods.filter((mood) => + mood.toLowerCase().includes(query) + ); + } + + filterTags(event: { query: string }) { + const query = event.query.toLowerCase(); + this.filteredTags = this.allTags.filter((tag) => + tag.toLowerCase().includes(query) + ); + } + constructor() { this.metadataForm = new FormGroup({ title: new FormControl(""), subtitle: new FormControl(""), authors: new FormControl(""), categories: new FormControl(""), + moods: new FormControl(""), + tags: new FormControl(""), publisher: new FormControl(""), publishedDate: new FormControl(""), isbn10: new FormControl(""), @@ -150,6 +147,8 @@ export class MetadataEditorComponent implements OnInit { subtitleLocked: new FormControl(false), authorsLocked: new FormControl(false), categoriesLocked: new FormControl(false), + moodsLocked: new FormControl(false), + tagsLocked: new FormControl(false), publisherLocked: new FormControl(false), publishedDateLocked: new FormControl(false), isbn10Locked: new FormControl(false), @@ -197,16 +196,22 @@ export class MetadataEditorComponent implements OnInit { .subscribe((bookState) => { const authors = new Set(); const categories = new Set(); + const moods = new Set(); + const tags = new Set(); (bookState.books ?? []).forEach((book) => { book.metadata?.authors?.forEach((author) => authors.add(author)); book.metadata?.categories?.forEach((category) => categories.add(category) ); + book.metadata?.moods?.forEach((mood) => moods.add(mood)); + book.metadata?.tags?.forEach((tag) => tags.add(tag)); }); this.allAuthors = Array.from(authors); this.allCategories = Array.from(categories); + this.allMoods = Array.from(moods); + this.allTags = Array.from(tags); }); } @@ -216,6 +221,8 @@ export class MetadataEditorComponent implements OnInit { subtitle: metadata.subtitle ?? null, authors: [...(metadata.authors ?? [])].sort(), categories: [...(metadata.categories ?? [])].sort(), + moods: [...(metadata.moods ?? [])].sort(), + tags: [...(metadata.tags ?? [])].sort(), publisher: metadata.publisher ?? null, publishedDate: metadata.publishedDate ?? null, isbn10: metadata.isbn10 ?? null, @@ -244,6 +251,8 @@ export class MetadataEditorComponent implements OnInit { subtitleLocked: metadata.subtitleLocked ?? false, authorsLocked: metadata.authorsLocked ?? false, categoriesLocked: metadata.categoriesLocked ?? false, + moodsLocked: metadata.moodsLocked ?? false, + tagsLocked: metadata.tagsLocked ?? false, publisherLocked: metadata.publisherLocked ?? false, publishedDateLocked: metadata.publishedDateLocked ?? false, isbn10Locked: metadata.isbn10Locked ?? false, @@ -274,6 +283,8 @@ export class MetadataEditorComponent implements OnInit { {key: "subtitleLocked", control: "subtitle"}, {key: "authorsLocked", control: "authors"}, {key: "categoriesLocked", control: "categories"}, + {key: "moodsLocked", control: "moods"}, + {key: "tagsLocked", control: "tags"}, {key: "publisherLocked", control: "publisher"}, {key: "publishedDateLocked", control: "publishedDate"}, {key: "languageLocked", control: "language"}, @@ -410,6 +421,8 @@ export class MetadataEditorComponent implements OnInit { subtitle: form.get("subtitle")?.value, authors: form.get("authors")?.value ?? [], categories: form.get("categories")?.value ?? [], + moods: form.get("moods")?.value ?? [], + tags: form.get("tags")?.value ?? [], publisher: form.get("publisher")?.value, publishedDate: form.get("publishedDate")?.value, isbn10: form.get("isbn10")?.value, @@ -441,6 +454,8 @@ export class MetadataEditorComponent implements OnInit { subtitleLocked: form.get("subtitleLocked")?.value, authorsLocked: form.get("authorsLocked")?.value, categoriesLocked: form.get("categoriesLocked")?.value, + moodsLocked: form.get("moodsLocked")?.value, + tagsLocked: form.get("tagsLocked")?.value, publisherLocked: form.get("publisherLocked")?.value, publishedDateLocked: form.get("publishedDateLocked")?.value, isbn10Locked: form.get("isbn10Locked")?.value, @@ -487,6 +502,8 @@ export class MetadataEditorComponent implements OnInit { subtitle: wasCleared("subtitle"), authors: wasCleared("authors"), categories: wasCleared("categories"), + moods: wasCleared("moods"), + tags: wasCleared("tags"), publisher: wasCleared("publisher"), publishedDate: wasCleared("publishedDate"), isbn10: wasCleared("isbn10"), @@ -602,62 +619,6 @@ export class MetadataEditorComponent implements OnInit { }); } - // restoreCbxMetadata() { - // this.isLoading = true; - // this.bookService.getComicInfoMetadata(this.currentBookId).subscribe(); - // setTimeout(() => { - // this.isLoading = false; - // // this.refreshingBookIds.delete(bookId); - // }, 10000); - // } - restoreCbxMetadata() { - this.isLoading = true; - console.log("LOADING CBX METADATA FOR BOOK ID:", this.currentBookId); - this.bookService.getComicInfoMetadata(this.currentBookId).subscribe({ - next: (metadata) => { - console.log("Retrieved ComicInfo.xml metadata:", metadata); - - if (metadata) { - this.originalMetadata = structuredClone(metadata); - this.populateFormFromMetadata(metadata); - this.messageService.add({ - severity: "success", - summary: "Restored", - detail: "Metadata loaded from ComicInfo.xml", - }); - } else { - this.messageService.add({ - severity: "warn", - summary: "No Data", - detail: "ComicInfo.xml not found or empty.", - }); - } - this.isLoading = false; - }, - error: (err) => { - console.error("Error loading ComicInfo.xml metadata:", err); - console.error(err.message); - this.isLoading = false; - this.messageService.add({ - severity: "error", - summary: "Error", - detail: err?.error?.message || "Failed to load ComicInfo.xml", - }); - }, - }); - } - - restoreMetadata() { - this.dialogService.open(MetadataRestoreDialogComponent, { - header: "Restore Metadata from Backup", - modal: true, - closable: true, - data: { - bookId: [this.currentBookId], - }, - }); - } - autoFetch(bookId: number) { this.refreshingBookIds.add(bookId); this.isAutoFetching = true; diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-picker/metadata-picker.component.ts b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-picker/metadata-picker.component.ts index 709d11e7b..19343aa3a 100644 --- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-picker/metadata-picker.component.ts +++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-picker/metadata-picker.component.ts @@ -49,7 +49,9 @@ export class MetadataPickerComponent implements OnInit { metadataChips = [ {label: 'Authors', controlName: 'authors', lockedKey: 'authorsLocked', fetchedKey: 'authors'}, - {label: 'Categories', controlName: 'categories', lockedKey: 'categoriesLocked', fetchedKey: 'categories'} + {label: 'Categories', controlName: 'categories', lockedKey: 'categoriesLocked', fetchedKey: 'categories'}, + {label: 'Moods', controlName: 'moods', lockedKey: 'moodsLocked', fetchedKey: 'moods'}, + {label: 'Tags', controlName: 'tags', lockedKey: 'tagsLocked', fetchedKey: 'tags'}, ]; metadataDescription = [ @@ -84,11 +86,19 @@ export class MetadataPickerComponent implements OnInit { allAuthors!: string[]; allCategories!: string[]; + allMoods!: string[]; + allTags!: string[]; filteredCategories: string[] = []; filteredAuthors: string[] = []; + filteredMoods: string[] = []; + filteredTags: string[] = []; getFiltered(controlName: string): string[] { - return controlName === 'authors' ? this.filteredAuthors : this.filteredCategories; + if (controlName === 'authors') return this.filteredAuthors; + if (controlName === 'categories') return this.filteredCategories; + if (controlName === 'moods') return this.filteredMoods; + if (controlName === 'tags') return this.filteredTags; + return []; } filterItems(event: { query: string }, controlName: string) { @@ -97,6 +107,10 @@ export class MetadataPickerComponent implements OnInit { this.filteredAuthors = this.allAuthors.filter(a => a.toLowerCase().includes(query)); } else if (controlName === 'categories') { this.filteredCategories = this.allCategories.filter(c => c.toLowerCase().includes(query)); + } else if (controlName === 'moods') { + this.filteredMoods = this.allMoods.filter(m => m.toLowerCase().includes(query)); + } else if (controlName === 'tags') { + this.filteredTags = this.allTags.filter(t => t.toLowerCase().includes(query)); } } @@ -118,6 +132,8 @@ export class MetadataPickerComponent implements OnInit { subtitle: new FormControl(''), authors: new FormControl(''), categories: new FormControl(''), + moods: new FormControl(''), + tags: new FormControl(''), publisher: new FormControl(''), publishedDate: new FormControl(''), isbn10: new FormControl(''), @@ -145,6 +161,8 @@ export class MetadataPickerComponent implements OnInit { subtitleLocked: new FormControl(false), authorsLocked: new FormControl(false), categoriesLocked: new FormControl(false), + moodsLocked: new FormControl(false), + tagsLocked: new FormControl(false), publisherLocked: new FormControl(false), publishedDateLocked: new FormControl(false), isbn10Locked: new FormControl(false), @@ -181,14 +199,20 @@ export class MetadataPickerComponent implements OnInit { .subscribe(bookState => { const authors = new Set(); const categories = new Set(); + const moods = new Set(); + const tags = new Set(); (bookState.books ?? []).forEach(book => { book.metadata?.authors?.forEach(author => authors.add(author)); book.metadata?.categories?.forEach(category => categories.add(category)); + book.metadata?.moods?.forEach(mood => moods.add(mood)); + book.metadata?.tags?.forEach(tag => tags.add(tag)); }); this.allAuthors = Array.from(authors); this.allCategories = Array.from(categories); + this.allMoods = Array.from(moods); + this.allTags = Array.from(tags); }); this.book$ @@ -214,6 +238,8 @@ export class MetadataPickerComponent implements OnInit { subtitle: metadata.subtitle || null, authors: [...(metadata.authors ?? [])].sort(), categories: [...(metadata.categories ?? [])].sort(), + moods: [...(metadata.moods ?? [])].sort(), + tags: [...(metadata.tags ?? [])].sort(), publisher: metadata.publisher || null, publishedDate: metadata.publishedDate || null, isbn10: metadata.isbn10 || null, @@ -241,6 +267,8 @@ export class MetadataPickerComponent implements OnInit { subtitleLocked: metadata.subtitleLocked || false, authorsLocked: metadata.authorsLocked || false, categoriesLocked: metadata.categoriesLocked || false, + moodsLocked: metadata.moodsLocked || false, + tagsLocked: metadata.tagsLocked || false, publisherLocked: metadata.publisherLocked || false, publishedDateLocked: metadata.publishedDateLocked || false, isbn10Locked: metadata.isbn10Locked || false, @@ -277,6 +305,8 @@ export class MetadataPickerComponent implements OnInit { if (metadata.subtitleLocked) this.metadataForm.get('subtitle')?.disable({emitEvent: false}); if (metadata.authorsLocked) this.metadataForm.get('authors')?.disable({emitEvent: false}); if (metadata.categoriesLocked) this.metadataForm.get('categories')?.disable({emitEvent: false}); + if (metadata.moodsLocked) this.metadataForm.get('moods')?.disable({emitEvent: false}); + if (metadata.tagsLocked) this.metadataForm.get('tags')?.disable({emitEvent: false}); if (metadata.publisherLocked) this.metadataForm.get('publisher')?.disable({emitEvent: false}); if (metadata.publishedDateLocked) this.metadataForm.get('publishedDate')?.disable({emitEvent: false}); if (metadata.languageLocked) this.metadataForm.get('language')?.disable({emitEvent: false}); @@ -353,6 +383,8 @@ export class MetadataPickerComponent implements OnInit { subtitle: this.metadataForm.get('subtitle')?.value || this.copiedFields['subtitle'] ? this.getValueOrCopied('subtitle') : '', authors: this.metadataForm.get('authors')?.value || this.copiedFields['authors'] ? this.getArrayFromFormField('authors', this.fetchedMetadata.authors) : [], categories: this.metadataForm.get('categories')?.value || this.copiedFields['categories'] ? this.getArrayFromFormField('categories', this.fetchedMetadata.categories) : [], + moods: this.metadataForm.get('moods')?.value || this.copiedFields['moods'] ? this.getArrayFromFormField('moods', this.fetchedMetadata.moods) : [], + tags: this.metadataForm.get('tags')?.value || this.copiedFields['tags'] ? this.getArrayFromFormField('tags', this.fetchedMetadata.tags) : [], publisher: this.metadataForm.get('publisher')?.value || this.copiedFields['publisher'] ? this.getValueOrCopied('publisher') : '', publishedDate: this.metadataForm.get('publishedDate')?.value || this.copiedFields['publishedDate'] ? this.getValueOrCopied('publishedDate') : '', isbn10: this.metadataForm.get('isbn10')?.value || this.copiedFields['isbn10'] ? this.getValueOrCopied('isbn10') : '', @@ -381,6 +413,8 @@ export class MetadataPickerComponent implements OnInit { subtitleLocked: this.metadataForm.get('subtitleLocked')?.value, authorsLocked: this.metadataForm.get('authorsLocked')?.value, categoriesLocked: this.metadataForm.get('categoriesLocked')?.value, + moodsLocked: this.metadataForm.get('moodsLocked')?.value, + tagsLocked: this.metadataForm.get('tagsLocked')?.value, publisherLocked: this.metadataForm.get('publisherLocked')?.value, publishedDateLocked: this.metadataForm.get('publishedDateLocked')?.value, isbn10Locked: this.metadataForm.get('isbn10Locked')?.value, @@ -422,6 +456,8 @@ export class MetadataPickerComponent implements OnInit { subtitle: !current.subtitle && !!original.subtitle, authors: current.authors?.length === 0 && original.authors?.length! > 0, categories: current.categories?.length === 0 && original.categories?.length! > 0, + moods: current.moods?.length === 0 && original.moods?.length! > 0, + tags: current.tags?.length === 0 && original.tags?.length! > 0, publisher: !current.publisher && !!original.publisher, publishedDate: !current.publishedDate && !!original.publishedDate, isbn10: !current.isbn10 && !!original.isbn10, diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html index c9cfcc484..ee1c7e18f 100644 --- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html +++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html @@ -216,18 +216,52 @@
- - @if (book?.metadata?.categories?.length) { -
-
- @for (category of book.metadata!.categories; track category) { - - - - } +
+ @if (book?.metadata?.categories?.length) { +
+

Genres:

+
+
+ @for (category of book.metadata!.categories; track category) { + + {{ category }} + + } +
+
-
- } + } + + @if (book?.metadata?.moods?.length) { +
+

Moods:

+
+
+ @for (mood of book.metadata!.moods; track mood) { + + {{ mood }} + + } +
+
+
+ } + + @if (book?.metadata?.tags?.length) { +
+

Tags:

+
+
+ @for (tag of book.metadata!.tags; track tag) { + + {{ tag }} + + } +
+
+
+ } +
@@ -251,20 +285,16 @@

Language: {{ book?.metadata!.language || '-' }}

File Type: - - {{ getFileExtension(book?.filePath) || '-' }} - + + {{ (getFileExtension(book?.filePath) | uppercase) || '-' }} +

BookLore Progress: - + {{ getProgressPercent(book) !== null ? getProgressPercent(book) + '%' : 'N/A' }} - + @if (getProgressPercent(book) !== null) { Metadata Match: @if (book?.metadataMatchScore != null) { - + {{ (book?.metadataMatchScore!) | number:'1.0-0' }}% - + } @else { - } @@ -305,23 +333,22 @@

Read Status: - + {{ getStatusLabel(selectedReadStatus) }} - + + +

@if (book?.koreaderProgress && book.koreaderProgress?.percentage != null) {

KOReader Progress: - - {{ getKOReaderPercentage(book) + '%' }} - + + {{ getKOReaderPercentage(book) + '%' }} + @if (getKOReaderPercentage(book) !== null) { ; @@ -551,6 +551,14 @@ export class MetadataViewerComponent implements OnInit, OnChanges { this.handleMetadataClick('category', category); } + goToMood(mood: string): void { + this.handleMetadataClick('mood', mood); + } + + goToTag(tag: string): void { + this.handleMetadataClick('tag', tag); + } + goToSeries(seriesName: string): void { const encodedSeriesName = encodeURIComponent(seriesName); this.router.navigate(['/series', encodedSeriesName]); @@ -636,21 +644,21 @@ export class MetadataViewerComponent implements OnInit, OnChanges { } } - getFileTypeColorClass(fileType: string | null | undefined): string { - if (!fileType) return 'bg-gray-600 text-white'; + getFileTypeColor(fileType: string | null | undefined): TagColor { + if (!fileType) return 'gray'; switch (fileType.toLowerCase()) { case 'pdf': - return 'bg-pink-700 text-white'; + return 'pink'; case 'epub': - return 'bg-indigo-600 text-white'; + return 'indigo'; case 'cbz': - return 'bg-teal-600 text-white'; + return 'teal'; case 'cbr': - return 'bg-purple-700 text-white'; + return 'purple'; case 'cb7': - return 'bg-blue-700 text-white'; + return 'blue'; default: - return 'bg-gray-600 text-white'; + return 'gray'; } } @@ -672,50 +680,51 @@ export class MetadataViewerComponent implements OnInit, OnChanges { } } - getMatchScoreColorClass(score: number): string { - if (score >= 0.95) return 'bg-green-800 border-green-900'; - if (score >= 0.90) return 'bg-green-700 border-green-800'; - if (score >= 0.80) return 'bg-green-600 border-green-700'; - if (score >= 0.70) return 'bg-yellow-600 border-yellow-700'; - if (score >= 0.60) return 'bg-yellow-500 border-yellow-600'; - if (score >= 0.50) return 'bg-yellow-400 border-yellow-500'; - if (score >= 0.40) return 'bg-red-400 border-red-500'; - if (score >= 0.30) return 'bg-red-500 border-red-600'; - return 'bg-red-600 border-red-700'; + + getMatchScoreColor(score: number): TagColor { + if (score >= 0.95) return 'emerald'; + if (score >= 0.90) return 'green'; + if (score >= 0.80) return 'lime'; + if (score >= 0.70) return 'yellow'; + if (score >= 0.60) return 'amber'; + if (score >= 0.50) return 'orange'; + if (score >= 0.40) return 'red'; + if (score >= 0.30) return 'rose'; + return 'pink'; } - getStatusSeverityClass(status: string): string { - const normalized = status?.toUpperCase(); + getStatusColor(status: string | null | undefined): TagColor { + const normalized = status?.toUpperCase() ?? ''; switch (normalized) { case 'UNREAD': - return 'bg-gray-500'; + return 'gray'; case 'PAUSED': - return 'bg-zinc-600'; + return 'zinc'; case 'READING': - return 'bg-blue-600'; + return 'blue'; case 'RE_READING': - return 'bg-indigo-600'; + return 'indigo'; case 'READ': - return 'bg-green-600'; + return 'green'; case 'PARTIALLY_READ': - return 'bg-yellow-600'; + return 'yellow'; case 'ABANDONED': - return 'bg-red-600'; + return 'red'; case 'WONT_READ': - return 'bg-pink-700'; + return 'pink'; default: - return 'bg-gray-600'; + return 'gray'; } } - getProgressColorClass(progress: number | null | undefined): string { - if (progress == null) return 'bg-gray-600'; - return 'bg-blue-500'; + getProgressColor(progress: number | null | undefined): TagColor { + if (progress == null) return 'gray'; + return 'blue'; } - getKoProgressColorClass(progress: number | null | undefined): string { - if (progress == null) return 'bg-gray-600'; - return 'bg-amber-500'; + getKoProgressColor(progress: number | null | undefined): TagColor { + if (progress == null) return 'gray'; + return 'amber'; } getKOReaderPercentage(book: Book): number | null { diff --git a/booklore-ui/src/app/shared/components/tag/tag.component.scss b/booklore-ui/src/app/shared/components/tag/tag.component.scss new file mode 100644 index 000000000..01be5abc3 --- /dev/null +++ b/booklore-ui/src/app/shared/components/tag/tag.component.scss @@ -0,0 +1,190 @@ +.app-tag { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + font-weight: 700; + line-height: 1.25rem; + border-radius: 0.375rem; + white-space: nowrap; + + &-5xs { + padding: 0.125rem 0.375rem; + font-size: 0.625rem; + line-height: 0.875rem; + gap: 0.25rem; + } + + &-4xs { + padding: 0.15625rem 0.40625rem; + font-size: 0.6875rem; + line-height: 0.9375rem; + gap: 0.28125rem; + } + + &-3xs { + padding: 0.1875rem 0.4375rem; + font-size: 0.75rem; + line-height: 1rem; + gap: 0.3125rem; + } + + &-2xs { + padding: 0.203125rem 0.453125rem; + font-size: 0.78125rem; + line-height: 1.0625rem; + gap: 0.328125rem; + } + + &-xs { + padding: 0.21875rem 0.46875rem; + font-size: 0.8125rem; + line-height: 1.125rem; + gap: 0.34375rem; + } + + &-s { + padding: 0.234375rem 0.484375rem; + font-size: 0.84375rem; + line-height: 1.1875rem; + gap: 0.359375rem; + } + + &-m { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + line-height: 1.25rem; + gap: 0.375rem; + } + + &-l { + padding: 0.265625rem 0.515625rem; + font-size: 0.90625rem; + line-height: 1.3125rem; + gap: 0.390625rem; + } + + &-xl { + padding: 0.28125rem 0.53125rem; + font-size: 0.9375rem; + line-height: 1.375rem; + gap: 0.40625rem; + } + + &-2xl { + padding: 0.296875rem 0.546875rem; + font-size: 0.96875rem; + line-height: 1.4375rem; + gap: 0.421875rem; + } + + &-3xl { + padding: 0.3125rem 0.5625rem; + font-size: 1rem; + line-height: 1.5rem; + gap: 0.4375rem; + } + + &-4xl { + padding: 0.34375rem 0.59375rem; + font-size: 1.0625rem; + line-height: 1.5625rem; + gap: 0.46875rem; + } + + &-5xl { + padding: 0.375rem 0.625rem; + font-size: 1.125rem; + line-height: 1.625rem; + gap: 0.5rem; + } + + &-rounded { + border-radius: 0.5rem; + } + + &-pill { + border-radius: 9999px; + } + + &-primary { + background-color: color-mix(in srgb, var(--p-primary-500), transparent 84%); + color: var(--p-primary-200); + } + + $colors: ( + 'secondary': 'slate', + 'success': 'green', + 'info': 'sky', + 'warning': 'orange', + 'danger': 'red', + 'blue': 'blue', + 'indigo': 'indigo', + 'purple': 'purple', + 'pink': 'pink', + 'red': 'red', + 'orange': 'orange', + 'yellow': 'yellow', + 'green': 'green', + 'teal': 'teal', + 'cyan': 'cyan', + 'gray': 'gray', + 'slate': 'slate', + 'zinc': 'zinc', + 'neutral': 'neutral', + 'stone': 'stone', + 'amber': 'amber', + 'lime': 'lime', + 'emerald': 'emerald', + 'sky': 'sky', + 'violet': 'violet', + 'fuchsia': 'fuchsia', + 'rose': 'rose' + ); + + @each $name, $color in $colors { + &-#{$name} { + background-color: color-mix(in srgb, var(--p-#{$color}-500), transparent 84%); + color: var(--p-#{$color}-200); + } + } + + &-dark { + background-color: color-mix(in srgb, var(--p-gray-800), transparent 84%); + color: var(--p-gray-300); + } + + &-light { + background-color: color-mix(in srgb, var(--p-gray-300), transparent 84%); + color: var(--p-gray-600); + } + + &-variant-pill { + border-radius: 9999px; + padding: 0.125rem 0.375rem; + + &.app-tag-primary { + background-color: var(--p-primary-600); + color: var(--p-primary-100); + } + + @each $name, $color in $colors { + &.app-tag-#{$name} { + background-color: var(--p-#{$color}-600); + color: var(--p-#{$color}-100); + } + } + + &.app-tag-dark { + background-color: var(--p-gray-800); + color: var(--p-gray-100); + } + + &.app-tag-light { + background-color: var(--p-gray-300); + color: var(--p-gray-800); + } + } +} diff --git a/booklore-ui/src/app/shared/components/tag/tag.component.ts b/booklore-ui/src/app/shared/components/tag/tag.component.ts new file mode 100644 index 000000000..16380e7a6 --- /dev/null +++ b/booklore-ui/src/app/shared/components/tag/tag.component.ts @@ -0,0 +1,44 @@ +import {Component, input, computed} from '@angular/core'; + +export type TagColor = + | 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'danger' + | 'blue' | 'indigo' | 'purple' | 'pink' | 'red' | 'orange' | 'yellow' + | 'green' | 'teal' | 'cyan' | 'gray' | 'slate' | 'zinc' | 'neutral' + | 'stone' | 'amber' | 'lime' | 'emerald' | 'sky' | 'violet' | 'fuchsia' + | 'rose' | 'dark' | 'light'; + +export type TagSize = '5xs' | '4xs' | '3xs' | '2xs' | 'xs' | 's' | 'm' | 'l' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl'; + +export type TagVariant = 'label' | 'pill'; + +@Component({ + selector: 'app-tag', + standalone: true, + template: ` + + + + `, + styleUrls: ['./tag.component.scss'] +}) +export class TagComponent { + color = input('primary'); + size = input('m'); + variant = input('label'); + rounded = input(false); + pill = input(false); + customBgColor = input(); + customTextColor = input(); + + protected tagClasses = computed(() => { + const classes = ['app-tag', `app-tag-${this.color()}`, `app-tag-${this.size()}`]; + if (this.variant() === 'pill') classes.push('app-tag-variant-pill'); + if (this.rounded()) classes.push('app-tag-rounded'); + if (this.pill()) classes.push('app-tag-pill'); + return classes.join(' '); + }); +} From ff230f34fcef944ee95d1d9cef5e8c24947dc77e Mon Sep 17 00:00:00 2001 From: Aditya Chandel <8075870+adityachandelgit@users.noreply.github.com> Date: Sun, 5 Oct 2025 00:15:11 -0600 Subject: [PATCH 02/11] Enhance metadata options to allow skipping fields during fetch and support extended field options (#1263) --- .../dto/request/MetadataRefreshOptions.java | 68 +++- .../appsettings/SettingPersistenceHelper.java | 136 ++++--- .../metadata/MetadataRefreshService.java | 350 +++++++++--------- .../metadata/MetadataRefreshServiceTest.java | 178 ++++++--- .../metadata-editor.component.html | 94 +++-- ...data-advanced-fetch-options.component.html | 87 +++-- ...tadata-advanced-fetch-options.component.ts | 160 ++++++-- .../request/metadata-refresh-options.model.ts | 19 +- .../library-metadata-settings.component.ts | 21 +- 9 files changed, 689 insertions(+), 424 deletions(-) diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/MetadataRefreshOptions.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/MetadataRefreshOptions.java index f7293e0f1..a58084e16 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/MetadataRefreshOptions.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/MetadataRefreshOptions.java @@ -6,27 +6,28 @@ import lombok.Getter; import lombok.Setter; import lombok.NoArgsConstructor; import lombok.AllArgsConstructor; +import lombok.Builder; @Getter @Setter @NoArgsConstructor @AllArgsConstructor +@Builder public class MetadataRefreshOptions { private Long libraryId; - @NotNull(message = "Default Provider cannot be null") - private MetadataProvider allP1; - private MetadataProvider allP2; - private MetadataProvider allP3; - private MetadataProvider allP4; private boolean refreshCovers; private boolean mergeCategories; private Boolean reviewBeforeApply; + @NotNull(message = "Field options cannot be null") private FieldOptions fieldOptions; + @NotNull(message = "Skip fields cannot be null") + private SkipFields skipFields; @Getter @Setter @NoArgsConstructor @AllArgsConstructor + @Builder public static class FieldOptions { private FieldProvider title; private FieldProvider subtitle; @@ -41,19 +42,68 @@ public class MetadataRefreshOptions { private FieldProvider isbn10; private FieldProvider language; private FieldProvider categories; + private FieldProvider cover; + private FieldProvider pageCount; + private FieldProvider asin; + private FieldProvider goodreadsId; + private FieldProvider comicvineId; + private FieldProvider hardcoverId; + private FieldProvider googleId; + private FieldProvider amazonRating; + private FieldProvider amazonReviewCount; + private FieldProvider goodreadsRating; + private FieldProvider goodreadsReviewCount; + private FieldProvider hardcoverRating; + private FieldProvider hardcoverReviewCount; private FieldProvider moods; private FieldProvider tags; - private FieldProvider cover; } @Getter @Setter @NoArgsConstructor @AllArgsConstructor + @Builder public static class FieldProvider { - private MetadataProvider p4; - private MetadataProvider p3; - private MetadataProvider p2; private MetadataProvider p1; + private MetadataProvider p2; + private MetadataProvider p3; + private MetadataProvider p4; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class SkipFields { + private boolean title; + private boolean subtitle; + private boolean description; + private boolean authors; + private boolean publisher; + private boolean publishedDate; + private boolean seriesName; + private boolean seriesNumber; + private boolean seriesTotal; + private boolean isbn13; + private boolean isbn10; + private boolean language; + private boolean categories; + private boolean cover; + private boolean pageCount; + private boolean asin; + private boolean goodreadsId; + private boolean comicvineId; + private boolean hardcoverId; + private boolean googleId; + private boolean amazonRating; + private boolean amazonReviewCount; + private boolean goodreadsRating; + private boolean goodreadsReviewCount; + private boolean hardcoverRating; + private boolean hardcoverReviewCount; + private boolean moods; + private boolean tags; } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java index 2f7fc5441..d6c56d31a 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java @@ -110,69 +110,83 @@ public class SettingPersistenceHelper { } MetadataRefreshOptions getDefaultMetadataRefreshOptions() { - MetadataRefreshOptions.FieldProvider titleProviders = - new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads); - MetadataRefreshOptions.FieldProvider subtitleProviders = - new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads); - MetadataRefreshOptions.FieldProvider descriptionProviders = - new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads); - MetadataRefreshOptions.FieldProvider authorsProviders = - new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads); - MetadataRefreshOptions.FieldProvider publisherProviders = - new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads); - MetadataRefreshOptions.FieldProvider publishedDateProviders = - new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads); - MetadataRefreshOptions.FieldProvider seriesNameProviders = - new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads); - MetadataRefreshOptions.FieldProvider seriesNumberProviders = - new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads); - MetadataRefreshOptions.FieldProvider seriesTotalProviders = - new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads); - MetadataRefreshOptions.FieldProvider isbn13Providers = - new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads); - MetadataRefreshOptions.FieldProvider isbn10Providers = - new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads); - MetadataRefreshOptions.FieldProvider languageProviders = - new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads); - MetadataRefreshOptions.FieldProvider categoriesProviders = - new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads); - MetadataRefreshOptions.FieldProvider moodsProviders = - new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads); - MetadataRefreshOptions.FieldProvider tagsProviders = - new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads); - MetadataRefreshOptions.FieldProvider coverProviders = - new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads); + MetadataRefreshOptions.FieldProvider amazonProvider = MetadataRefreshOptions.FieldProvider.builder() + .p1(MetadataProvider.Amazon) + .build(); - MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions( - titleProviders, - subtitleProviders, - descriptionProviders, - authorsProviders, - publisherProviders, - publishedDateProviders, - seriesNameProviders, - seriesNumberProviders, - seriesTotalProviders, - isbn13Providers, - isbn10Providers, - languageProviders, - categoriesProviders, - moodsProviders, - tagsProviders, - coverProviders - ); + MetadataRefreshOptions.FieldProvider nullProvider = MetadataRefreshOptions.FieldProvider.builder() + .build(); - return new MetadataRefreshOptions( - null, - MetadataProvider.GoodReads, - MetadataProvider.Amazon, - MetadataProvider.Google, - null, - false, - true, - false, - fieldOptions - ); + MetadataRefreshOptions.FieldOptions fieldOptions = MetadataRefreshOptions.FieldOptions.builder() + .title(amazonProvider) + .subtitle(amazonProvider) + .description(amazonProvider) + .authors(amazonProvider) + .publisher(amazonProvider) + .publishedDate(amazonProvider) + .seriesName(amazonProvider) + .seriesNumber(amazonProvider) + .seriesTotal(amazonProvider) + .isbn13(amazonProvider) + .isbn10(amazonProvider) + .language(amazonProvider) + .categories(amazonProvider) + .cover(amazonProvider) + .pageCount(amazonProvider) + .asin(nullProvider) + .goodreadsId(nullProvider) + .comicvineId(nullProvider) + .hardcoverId(nullProvider) + .googleId(nullProvider) + .amazonRating(nullProvider) + .amazonReviewCount(nullProvider) + .goodreadsRating(nullProvider) + .goodreadsReviewCount(nullProvider) + .hardcoverRating(nullProvider) + .hardcoverReviewCount(nullProvider) + .moods(nullProvider) + .tags(nullProvider) + .build(); + + MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder() + .title(false) + .subtitle(false) + .description(false) + .authors(false) + .publisher(false) + .publishedDate(false) + .seriesName(false) + .seriesNumber(false) + .seriesTotal(false) + .isbn13(false) + .isbn10(false) + .language(false) + .categories(false) + .cover(false) + .pageCount(false) + .asin(false) + .goodreadsId(false) + .comicvineId(false) + .hardcoverId(false) + .googleId(false) + .amazonRating(false) + .amazonReviewCount(false) + .goodreadsRating(false) + .goodreadsReviewCount(false) + .hardcoverRating(false) + .hardcoverReviewCount(false) + .moods(false) + .tags(false) + .build(); + + return MetadataRefreshOptions.builder() + .libraryId(null) + .refreshCovers(false) + .mergeCategories(true) + .reviewBeforeApply(false) + .fieldOptions(fieldOptions) + .skipFields(skipFields) + .build(); } public MetadataMatchWeights getDefaultMetadataMatchWeights() { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java index addfd019e..808c66f12 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java @@ -32,6 +32,9 @@ import java.time.Instant; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; import java.util.stream.Collectors; import static com.adityachandel.booklore.model.enums.MetadataProvider.*; @@ -213,7 +216,7 @@ public class MetadataRefreshService { )); } - private CompletableFuture createInterruptibleMetadataFuture(java.util.function.Supplier metadataSupplier) { + private CompletableFuture createInterruptibleMetadataFuture(Supplier metadataSupplier) { return CompletableFuture.supplyAsync(() -> { if (Thread.currentThread().isInterrupted()) { log.info("Skipping metadata fetch due to interruption"); @@ -323,12 +326,33 @@ public class MetadataRefreshService { 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.getCover(), uniqueProviders); } return uniqueProviders; @@ -343,7 +367,6 @@ public class MetadataRefreshService { } } - public BookMetadata fetchTopMetadataFromAProvider(MetadataProvider provider, Book book) { return getParser(provider).fetchTopMetadata(book, buildFetchMetadataRequestFromBook(book)); } @@ -370,10 +393,122 @@ public class MetadataRefreshService { public BookMetadata buildFetchMetadata(Long bookId, MetadataRefreshOptions refreshOptions, Map metadataMap) { BookMetadata metadata = BookMetadata.builder().bookId(bookId).build(); MetadataRefreshOptions.FieldOptions fieldOptions = refreshOptions.getFieldOptions(); + MetadataRefreshOptions.SkipFields skipFields = refreshOptions.getSkipFields(); - metadata.setTitle(resolveFieldAsString(metadataMap, fieldOptions.getTitle(), BookMetadata::getTitle)); - metadata.setDescription(resolveFieldAsString(metadataMap, fieldOptions.getDescription(), BookMetadata::getDescription)); - metadata.setAuthors(resolveFieldAsList(metadataMap, fieldOptions.getAuthors(), BookMetadata::getAuthors)); + if (!skipFields.isTitle()) { + metadata.setTitle(resolveFieldAsString(metadataMap, fieldOptions.getTitle(), BookMetadata::getTitle)); + } + if (!skipFields.isSubtitle()) { + metadata.setSubtitle(resolveFieldAsString(metadataMap, fieldOptions.getSubtitle(), BookMetadata::getSubtitle)); + } + if (!skipFields.isDescription()) { + metadata.setDescription(resolveFieldAsString(metadataMap, fieldOptions.getDescription(), BookMetadata::getDescription)); + } + if (!skipFields.isAuthors()) { + metadata.setAuthors(resolveFieldAsList(metadataMap, fieldOptions.getAuthors(), BookMetadata::getAuthors)); + } + if (!skipFields.isPublisher()) { + metadata.setPublisher(resolveFieldAsString(metadataMap, fieldOptions.getPublisher(), BookMetadata::getPublisher)); + } + if (!skipFields.isPublishedDate()) { + metadata.setPublishedDate(resolveField(metadataMap, fieldOptions.getPublishedDate(), BookMetadata::getPublishedDate)); + } + if (!skipFields.isSeriesName()) { + metadata.setSeriesName(resolveFieldAsString(metadataMap, fieldOptions.getSeriesName(), BookMetadata::getSeriesName)); + } + if (!skipFields.isSeriesNumber()) { + metadata.setSeriesNumber(resolveField(metadataMap, fieldOptions.getSeriesNumber(), BookMetadata::getSeriesNumber)); + } + if (!skipFields.isSeriesTotal()) { + metadata.setSeriesTotal(resolveFieldAsInteger(metadataMap, fieldOptions.getSeriesTotal(), BookMetadata::getSeriesTotal)); + } + if (!skipFields.isIsbn13()) { + metadata.setIsbn13(resolveFieldAsString(metadataMap, fieldOptions.getIsbn13(), BookMetadata::getIsbn13)); + } + if (!skipFields.isIsbn10()) { + metadata.setIsbn10(resolveFieldAsString(metadataMap, fieldOptions.getIsbn10(), BookMetadata::getIsbn10)); + } + if (!skipFields.isLanguage()) { + metadata.setLanguage(resolveFieldAsString(metadataMap, fieldOptions.getLanguage(), BookMetadata::getLanguage)); + } + if (!skipFields.isPageCount()) { + metadata.setPageCount(resolveFieldAsInteger(metadataMap, fieldOptions.getPageCount(), BookMetadata::getPageCount)); + } + if (!skipFields.isCover()) { + metadata.setThumbnailUrl(resolveFieldAsString(metadataMap, fieldOptions.getCover(), BookMetadata::getThumbnailUrl)); + } + if (!skipFields.isAmazonRating()) { + if (metadataMap.containsKey(Amazon)) { + metadata.setAmazonRating(metadataMap.get(Amazon).getAmazonRating()); + } + } + if (!skipFields.isAmazonReviewCount()) { + if (metadataMap.containsKey(Amazon)) { + metadata.setAmazonReviewCount(metadataMap.get(Amazon).getAmazonReviewCount()); + } + } + if (!skipFields.isGoodreadsRating()) { + if (metadataMap.containsKey(GoodReads)) { + metadata.setGoodreadsRating(metadataMap.get(GoodReads).getGoodreadsRating()); + } + } + if (!skipFields.isGoodreadsReviewCount()) { + if (metadataMap.containsKey(GoodReads)) { + metadata.setGoodreadsReviewCount(metadataMap.get(GoodReads).getGoodreadsReviewCount()); + } + } + if (!skipFields.isHardcoverRating()) { + if (metadataMap.containsKey(Hardcover)) { + metadata.setHardcoverRating(metadataMap.get(Hardcover).getHardcoverRating()); + } + } + if (!skipFields.isHardcoverReviewCount()) { + if (metadataMap.containsKey(Hardcover)) { + metadata.setHardcoverReviewCount(metadataMap.get(Hardcover).getHardcoverReviewCount()); + } + } + if (!skipFields.isAsin()) { + if (metadataMap.containsKey(Amazon)) { + metadata.setAsin(metadataMap.get(Amazon).getAsin()); + } + } + if (!skipFields.isGoodreadsId()) { + if (metadataMap.containsKey(GoodReads)) { + metadata.setGoodreadsId(metadataMap.get(GoodReads).getGoodreadsId()); + } + } + if (!skipFields.isHardcoverId()) { + if (metadataMap.containsKey(Hardcover)) { + metadata.setHardcoverId(metadataMap.get(Hardcover).getHardcoverId()); + } + } + if (!skipFields.isGoogleId()) { + if (metadataMap.containsKey(Google)) { + metadata.setGoogleId(metadataMap.get(Google).getGoogleId()); + } + } + if (!skipFields.isComicvineId()) { + if (metadataMap.containsKey(Comicvine)) { + metadata.setComicvineId(metadataMap.get(Comicvine).getComicvineId()); + } + } + if (!skipFields.isMoods()) { + if (metadataMap.containsKey(Hardcover)) { + metadata.setMoods(metadataMap.get(Hardcover).getMoods()); + } + } + if (!skipFields.isTags()) { + if (metadataMap.containsKey(Hardcover)) { + metadata.setTags(metadataMap.get(Hardcover).getTags()); + } + } + if (!skipFields.isCategories()) { + if (refreshOptions.isMergeCategories()) { + metadata.setCategories(getAllCategories(metadataMap, fieldOptions.getCategories(), BookMetadata::getCategories)); + } else { + metadata.setCategories(resolveFieldAsList(metadataMap, fieldOptions.getCategories(), BookMetadata::getCategories)); + } + } List allReviews = metadataMap.values().stream() .filter(Objects::nonNull) @@ -383,187 +518,70 @@ public class MetadataRefreshService { metadata.setBookReviews(allReviews); } - if (metadataMap.containsKey(GoodReads)) { - metadata.setGoodreadsId(metadataMap.get(GoodReads).getGoodreadsId()); - } - if (metadataMap.containsKey(Hardcover)) { - metadata.setHardcoverId(metadataMap.get(Hardcover).getHardcoverId()); - } - if (metadataMap.containsKey(Google)) { - metadata.setGoogleId(metadataMap.get(Google).getGoogleId()); - } - if (metadataMap.containsKey(Comicvine)) { - metadata.setComicvineId(metadataMap.get(Comicvine).getComicvineId()); - } - - if (refreshOptions.isMergeCategories()) { - metadata.setCategories(getAllCategories(metadataMap, fieldOptions.getCategories(), BookMetadata::getCategories)); - metadata.setMoods(getAllMoods(metadataMap, fieldOptions.getMoods(), BookMetadata::getMoods)); - metadata.setTags(getAllTags(metadataMap, fieldOptions.getTags(), BookMetadata::getTags)); - } else { - metadata.setCategories(resolveFieldAsList(metadataMap, fieldOptions.getCategories(), BookMetadata::getCategories)); - metadata.setMoods(resolveFieldAsList(metadataMap, fieldOptions.getMoods(), BookMetadata::getMoods)); - metadata.setTags(resolveFieldAsList(metadataMap, fieldOptions.getTags(), BookMetadata::getTags)); - } - metadata.setThumbnailUrl(resolveFieldAsString(metadataMap, fieldOptions.getCover(), BookMetadata::getThumbnailUrl)); - - if (refreshOptions.getAllP4() != null) { - setOtherUnspecifiedMetadata(metadataMap, metadata, refreshOptions.getAllP4()); - } - if (refreshOptions.getAllP3() != null) { - setOtherUnspecifiedMetadata(metadataMap, metadata, refreshOptions.getAllP3()); - } - if (refreshOptions.getAllP2() != null) { - setOtherUnspecifiedMetadata(metadataMap, metadata, refreshOptions.getAllP2()); - } - if (refreshOptions.getAllP1() != null) { - setOtherUnspecifiedMetadata(metadataMap, metadata, refreshOptions.getAllP1()); - } - return metadata; } - protected void setOtherUnspecifiedMetadata(Map metadataMap, BookMetadata metadataCombined, MetadataProvider provider) { - if (metadataMap.containsKey(provider)) { - BookMetadata metadata = metadataMap.get(provider); - metadataCombined.setSubtitle(metadata.getSubtitle() != null ? metadata.getSubtitle() : metadataCombined.getSubtitle()); - metadataCombined.setPublisher(metadata.getPublisher() != null ? metadata.getPublisher() : metadataCombined.getPublisher()); - metadataCombined.setPublishedDate(metadata.getPublishedDate() != null ? metadata.getPublishedDate() : metadataCombined.getPublishedDate()); - metadataCombined.setIsbn10(metadata.getIsbn10() != null ? metadata.getIsbn10() : metadataCombined.getIsbn10()); - metadataCombined.setIsbn13(metadata.getIsbn13() != null ? metadata.getIsbn13() : metadataCombined.getIsbn13()); - metadataCombined.setAsin(metadata.getAsin() != null ? metadata.getAsin() : metadataCombined.getAsin()); - metadataCombined.setPageCount(metadata.getPageCount() != null ? metadata.getPageCount() : metadataCombined.getPageCount()); - metadataCombined.setLanguage(metadata.getLanguage() != null ? metadata.getLanguage() : metadataCombined.getLanguage()); - metadataCombined.setGoodreadsRating(metadata.getGoodreadsRating() != null ? metadata.getGoodreadsRating() : metadataCombined.getGoodreadsRating()); - metadataCombined.setGoodreadsReviewCount(metadata.getGoodreadsReviewCount() != null ? metadata.getGoodreadsReviewCount() : metadataCombined.getGoodreadsReviewCount()); - metadataCombined.setAmazonRating(metadata.getAmazonRating() != null ? metadata.getAmazonRating() : metadataCombined.getAmazonRating()); - metadataCombined.setAmazonReviewCount(metadata.getAmazonReviewCount() != null ? metadata.getAmazonReviewCount() : metadataCombined.getAmazonReviewCount()); - metadataCombined.setHardcoverRating(metadata.getHardcoverRating() != null ? metadata.getHardcoverRating() : metadataCombined.getHardcoverRating()); - metadataCombined.setHardcoverReviewCount(metadata.getHardcoverReviewCount() != null ? metadata.getHardcoverReviewCount() : metadataCombined.getHardcoverReviewCount()); - metadataCombined.setPersonalRating(metadata.getPersonalRating() != null ? metadata.getPersonalRating() : metadataCombined.getPersonalRating()); - metadataCombined.setSeriesName(metadata.getSeriesName() != null ? metadata.getSeriesName() : metadataCombined.getSeriesName()); - metadataCombined.setSeriesNumber(metadata.getSeriesNumber() != null ? metadata.getSeriesNumber() : metadataCombined.getSeriesNumber()); - metadataCombined.setSeriesTotal(metadata.getSeriesTotal() != null ? metadata.getSeriesTotal() : metadataCombined.getSeriesTotal()); - } + protected T resolveField(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, Function extractor) { + return resolveFieldWithProviders(metadataMap, fieldProvider, extractor, (value) -> value != null); } - @FunctionalInterface - public interface FieldValueExtractor { - String extract(BookMetadata metadata); + protected Integer resolveFieldAsInteger(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, Function fieldValueExtractor) { + return resolveFieldWithProviders(metadataMap, fieldProvider, fieldValueExtractor, (value) -> value != null); } - @FunctionalInterface - public interface FieldValueExtractorList { - Set extract(BookMetadata metadata); - } - - protected String resolveFieldAsString(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractor fieldValueExtractor) { - String value = null; - if (fieldProvider.getP4() != null && metadataMap.containsKey(fieldProvider.getP4())) { - String newValue = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP4())); - if (newValue != null) value = newValue; - } - if (fieldProvider.getP3() != null && metadataMap.containsKey(fieldProvider.getP3())) { - String newValue = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP3())); - if (newValue != null) value = newValue; - } - if (fieldProvider.getP2() != null && metadataMap.containsKey(fieldProvider.getP2())) { - String newValue = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP2())); - if (newValue != null) value = newValue; - } - if (fieldProvider.getP1() != null && metadataMap.containsKey(fieldProvider.getP1())) { - String newValue = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP1())); - if (newValue != null) value = newValue; - } - return value; + return resolveFieldWithProviders(metadataMap, fieldProvider, fieldValueExtractor::extract, (value) -> value != null); } - protected Set resolveFieldAsList(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractorList fieldValueExtractor) { - Set values = new HashSet<>(); - if (fieldProvider.getP4() != null && metadataMap.containsKey(fieldProvider.getP4())) { - Set newValues = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP4())); - if (newValues != null && !newValues.isEmpty()) values = newValues; + return resolveFieldWithProviders(metadataMap, fieldProvider, fieldValueExtractor::extract, (value) -> value != null && !value.isEmpty()); + } + + private T resolveFieldWithProviders(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, Function extractor, Predicate isValidValue) { + if (fieldProvider == null) { + return null; } - if (fieldProvider.getP3() != null && metadataMap.containsKey(fieldProvider.getP3())) { - Set newValues = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP3())); - if (newValues != null && !newValues.isEmpty()) values = newValues; + MetadataProvider[] providers = { + fieldProvider.getP4(), + fieldProvider.getP3(), + fieldProvider.getP2(), + fieldProvider.getP1() + }; + for (MetadataProvider provider : providers) { + if (provider != null && metadataMap.containsKey(provider)) { + T value = extractor.apply(metadataMap.get(provider)); + if (isValidValue.test(value)) { + return value; + } + } } - if (values.isEmpty() && fieldProvider.getP2() != null && metadataMap.containsKey(fieldProvider.getP2())) { - Set newValues = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP2())); - if (newValues != null && !newValues.isEmpty()) values = newValues; - } - if (values.isEmpty() && fieldProvider.getP1() != null && metadataMap.containsKey(fieldProvider.getP1())) { - Set newValues = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP1())); - if (newValues != null && !newValues.isEmpty()) values = newValues; - } - return values; + return null; } Set getAllCategories(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractorList fieldValueExtractor) { Set uniqueCategories = new HashSet<>(); - if (fieldProvider.getP4() != null && metadataMap.containsKey(fieldProvider.getP4())) { - Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP4())); - if (extracted != null) uniqueCategories.addAll(extracted); + if (fieldProvider == null) { + return uniqueCategories; } - if (fieldProvider.getP3() != null && metadataMap.containsKey(fieldProvider.getP3())) { - Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP3())); - if (extracted != null) uniqueCategories.addAll(extracted); - } - if (fieldProvider.getP2() != null && metadataMap.containsKey(fieldProvider.getP2())) { - Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP2())); - if (extracted != null) uniqueCategories.addAll(extracted); - } - if (fieldProvider.getP1() != null && metadataMap.containsKey(fieldProvider.getP1())) { - Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP1())); - if (extracted != null) uniqueCategories.addAll(extracted); - } - return new HashSet<>(uniqueCategories); - } - Set getAllMoods(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractorList fieldValueExtractor) { - Set uniqueMoods = new HashSet<>(); - if (fieldProvider.getP4() != null && metadataMap.containsKey(fieldProvider.getP4())) { - Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP4())); - if (extracted != null) uniqueMoods.addAll(extracted); - } - if (fieldProvider.getP3() != null && metadataMap.containsKey(fieldProvider.getP3())) { - Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP3())); - if (extracted != null) uniqueMoods.addAll(extracted); - } - if (fieldProvider.getP2() != null && metadataMap.containsKey(fieldProvider.getP2())) { - Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP2())); - if (extracted != null) uniqueMoods.addAll(extracted); - } - if (fieldProvider.getP1() != null && metadataMap.containsKey(fieldProvider.getP1())) { - Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP1())); - if (extracted != null) uniqueMoods.addAll(extracted); - } - return new HashSet<>(uniqueMoods); - } + MetadataProvider[] providers = { + fieldProvider.getP4(), + fieldProvider.getP3(), + fieldProvider.getP2(), + fieldProvider.getP1() + }; - Set getAllTags(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractorList fieldValueExtractor) { - Set uniqueTags = new HashSet<>(); - if (fieldProvider.getP4() != null && metadataMap.containsKey(fieldProvider.getP4())) { - Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP4())); - if (extracted != null) uniqueTags.addAll(extracted); + for (MetadataProvider provider : providers) { + if (provider != null && metadataMap.containsKey(provider)) { + Set extracted = fieldValueExtractor.extract(metadataMap.get(provider)); + if (extracted != null) { + uniqueCategories.addAll(extracted); + } + } } - if (fieldProvider.getP3() != null && metadataMap.containsKey(fieldProvider.getP3())) { - Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP3())); - if (extracted != null) uniqueTags.addAll(extracted); - } - if (fieldProvider.getP2() != null && metadataMap.containsKey(fieldProvider.getP2())) { - Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP2())); - if (extracted != null) uniqueTags.addAll(extracted); - } - if (fieldProvider.getP1() != null && metadataMap.containsKey(fieldProvider.getP1())) { - Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP1())); - if (extracted != null) uniqueTags.addAll(extracted); - } - return new HashSet<>(uniqueTags); - } + return uniqueCategories; + } protected Set getBookEntities(MetadataRefreshRequest request) { MetadataRefreshRequest.RefreshType refreshType = request.getRefreshType(); @@ -578,4 +596,4 @@ public class MetadataRefreshService { case BOOKS -> request.getBookIds(); }; } -} +} \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/MetadataRefreshServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/MetadataRefreshServiceTest.java index 1fce67766..fba06ea77 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/MetadataRefreshServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/MetadataRefreshServiceTest.java @@ -83,41 +83,69 @@ class MetadataRefreshServiceTest { } private void setupDefaultOptions() { - MetadataRefreshOptions.FieldProvider titleProvider = new MetadataRefreshOptions.FieldProvider( - null, null, MetadataProvider.Google, MetadataProvider.GoodReads); - MetadataRefreshOptions.FieldProvider descriptionProvider = new MetadataRefreshOptions.FieldProvider( - null, null, null, MetadataProvider.Google); - MetadataRefreshOptions.FieldProvider authorsProvider = new MetadataRefreshOptions.FieldProvider( - null, null, null, MetadataProvider.GoodReads); - MetadataRefreshOptions.FieldProvider categoriesProvider = new MetadataRefreshOptions.FieldProvider( - null, null, null, MetadataProvider.Google); - MetadataRefreshOptions.FieldProvider moodProvider = new MetadataRefreshOptions.FieldProvider( - null, null, null, MetadataProvider.Google); - MetadataRefreshOptions.FieldProvider tagProvider = new MetadataRefreshOptions.FieldProvider( - null, null, null, MetadataProvider.Google); - MetadataRefreshOptions.FieldProvider coverProvider = new MetadataRefreshOptions.FieldProvider( - null, null, null, MetadataProvider.GoodReads); + MetadataRefreshOptions.FieldProvider titleProvider = MetadataRefreshOptions.FieldProvider.builder() + .p3(MetadataProvider.Google) + .p1(MetadataProvider.GoodReads) + .build(); + MetadataRefreshOptions.FieldProvider descriptionProvider = MetadataRefreshOptions.FieldProvider.builder() + .p1(MetadataProvider.Google) + .build(); + MetadataRefreshOptions.FieldProvider authorsProvider = MetadataRefreshOptions.FieldProvider.builder() + .p1(MetadataProvider.GoodReads) + .build(); + MetadataRefreshOptions.FieldProvider categoriesProvider = MetadataRefreshOptions.FieldProvider.builder() + .p1(MetadataProvider.Google) + .build(); + MetadataRefreshOptions.FieldProvider moodProvider = MetadataRefreshOptions.FieldProvider.builder() + .p1(MetadataProvider.Google) + .build(); + MetadataRefreshOptions.FieldProvider tagProvider = MetadataRefreshOptions.FieldProvider.builder() + .p1(MetadataProvider.Google) + .build(); + MetadataRefreshOptions.FieldProvider coverProvider = MetadataRefreshOptions.FieldProvider.builder() + .p1(MetadataProvider.GoodReads) + .build(); - MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions( - titleProvider, null, descriptionProvider, authorsProvider, null, null, - null, null, null, null, null, null, categoriesProvider, moodProvider, tagProvider, coverProvider); + MetadataRefreshOptions.FieldOptions fieldOptions = MetadataRefreshOptions.FieldOptions.builder() + .title(titleProvider) + .description(descriptionProvider) + .authors(authorsProvider) + .categories(categoriesProvider) + .moods(moodProvider) + .tags(tagProvider) + .cover(coverProvider) + .build(); - defaultOptions = new MetadataRefreshOptions( - null, MetadataProvider.GoodReads, MetadataProvider.Google, null, null, - true, false, false, fieldOptions); + MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder().build(); + + defaultOptions = MetadataRefreshOptions.builder() + .refreshCovers(true) + .mergeCategories(false) + .reviewBeforeApply(false) + .fieldOptions(fieldOptions) + .skipFields(skipFields) + .build(); } private void setupLibraryOptions() { - MetadataRefreshOptions.FieldProvider titleProvider = new MetadataRefreshOptions.FieldProvider( - null, null, null, MetadataProvider.Google); + MetadataRefreshOptions.FieldProvider titleProvider = MetadataRefreshOptions.FieldProvider.builder() + .p1(MetadataProvider.Google) + .build(); - MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions( - titleProvider, null, null, null, null, null, - null, null, null, null, null, null, null, null, null, null); + MetadataRefreshOptions.FieldOptions fieldOptions = MetadataRefreshOptions.FieldOptions.builder() + .title(titleProvider) + .build(); - libraryOptions = new MetadataRefreshOptions( - 1L, MetadataProvider.Google, null, null, null, - false, true, true, fieldOptions); + MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder().build(); + + libraryOptions = MetadataRefreshOptions.builder() + .libraryId(1L) + .refreshCovers(false) + .mergeCategories(true) + .reviewBeforeApply(true) + .fieldOptions(fieldOptions) + .skipFields(skipFields) + .build(); } private void setupAppSettings() { @@ -225,15 +253,21 @@ class MetadataRefreshServiceTest { @Test void testRefreshMetadata_WithRequestOptions_ShouldUseRequestOptions() { // Given - MetadataRefreshOptions.FieldProvider titleProvider = new MetadataRefreshOptions.FieldProvider( - null, null, null, MetadataProvider.Hardcover); - MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions( - titleProvider, null, null, null, null, null, - null, null, null, null, null, null, null, null, null, null); + MetadataRefreshOptions.FieldProvider titleProvider = MetadataRefreshOptions.FieldProvider.builder() + .p1(MetadataProvider.Hardcover) + .build(); + MetadataRefreshOptions.FieldOptions fieldOptions = MetadataRefreshOptions.FieldOptions.builder() + .title(titleProvider) + .build(); + MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder().build(); - MetadataRefreshOptions requestOptions = new MetadataRefreshOptions( - null, MetadataProvider.Hardcover, null, null, null, - true, false, false, fieldOptions); + MetadataRefreshOptions requestOptions = MetadataRefreshOptions.builder() + .refreshCovers(true) + .mergeCategories(false) + .reviewBeforeApply(false) + .fieldOptions(fieldOptions) + .skipFields(skipFields) + .build(); MetadataRefreshRequest request = MetadataRefreshRequest.builder() .refreshType(MetadataRefreshRequest.RefreshType.BOOKS) @@ -288,9 +322,15 @@ class MetadataRefreshServiceTest { @Test void testRefreshMetadata_WithReviewMode_ShouldCreateTaskAndProposals() throws JsonProcessingException { - MetadataRefreshOptions reviewOptions = new MetadataRefreshOptions( - null, MetadataProvider.GoodReads, MetadataProvider.Google, null, null, - true, false, true, defaultOptions.getFieldOptions()); + MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder().build(); + + MetadataRefreshOptions reviewOptions = MetadataRefreshOptions.builder() + .refreshCovers(true) + .mergeCategories(false) + .reviewBeforeApply(true) + .fieldOptions(defaultOptions.getFieldOptions()) + .skipFields(skipFields) + .build(); MetadataRefreshRequest request = MetadataRefreshRequest.builder() .refreshType(MetadataRefreshRequest.RefreshType.BOOKS) @@ -449,28 +489,48 @@ class MetadataRefreshServiceTest { @Test void testBuildFetchMetadata_WithMergeCategories_ShouldMergeAllCategories() { - MetadataRefreshOptions.FieldProvider titleProvider = new MetadataRefreshOptions.FieldProvider( - null, null, null, MetadataProvider.Google); - MetadataRefreshOptions.FieldProvider descriptionProvider = new MetadataRefreshOptions.FieldProvider( - null, null, null, MetadataProvider.Google); - MetadataRefreshOptions.FieldProvider authorsProvider = new MetadataRefreshOptions.FieldProvider( - null, null, null, MetadataProvider.Google); - MetadataRefreshOptions.FieldProvider moodProvider = new MetadataRefreshOptions.FieldProvider( - null, null, null, MetadataProvider.Google); - MetadataRefreshOptions.FieldProvider tagProvider = new MetadataRefreshOptions.FieldProvider( - null, null, null, MetadataProvider.Google); - MetadataRefreshOptions.FieldProvider categoriesProvider = new MetadataRefreshOptions.FieldProvider( - null, null, MetadataProvider.Google, MetadataProvider.GoodReads); - MetadataRefreshOptions.FieldProvider coverProvider = new MetadataRefreshOptions.FieldProvider( - null, null, null, MetadataProvider.Google); + MetadataRefreshOptions.FieldProvider titleProvider = MetadataRefreshOptions.FieldProvider.builder() + .p1(MetadataProvider.Google) + .build(); + MetadataRefreshOptions.FieldProvider descriptionProvider = MetadataRefreshOptions.FieldProvider.builder() + .p1(MetadataProvider.Google) + .build(); + MetadataRefreshOptions.FieldProvider authorsProvider = MetadataRefreshOptions.FieldProvider.builder() + .p1(MetadataProvider.Google) + .build(); + MetadataRefreshOptions.FieldProvider moodProvider = MetadataRefreshOptions.FieldProvider.builder() + .p1(MetadataProvider.Google) + .build(); + MetadataRefreshOptions.FieldProvider tagProvider = MetadataRefreshOptions.FieldProvider.builder() + .p1(MetadataProvider.Google) + .build(); + MetadataRefreshOptions.FieldProvider categoriesProvider = MetadataRefreshOptions.FieldProvider.builder() + .p3(MetadataProvider.Google) + .p1(MetadataProvider.GoodReads) + .build(); + MetadataRefreshOptions.FieldProvider coverProvider = MetadataRefreshOptions.FieldProvider.builder() + .p1(MetadataProvider.Google) + .build(); - MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions( - titleProvider, null, descriptionProvider, authorsProvider, null, null, - null, null, null, null, null, null, categoriesProvider, moodProvider, tagProvider, coverProvider); + MetadataRefreshOptions.FieldOptions fieldOptions = MetadataRefreshOptions.FieldOptions.builder() + .title(titleProvider) + .description(descriptionProvider) + .authors(authorsProvider) + .categories(categoriesProvider) + .moods(moodProvider) + .tags(tagProvider) + .cover(coverProvider) + .build(); - MetadataRefreshOptions mergeOptions = new MetadataRefreshOptions( - null, MetadataProvider.GoodReads, MetadataProvider.Google, null, null, - true, true, false, fieldOptions); + MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder().build(); + + MetadataRefreshOptions mergeOptions = MetadataRefreshOptions.builder() + .refreshCovers(true) + .mergeCategories(true) + .reviewBeforeApply(false) + .fieldOptions(fieldOptions) + .skipFields(skipFields) + .build(); Map metadataMap = new HashMap<>(); metadataMap.put(MetadataProvider.GoodReads, BookMetadata.builder() diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.html b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.html index 3021232ee..fd9c6044a 100644 --- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.html +++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.html @@ -177,58 +177,56 @@

-
-
- -
-
- - -
- @if (!book.metadata!['moodsLocked']) { - - } - @if (book.metadata!['moodsLocked']) { - - } +
+ +
+
+ +
+ @if (!book.metadata!['moodsLocked']) { + + } + @if (book.metadata!['moodsLocked']) { + + }
-
-
- -
-
- - -
- @if (!book.metadata!['tagsLocked']) { - - } - @if (book.metadata!['tagsLocked']) { - - } +
+
+
+ +
+
+ +
+ @if (!book.metadata!['tagsLocked']) { + + } + @if (book.metadata!['tagsLocked']) { + + }
diff --git a/booklore-ui/src/app/metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component.html b/booklore-ui/src/app/metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component.html index 9b69814b9..773dc9865 100644 --- a/booklore-ui/src/app/metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component.html +++ b/booklore-ui/src/app/metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component.html @@ -2,95 +2,100 @@ - - + + - - - - - - - - + + - - - - - @for (field of fields; track field) { - - + + + @for (field of nonProviderSpecificFields; track field) { + + +
Book Field + SkipMetadata Field 4th Priority + 3rd Priority + 2nd Priority + 1st Priority
- All Other Fields - - - Set All: + - + - + - +
{{ formatLabel(field) }}
+ + {{ formatLabel(field) }} @@ -100,6 +105,20 @@
+
+

Provider-Specific Fields

+

These fields are unique to specific providers and cannot have custom priority settings. Use the checkboxes to skip fetching these fields entirely.

+
+ @for (field of providerSpecificFields; track field) { +
+ + {{ formatLabel(field) }} +
+ } +
+
diff --git a/booklore-ui/src/app/metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component.ts b/booklore-ui/src/app/metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component.ts index e0a439aaf..c4a6e6864 100644 --- a/booklore-ui/src/app/metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component.ts +++ b/booklore-ui/src/app/metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component.ts @@ -1,17 +1,11 @@ -import { - Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges -} from '@angular/core'; -import {Select, SelectChangeEvent} from 'primeng/select'; +import {Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges} from '@angular/core'; +import {Select} from 'primeng/select'; import {FormsModule} from '@angular/forms'; import {Checkbox} from 'primeng/checkbox'; import {Button} from 'primeng/button'; import {MessageService} from 'primeng/api'; -import { - FieldOptions, - FieldProvider, - MetadataRefreshOptions -} from '../../model/request/metadata-refresh-options.model'; +import {FieldOptions, MetadataRefreshOptions} from '../../model/request/metadata-refresh-options.model'; import {Tooltip} from 'primeng/tooltip'; @Component({ @@ -30,25 +24,49 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges { fields: (keyof FieldOptions)[] = [ 'title', 'subtitle', 'description', 'authors', 'publisher', 'publishedDate', 'seriesName', 'seriesNumber', 'seriesTotal', 'isbn13', 'isbn10', - 'language', 'categories', 'cover' + 'language', 'categories', 'cover', 'pageCount', + 'asin', 'goodreadsId', 'comicvineId', 'hardcoverId', 'googleId', + 'amazonRating', 'amazonReviewCount', 'goodreadsRating', 'goodreadsReviewCount', + 'hardcoverRating', 'hardcoverReviewCount', 'moods', 'tags' ]; + + providerSpecificFields: (keyof FieldOptions)[] = [ + 'asin', 'goodreadsId', 'comicvineId', 'hardcoverId', 'googleId', + 'amazonRating', 'amazonReviewCount', 'goodreadsRating', 'goodreadsReviewCount', + 'hardcoverRating', 'hardcoverReviewCount', 'moods', 'tags' + ]; + + nonProviderSpecificFields: (keyof FieldOptions)[] = [ + 'title', 'subtitle', 'description', 'authors', 'publisher', 'publishedDate', + 'seriesName', 'seriesNumber', 'seriesTotal', 'isbn13', 'isbn10', + 'language', 'categories', 'cover', 'pageCount', + ]; + providers: string[] = ['Amazon', 'Google', 'GoodReads', 'Hardcover', 'Comicvine', 'Douban']; + providersWithClear: string[] = ['Clear All', 'Amazon', 'Google', 'GoodReads', 'Hardcover', 'Comicvine', 'Douban']; refreshCovers: boolean = false; mergeCategories: boolean = false; reviewBeforeApply: boolean = false; - allP1 = {placeholder: 'Set All', value: null as string | null}; - allP2 = {placeholder: 'Set All', value: null as string | null}; - allP3 = {placeholder: 'Set All', value: null as string | null}; - allP4 = {placeholder: 'Set All', value: null as string | null}; - fieldOptions: FieldOptions = this.initializeFieldOptions(); + skipFields: Record = this.initializeSkipFields(); + + bulkP1: string | null = null; + bulkP2: string | null = null; + bulkP3: string | null = null; + bulkP4: string | null = null; private messageService = inject(MessageService); private justSubmitted = false; + private providerSpecificFieldsList = [ + 'asin', 'goodreadsId', 'comicvineId', 'hardcoverId', 'googleId', + 'amazonRating', 'amazonReviewCount', 'goodreadsRating', 'goodreadsReviewCount', + 'hardcoverRating', 'hardcoverReviewCount', 'moods', 'tags' + ]; + private initializeFieldOptions(): FieldOptions { return this.fields.reduce((acc, field) => { acc[field] = {p1: null, p2: null, p3: null, p4: null}; @@ -56,6 +74,13 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges { }, {} as FieldOptions); } + private initializeSkipFields(): Record { + return this.fields.reduce((acc, field) => { + acc[field] = false; + return acc; + }, {} as Record); + } + ngOnChanges(changes: SimpleChanges): void { if (changes['currentMetadataOptions'] && this.currentMetadataOptions && !this.justSubmitted) { this.refreshCovers = this.currentMetadataOptions.refreshCovers || false; @@ -72,10 +97,11 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges { } this.fieldOptions = backendFieldOptions; - this.allP1 = {placeholder: 'Set All', value: this.currentMetadataOptions.allP1 || null}; - this.allP2 = {placeholder: 'Set All', value: this.currentMetadataOptions.allP2 || null}; - this.allP3 = {placeholder: 'Set All', value: this.currentMetadataOptions.allP3 || null}; - this.allP4 = {placeholder: 'Set All', value: this.currentMetadataOptions.allP4 || null}; + if (this.currentMetadataOptions.skipFields) { + this.skipFields = {...this.skipFields, ...this.currentMetadataOptions.skipFields}; + } else { + this.skipFields = this.initializeSkipFields(); + } } } @@ -92,14 +118,10 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges { return cloned; } - syncProvider(event: SelectChangeEvent, providerType: keyof FieldProvider) { - for (const field of Object.keys(this.fieldOptions)) { - this.fieldOptions[field as keyof FieldOptions][providerType] = event.value; - } - } - submit() { - const allFieldsHaveProvider = Object.values(this.fieldOptions).every(opt => + const allFieldsHaveProvider = Object.entries(this.fieldOptions).every(([field, opt]) => + this.skipFields[field as keyof FieldOptions] || + this.isProviderSpecificField(field as keyof FieldOptions) || opt.p1 !== null || opt.p2 !== null || opt.p3 !== null || opt.p4 !== null ); @@ -108,14 +130,11 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges { const metadataRefreshOptions: MetadataRefreshOptions = { libraryId: null, - allP1: this.allP1.value, - allP2: this.allP2.value, - allP3: this.allP3.value, - allP4: this.allP4.value, refreshCovers: this.refreshCovers, mergeCategories: this.mergeCategories, reviewBeforeApply: this.reviewBeforeApply, - fieldOptions: this.fieldOptions + fieldOptions: this.fieldOptions, + skipFields: this.skipFields }; this.metadataOptionsSubmitted.emit(metadataRefreshOptions); @@ -127,18 +146,41 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges { this.messageService.add({ severity: 'error', summary: 'Error', - detail: 'At least one provider (P1–P4) must be selected for each book field.', + detail: 'At least one provider (P1–P4) must be selected for each non-skipped book field.', life: 5000 }); } } + setBulkProvider(priority: 'p1' | 'p2' | 'p3' | 'p4', provider: string | null): void { + if (!provider) return; + + const value = provider === 'Clear All' ? null : provider; + + for (const field of this.nonProviderSpecificFields) { + if (!this.skipFields[field]) { + this.fieldOptions[field][priority] = value; + } + } + + switch (priority) { + case 'p1': + this.bulkP1 = null; + break; + case 'p2': + this.bulkP2 = null; + break; + case 'p3': + this.bulkP3 = null; + break; + case 'p4': + this.bulkP4 = null; + break; + } + } + reset() { this.justSubmitted = false; - this.allP1.value = null; - this.allP2.value = null; - this.allP3.value = null; - this.allP4.value = null; for (const field of Object.keys(this.fieldOptions)) { this.fieldOptions[field as keyof FieldOptions] = { p1: null, @@ -147,9 +189,53 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges { p4: null }; } + this.skipFields = this.initializeSkipFields(); + + // Reset bulk selectors + this.bulkP1 = null; + this.bulkP2 = null; + this.bulkP3 = null; + this.bulkP4 = null; } formatLabel(field: string): string { - return field.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase()).trim(); + const fieldLabels: Record = { + 'title': 'Title', + 'subtitle': 'Subtitle', + 'description': 'Description', + 'authors': 'Authors', + 'publisher': 'Publisher', + 'publishedDate': 'Published Date', + 'seriesName': 'Series Name', + 'seriesNumber': 'Series Number', + 'seriesTotal': 'Series Total', + 'isbn13': 'ISBN-13', + 'isbn10': 'ISBN-10', + 'language': 'Language', + 'categories': 'Genres', + 'cover': 'Cover Image', + 'pageCount': 'Page Count', + 'rating': 'Rating', + 'reviewCount': 'Review Count', + 'asin': 'Amazon ASIN', + 'goodreadsId': 'Goodreads ID', + 'comicvineId': 'Comicvine ID', + 'hardcoverId': 'Hardcover ID', + 'googleId': 'Google Books ID', + 'amazonRating': 'Amazon Rating', + 'amazonReviewCount': 'Amazon Review Count', + 'goodreadsRating': 'Goodreads Rating', + 'goodreadsReviewCount': 'Goodreads Review Count', + 'hardcoverRating': 'Hardcover Rating', + 'hardcoverReviewCount': 'Hardcover Review Count', + 'moods': 'Moods (Hardcover)', + 'tags': 'Tags (Hardcover)' + }; + + return fieldLabels[field] || field.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase()).trim(); + } + + isProviderSpecificField(field: keyof FieldOptions): boolean { + return this.providerSpecificFieldsList.includes(field as string); } } diff --git a/booklore-ui/src/app/metadata/model/request/metadata-refresh-options.model.ts b/booklore-ui/src/app/metadata/model/request/metadata-refresh-options.model.ts index 550556602..737993f72 100644 --- a/booklore-ui/src/app/metadata/model/request/metadata-refresh-options.model.ts +++ b/booklore-ui/src/app/metadata/model/request/metadata-refresh-options.model.ts @@ -1,13 +1,10 @@ export interface MetadataRefreshOptions { libraryId: number | null; - allP4: string | null; - allP3: string | null; - allP2: string | null; - allP1: string | null; refreshCovers: boolean; mergeCategories: boolean; reviewBeforeApply: boolean; fieldOptions?: FieldOptions; + skipFields?: Record; } export interface FieldProvider { @@ -32,4 +29,18 @@ export interface FieldOptions { isbn13: FieldProvider; isbn10: FieldProvider; language: FieldProvider; + pageCount: FieldProvider; + asin: FieldProvider; + goodreadsId: FieldProvider; + comicvineId: FieldProvider; + hardcoverId: FieldProvider; + googleId: FieldProvider; + amazonRating: FieldProvider; + amazonReviewCount: FieldProvider; + goodreadsRating: FieldProvider; + goodreadsReviewCount: FieldProvider; + hardcoverRating: FieldProvider; + hardcoverReviewCount: FieldProvider; + moods: FieldProvider; + tags: FieldProvider; } diff --git a/booklore-ui/src/app/settings/library-metadata-settings-component/library-metadata-settings.component.ts b/booklore-ui/src/app/settings/library-metadata-settings-component/library-metadata-settings.component.ts index 9a86741c5..3a0d82484 100644 --- a/booklore-ui/src/app/settings/library-metadata-settings-component/library-metadata-settings.component.ts +++ b/booklore-ui/src/app/settings/library-metadata-settings-component/library-metadata-settings.component.ts @@ -184,10 +184,6 @@ export class LibraryMetadataSettingsComponent implements OnInit { private getDefaultMetadataOptions(): MetadataRefreshOptions { return { libraryId: null, - allP1: null, - allP2: null, - allP3: null, - allP4: null, refreshCovers: false, mergeCategories: false, reviewBeforeApply: false, @@ -205,9 +201,22 @@ export class LibraryMetadataSettingsComponent implements OnInit { isbn10: {p1: null, p2: null, p3: null, p4: null}, language: {p1: null, p2: null, p3: null, p4: null}, categories: {p1: null, p2: null, p3: null, p4: null}, - cover: {p1: null, p2: null, p3: null, p4: null} + cover: {p1: null, p2: null, p3: null, p4: null}, + pageCount: {p1: null, p2: null, p3: null, p4: null}, + asin: {p1: null, p2: null, p3: null, p4: null}, + goodreadsId: {p1: null, p2: null, p3: null, p4: null}, + comicvineId: {p1: null, p2: null, p3: null, p4: null}, + hardcoverId: {p1: null, p2: null, p3: null, p4: null}, + googleId: {p1: null, p2: null, p3: null, p4: null}, + amazonRating: {p1: null, p2: null, p3: null, p4: null}, + amazonReviewCount: {p1: null, p2: null, p3: null, p4: null}, + goodreadsRating: {p1: null, p2: null, p3: null, p4: null}, + goodreadsReviewCount: {p1: null, p2: null, p3: null, p4: null}, + hardcoverRating: {p1: null, p2: null, p3: null, p4: null}, + hardcoverReviewCount: {p1: null, p2: null, p3: null, p4: null}, + moods: {p1: null, p2: null, p3: null, p4: null}, + tags: {p1: null, p2: null, p3: null, p4: null} } }; } } - From f778f1932d0f405259c3fc840396d709c2239ad9 Mon Sep 17 00:00:00 2001 From: Aditya Chandel <8075870+adityachandelgit@users.noreply.github.com> Date: Sun, 5 Oct 2025 00:16:22 -0600 Subject: [PATCH 03/11] Sidebar filter: Restore Authors & Genres, add Moods & Tags (#1264) --- .../book-browser/book-browser.component.html | 20 ------- .../book-browser/book-browser.component.ts | 6 +-- .../book-filter/book-filter.component.html | 10 +++- .../book-filter/book-filter.component.ts | 46 ++++++++++------ .../filter-sorting-preferences.service.ts | 52 ------------------- 5 files changed, 39 insertions(+), 95 deletions(-) delete mode 100644 booklore-ui/src/app/book/components/book-browser/filters/filter-sorting-preferences.service.ts diff --git a/booklore-ui/src/app/book/components/book-browser/book-browser.component.html b/booklore-ui/src/app/book/components/book-browser/book-browser.component.html index 9eb3fd0ba..4ceb4b74d 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-browser.component.html +++ b/booklore-ui/src/app/book/components/book-browser/book-browser.component.html @@ -117,26 +117,6 @@ {{ coverScalePreferenceService.scaleFactor.toFixed(2) }}x
- -
- -
- - -
-
diff --git a/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts b/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts index f6e0b2c6b..15265a9b5 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts +++ b/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts @@ -36,8 +36,6 @@ import {BookDialogHelperService} from './BookDialogHelperService'; import {Checkbox} from 'primeng/checkbox'; import {Popover} from 'primeng/popover'; import {Slider} from 'primeng/slider'; -import {Select} from 'primeng/select'; -import {FilterSortPreferenceService} from './filters/filter-sorting-preferences.service'; import {Divider} from 'primeng/divider'; import {MultiSelect} from 'primeng/multiselect'; import {TableColumnPreferenceService} from './table-column-preference-service'; @@ -85,7 +83,7 @@ const SORT_DIRECTION = { imports: [ Button, VirtualScrollerModule, BookCardComponent, AsyncPipe, ProgressSpinner, Menu, InputText, FormsModule, BookTableComponent, BookFilterComponent, Tooltip, NgClass, PrimeTemplate, NgStyle, Popover, - Checkbox, Slider, Select, Divider, MultiSelect, TieredMenu + Checkbox, Slider, Divider, MultiSelect, TieredMenu ], providers: [SeriesCollapseFilter], animations: [ @@ -100,7 +98,6 @@ const SORT_DIRECTION = { export class BookBrowserComponent implements OnInit { protected userService = inject(UserService); protected coverScalePreferenceService = inject(CoverScalePreferenceService); - protected filterSortPreferenceService = inject(FilterSortPreferenceService); protected columnPreferenceService = inject(TableColumnPreferenceService); protected sidebarFilterTogglePrefService = inject(SidebarFilterTogglePrefService); private activatedRoute = inject(ActivatedRoute); @@ -274,7 +271,6 @@ export class BookBrowserComponent implements OnInit { const globalPrefs = this.entityViewPreferences?.global; const currentEntityTypeStr = this.entityType ? this.entityType.toString().toUpperCase() : undefined; this.coverScalePreferenceService.initScaleValue(this.coverScalePreferenceService.scaleFactor); - this.filterSortPreferenceService.initValue(user.user?.userSettings?.filterSortingMode); this.columnPreferenceService.initPreferences(user.user?.userSettings?.tableColumnPreference); this.visibleColumns = this.columnPreferenceService.visibleColumns; diff --git a/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.html b/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.html index 0c71348cd..bf3af256c 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.html +++ b/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.html @@ -41,7 +41,11 @@ (click)="handleFilterClick(filterType, filter.value?.id || filter.value)"> {{ filter.value.name || filter.value }} - +
+ } + @if (truncatedFilters[filterType]) { +
+ Showing first 250 items
}
@@ -50,5 +54,9 @@ } + +
+ Note: Top 500 items are displayed per filter category +
diff --git a/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.ts b/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.ts index 4e342f22a..dc650016f 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.ts +++ b/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.ts @@ -1,5 +1,5 @@ import {ChangeDetectionStrategy, Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output} from '@angular/core'; -import {combineLatest, distinctUntilChanged, Observable, of, shareReplay, Subject, takeUntil} from 'rxjs'; +import {combineLatest, Observable, of, shareReplay, Subject, takeUntil} from 'rxjs'; import {map} from 'rxjs/operators'; import {BookService} from '../../../service/book.service'; import {Library} from '../../../model/library.model'; @@ -12,7 +12,6 @@ import {Badge} from 'primeng/badge'; import {FormsModule} from '@angular/forms'; import {SelectButton} from 'primeng/selectbutton'; import {UserService} from '../../../../settings/user-management/user.service'; -import {FilterSortPreferenceService} from '../filters/filter-sorting-preferences.service'; import {MagicShelf} from '../../../../magic-shelf.service'; import {GroupRule} from '../../../../magic-shelf-component/magic-shelf-component'; import {BookRuleEvaluatorService} from '../../../../book-rule-evaluator.service'; @@ -161,6 +160,7 @@ export class BookFilterComponent implements OnInit, OnDestroy { activeFilters: Record = {}; filterStreams: Record[]>> = {}; + truncatedFilters: Record = {}; filterTypes: string[] = []; filterModeOptions = [ {label: 'AND', value: 'and'}, @@ -190,38 +190,37 @@ export class BookFilterComponent implements OnInit, OnDestroy { bookService = inject(BookService); userService = inject(UserService); - filterSortPreferenceService = inject(FilterSortPreferenceService); bookRuleEvaluatorService = inject(BookRuleEvaluatorService); ngOnInit(): void { combineLatest([ - this.filterSortPreferenceService.sortMode$.pipe(distinctUntilChanged()), this.entity$ ?? of(null), this.entityType$ ?? of(EntityType.ALL_BOOKS) ]) .pipe(takeUntil(this.destroy$)) .subscribe(([sortMode]) => { this.filterStreams = { - // Temporarily disabled until we can optimize for large libraries - /*author: this.getFilterStream((book: Book) => book.metadata?.authors!.map(name => ({id: name, name})) || [], 'id', 'name', sortMode), - category: this.getFilterStream((book: Book) => book.metadata?.categories!.map(name => ({id: name, name})) || [], 'id', 'name', sortMode),*/ - series: this.getFilterStream((book) => (book.metadata?.seriesName ? [{id: book.metadata.seriesName, name: book.metadata.seriesName}] : []), 'id', 'name', sortMode), - publisher: this.getFilterStream((book) => (book.metadata?.publisher ? [{id: book.metadata.publisher, name: book.metadata.publisher}] : []), 'id', 'name', sortMode), + author: this.getFilterStream((book: Book) => book.metadata?.authors!.map(name => ({id: name, name})) || [], 'id', 'name'), + category: this.getFilterStream((book: Book) => book.metadata?.categories!.map(name => ({id: name, name})) || [], 'id', 'name'), + series: this.getFilterStream((book) => (book.metadata?.seriesName ? [{id: book.metadata.seriesName, name: book.metadata.seriesName}] : []), 'id', 'name'), + publisher: this.getFilterStream((book) => (book.metadata?.publisher ? [{id: book.metadata.publisher, name: book.metadata.publisher}] : []), 'id', 'name'), readStatus: this.getFilterStream((book: Book) => { let status = book.readStatus; if (status == null || !(status in readStatusLabels)) { status = ReadStatus.UNSET; } return [{id: status, name: getReadStatusName(status)}]; - }, 'id', 'name', sortMode), + }, 'id', 'name'), + mood: this.getFilterStream((book: Book) => book.metadata?.moods!.map(name => ({id: name, name})) || [], 'id', 'name'), + tag: this.getFilterStream((book: Book) => book.metadata?.tags!.map(name => ({id: name, name})) || [], 'id', 'name'), matchScore: this.getFilterStream((book: Book) => getMatchScoreRangeFilters(book.metadataMatchScore), 'id', 'name', 'sortIndex'), personalRating: this.getFilterStream((book: Book) => getRatingRangeFilters10(book.metadata?.personalRating!), 'id', 'name', 'sortIndex'), amazonRating: this.getFilterStream((book: Book) => getRatingRangeFilters(book.metadata?.amazonRating!), 'id', 'name', 'sortIndex'), goodreadsRating: this.getFilterStream((book: Book) => getRatingRangeFilters(book.metadata?.goodreadsRating!), 'id', 'name', 'sortIndex'), hardcoverRating: this.getFilterStream((book: Book) => getRatingRangeFilters(book.metadata?.hardcoverRating!), 'id', 'name', 'sortIndex'), - shelfStatus: this.getFilterStream(getShelfStatusFilter, 'id', 'name', sortMode), - publishedDate: this.getFilterStream(extractPublishedYearFilter, 'id', 'name', sortMode), - language: this.getFilterStream(getLanguageFilter, 'id', 'name', sortMode), + shelfStatus: this.getFilterStream(getShelfStatusFilter, 'id', 'name'), + publishedDate: this.getFilterStream(extractPublishedYearFilter, 'id', 'name'), + language: this.getFilterStream(getLanguageFilter, 'id', 'name'), fileSize: this.getFilterStream((book: Book) => getFileSizeRangeFilters(book.fileSizeKb), 'id', 'name', 'sortIndex'), pageCount: this.getFilterStream((book: Book) => getPageCountRangeFilters(book.metadata?.pageCount!), 'id', 'name', 'sortIndex'), }; @@ -266,18 +265,31 @@ export class BookFilterComponent implements OnInit, OnDestroy { const result = Array.from(filterMap.values()); - return result.sort((a, b) => { + const sorted = result.sort((a, b) => { if (sortMode === 'sortIndex') { return (a.value.sortIndex ?? 999) - (b.value.sortIndex ?? 999); } - if (sortMode === 'alphabetical') { - return a.value[nameKey].toString().localeCompare(b.value[nameKey].toString()); - } return ( b.bookCount - a.bookCount || a.value[nameKey].toString().localeCompare(b.value[nameKey].toString()) ); }); + + const isTruncated = sorted.length > 500; + const truncated = sorted.slice(0, 500); + + return {items: truncated, isTruncated}; + }), + map(({items, isTruncated}) => { + setTimeout(() => { + const filterType = Object.keys(this.filterStreams).find(key => + this.filterStreams[key] === this.getFilterStream(extractor, idKey, nameKey) + ); + if (filterType) { + this.truncatedFilters[filterType] = isTruncated; + } + }); + return items; }), shareReplay({bufferSize: 1, refCount: true}) ); diff --git a/booklore-ui/src/app/book/components/book-browser/filters/filter-sorting-preferences.service.ts b/booklore-ui/src/app/book/components/book-browser/filters/filter-sorting-preferences.service.ts deleted file mode 100644 index e9fbb8510..000000000 --- a/booklore-ui/src/app/book/components/book-browser/filters/filter-sorting-preferences.service.ts +++ /dev/null @@ -1,52 +0,0 @@ -import {inject, Injectable} from '@angular/core'; -import {BehaviorSubject} from 'rxjs'; -import {UserService} from '../../../../settings/user-management/user.service'; -import {MessageService} from 'primeng/api'; - -@Injectable({ - providedIn: 'root' -}) -export class FilterSortPreferenceService { - - readonly filterSortingOptions = [ - { label: 'Alphabetical (A–Z)', value: 'alphabetical' }, - { label: 'Book Count (High to Low)', value: 'count' } - ]; - - private readonly userService = inject(UserService); - private readonly messageService = inject(MessageService); - - private readonly sortModeSubject = new BehaviorSubject<'alphabetical' | 'count'>('count'); - readonly sortMode$ = this.sortModeSubject.asObservable(); - - initValue(value: 'alphabetical' | 'count' | null | undefined): void { - const resolved = value ?? 'count'; - this.sortModeSubject.next(resolved); - } - - get selectedFilterSorting(): 'alphabetical' | 'count' { - return this.sortModeSubject.value; - } - - set selectedFilterSorting(value: 'alphabetical' | 'count') { - if (this.sortModeSubject.value !== value) { - this.sortModeSubject.next(value); - this.savePreference(value); - } - } - - private savePreference(value: 'alphabetical' | 'count'): void { - const user = this.userService.getCurrentUser(); - if (!user) return; - - user.userSettings.filterSortingMode = value; - this.userService.updateUserSetting(user.id, 'filterSortingMode', value); - - this.messageService.add({ - severity: 'success', - summary: 'Preferences Updated', - detail: 'Your filter sorting preference has been saved.', - life: 1500 - }); - } -} From ba6188579004af5ea5413828944a4ebc52c05610 Mon Sep 17 00:00:00 2001 From: Aditya Chandel <8075870+adityachandelgit@users.noreply.github.com> Date: Sun, 5 Oct 2025 00:39:10 -0600 Subject: [PATCH 04/11] Improved EPUB navigation on mobile devices with swipe gestures (#1265) --- .../component/epub-viewer.component.scss | 14 +++++ .../component/epub-viewer.component.ts | 59 ++++++++++++++++++- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.scss b/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.scss index bd5f7f752..083c31eac 100644 --- a/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.scss +++ b/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.scss @@ -61,6 +61,16 @@ height: 100%; width: 100%; overflow: hidden; + + @media (max-width: 768px) { + touch-action: manipulation; + user-select: none; + } + + iframe { + pointer-events: auto; + touch-action: manipulation; + } } .menu-toggle-button, @@ -91,6 +101,10 @@ border: none; padding: 10px; transition: color 0.3s ease, transform 0.2s ease; + + @media (max-width: 768px) { + display: none; + } } .epub-controls-left { diff --git a/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.ts b/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.ts index ab7981794..dd97a35ae 100644 --- a/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.ts +++ b/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.ts @@ -1,4 +1,4 @@ -import {Component, ElementRef, inject, OnDestroy, OnInit, ViewChild} from '@angular/core'; +import {Component, ElementRef, inject, OnDestroy, OnInit, ViewChild, AfterViewInit} from '@angular/core'; import ePub from 'epubjs'; import {Drawer} from 'primeng/drawer'; import {Button} from 'primeng/button'; @@ -75,6 +75,11 @@ export class EpubViewerComponent implements OnInit, OnDestroy { epub!: Book; + private touchStartX: number = 0; + private touchStartY: number = 0; + private minSwipeDistance: number = 50; + private maxVerticalDistance: number = 100; + ngOnInit(): void { this.route.paramMap.subscribe((params) => { this.isLoading = true; @@ -152,6 +157,7 @@ export class EpubViewerComponent implements OnInit, OnDestroy { displayPromise.then(() => { this.setupKeyListener(); + this.setupTouchListeners(); this.trackProgress(); this.isLoading = false; }); @@ -193,6 +199,7 @@ export class EpubViewerComponent implements OnInit, OnDestroy { this.applyCombinedTheme(); this.setupKeyListener(); + this.setupTouchListeners(); this.rendition.display(cfi || undefined); this.updateViewerSetting(); } @@ -277,6 +284,54 @@ export class EpubViewerComponent implements OnInit, OnDestroy { document.addEventListener('keyup', this.keyListener); } + private setupTouchListeners(): void { + if (!this.isMobileDevice() || this.selectedFlow === 'scrolled') return; + + this.rendition.on('rendered', () => { + const iframe = this.epubContainer.nativeElement.querySelector('iframe'); + if (iframe && iframe.contentDocument) { + const iframeDoc = iframe.contentDocument; + + iframeDoc.addEventListener('touchstart', this.onTouchStart.bind(this), {passive: true}); + iframeDoc.addEventListener('touchend', this.onTouchEnd.bind(this), {passive: true}); + } + }); + + const container = this.epubContainer.nativeElement; + container.addEventListener('touchstart', this.onTouchStart.bind(this), {passive: true}); + container.addEventListener('touchend', this.onTouchEnd.bind(this), {passive: true}); + } + + onTouchStart(event: TouchEvent): void { + if (this.selectedFlow === 'scrolled') return; + + this.touchStartX = event.touches[0].clientX; + this.touchStartY = event.touches[0].clientY; + } + + onTouchEnd(event: TouchEvent): void { + if (this.selectedFlow === 'scrolled') return; + + const touchEndX = event.changedTouches[0].clientX; + const touchEndY = event.changedTouches[0].clientY; + + const deltaX = touchEndX - this.touchStartX; + const deltaY = Math.abs(touchEndY - this.touchStartY); + + if (Math.abs(deltaX) > this.minSwipeDistance && deltaY < this.maxVerticalDistance) { + event.preventDefault(); + if (deltaX > 0) { + this.prevPage(); + } else { + this.nextPage(); + } + } + } + + private isMobileDevice(): boolean { + return window.innerWidth <= 768; + } + prevPage(): void { if (this.rendition) { this.rendition.prev(); @@ -333,7 +388,6 @@ export class EpubViewerComponent implements OnInit, OnDestroy { return this.book.locations.generate(1600); }).then(() => { this.locationsReady = true; - // Recalculate progress with new locations if (this.rendition.currentLocation()) { const location = this.rendition.currentLocation(); const cfi = location.end.cfi; @@ -341,7 +395,6 @@ export class EpubViewerComponent implements OnInit, OnDestroy { this.progressPercentage = Math.round(percentage * 1000) / 10; } }).catch(() => { - // If location generation fails, keep using spine-based calculation this.locationsReady = false; }); } From 23aa45b26440e97dbea2fdf8526f1c8afe5ae801 Mon Sep 17 00:00:00 2001 From: Aditya Chandel <8075870+adityachandelgit@users.noreply.github.com> Date: Sun, 5 Oct 2025 10:28:16 -0600 Subject: [PATCH 05/11] Implement EPUB page spread feature for desktop (#1267) * Implement EPUB page spread feature for desktop * Implement EPUB page spread feature for desktop --- .../booklore/model/dto/BookLoreUser.java | 1 + .../model/dto/EpubViewerPreferences.java | 1 + .../dto/request/MetadataRefreshOptions.java | 6 +- .../entity/EpubViewerPreferencesEntity.java | 3 + .../booklore/service/BookService.java | 2 + .../appsettings/SettingPersistenceHelper.java | 60 +++--- .../metadata/MetadataRefreshService.java | 61 +++--- .../user/DefaultUserSettingsProvider.java | 1 + .../migration/V54__Add_Spread_Column_Epub.sql | 2 + .../metadata/MetadataRefreshServiceTest.java | 55 ++++-- .../component/epub-viewer.component.html | 30 +++ .../component/epub-viewer.component.scss | 1 + .../component/epub-viewer.component.ts | 47 ++++- booklore-ui/src/app/book/model/book.model.ts | 1 + ...data-advanced-fetch-options.component.html | 33 ++-- ...tadata-advanced-fetch-options.component.ts | 22 +-- .../request/metadata-refresh-options.model.ts | 2 +- .../library-metadata-settings.component.html | 2 + .../cbx-reader-preferences-component.scss | 2 +- .../cbx-reader-preferences-component.ts | 10 +- .../epub-reader-preferences-component.html | 182 +++++++++++------- .../epub-reader-preferences-component.scss | 60 ++++-- .../epub-reader-preferences-component.ts | 22 ++- .../pdf-reader-preferences-component.scss | 2 +- .../settings/user-management/user.service.ts | 1 + 25 files changed, 396 insertions(+), 213 deletions(-) create mode 100644 booklore-api/src/main/resources/db/migration/V54__Add_Spread_Column_Epub.sql diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java index 011bbc035..b8fe745bd 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java @@ -113,6 +113,7 @@ public class BookLoreUser { private Float letterSpacing; private Float lineHeight; private String flow; + private String spread; } @Data diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/EpubViewerPreferences.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/EpubViewerPreferences.java index f6360a2e1..258f67bf1 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/EpubViewerPreferences.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/EpubViewerPreferences.java @@ -10,6 +10,7 @@ public class EpubViewerPreferences { private String theme; private String font; private String flow; + private String spread; private Integer fontSize; private Float letterSpacing; private Float lineHeight; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/MetadataRefreshOptions.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/MetadataRefreshOptions.java index a58084e16..e0b385114 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/MetadataRefreshOptions.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/MetadataRefreshOptions.java @@ -20,8 +20,8 @@ public class MetadataRefreshOptions { private Boolean reviewBeforeApply; @NotNull(message = "Field options cannot be null") private FieldOptions fieldOptions; - @NotNull(message = "Skip fields cannot be null") - private SkipFields skipFields; + @NotNull(message = "Enabled fields cannot be null") + private EnabledFields enabledFields; @Getter @Setter @@ -76,7 +76,7 @@ public class MetadataRefreshOptions { @NoArgsConstructor @AllArgsConstructor @Builder - public static class SkipFields { + public static class EnabledFields { private boolean title; private boolean subtitle; private boolean description; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/EpubViewerPreferencesEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/EpubViewerPreferencesEntity.java index 412c987a2..5e6fb116d 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/EpubViewerPreferencesEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/EpubViewerPreferencesEntity.java @@ -41,4 +41,7 @@ public class EpubViewerPreferencesEntity { @Column(name = "flow") private String flow; + + @Column(name = "spread") + private String spread; } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java index 9b02a4a7d..5cacb26de 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java @@ -193,6 +193,7 @@ public class BookService { .fontSize(epubPref.getFontSize()) .theme(epubPref.getTheme()) .flow(epubPref.getFlow()) + .spread(epubPref.getSpread()) .letterSpacing(epubPref.getLetterSpacing()) .lineHeight(epubPref.getLineHeight()) .build())); @@ -272,6 +273,7 @@ public class BookService { epubPrefs.setFontSize(epubSettings.getFontSize()); epubPrefs.setTheme(epubSettings.getTheme()); epubPrefs.setFlow(epubSettings.getFlow()); + epubPrefs.setSpread(epubSettings.getSpread()); epubPrefs.setLetterSpacing(epubSettings.getLetterSpacing()); epubPrefs.setLineHeight(epubSettings.getLineHeight()); epubViewerPreferencesRepository.save(epubPrefs); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java index d6c56d31a..53c9cffc9 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java @@ -148,35 +148,35 @@ public class SettingPersistenceHelper { .tags(nullProvider) .build(); - MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder() - .title(false) - .subtitle(false) - .description(false) - .authors(false) - .publisher(false) - .publishedDate(false) - .seriesName(false) - .seriesNumber(false) - .seriesTotal(false) - .isbn13(false) - .isbn10(false) - .language(false) - .categories(false) - .cover(false) - .pageCount(false) - .asin(false) - .goodreadsId(false) - .comicvineId(false) - .hardcoverId(false) - .googleId(false) - .amazonRating(false) - .amazonReviewCount(false) - .goodreadsRating(false) - .goodreadsReviewCount(false) - .hardcoverRating(false) - .hardcoverReviewCount(false) - .moods(false) - .tags(false) + MetadataRefreshOptions.EnabledFields enabledFields = MetadataRefreshOptions.EnabledFields.builder() + .title(true) + .subtitle(true) + .description(true) + .authors(true) + .publisher(true) + .publishedDate(true) + .seriesName(true) + .seriesNumber(true) + .seriesTotal(true) + .isbn13(true) + .isbn10(true) + .language(true) + .categories(true) + .cover(true) + .pageCount(true) + .asin(true) + .goodreadsId(true) + .comicvineId(true) + .hardcoverId(true) + .googleId(true) + .amazonRating(true) + .amazonReviewCount(true) + .goodreadsRating(true) + .goodreadsReviewCount(true) + .hardcoverRating(true) + .hardcoverReviewCount(true) + .moods(true) + .tags(true) .build(); return MetadataRefreshOptions.builder() @@ -185,7 +185,7 @@ public class SettingPersistenceHelper { .mergeCategories(true) .reviewBeforeApply(false) .fieldOptions(fieldOptions) - .skipFields(skipFields) + .enabledFields(enabledFields) .build(); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java index 808c66f12..a5010b098 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java @@ -393,116 +393,116 @@ public class MetadataRefreshService { public BookMetadata buildFetchMetadata(Long bookId, MetadataRefreshOptions refreshOptions, Map metadataMap) { BookMetadata metadata = BookMetadata.builder().bookId(bookId).build(); MetadataRefreshOptions.FieldOptions fieldOptions = refreshOptions.getFieldOptions(); - MetadataRefreshOptions.SkipFields skipFields = refreshOptions.getSkipFields(); + MetadataRefreshOptions.EnabledFields enabledFields = refreshOptions.getEnabledFields(); - if (!skipFields.isTitle()) { + if (enabledFields.isTitle()) { metadata.setTitle(resolveFieldAsString(metadataMap, fieldOptions.getTitle(), BookMetadata::getTitle)); } - if (!skipFields.isSubtitle()) { + if (enabledFields.isSubtitle()) { metadata.setSubtitle(resolveFieldAsString(metadataMap, fieldOptions.getSubtitle(), BookMetadata::getSubtitle)); } - if (!skipFields.isDescription()) { + if (enabledFields.isDescription()) { metadata.setDescription(resolveFieldAsString(metadataMap, fieldOptions.getDescription(), BookMetadata::getDescription)); } - if (!skipFields.isAuthors()) { + if (enabledFields.isAuthors()) { metadata.setAuthors(resolveFieldAsList(metadataMap, fieldOptions.getAuthors(), BookMetadata::getAuthors)); } - if (!skipFields.isPublisher()) { + if (enabledFields.isPublisher()) { metadata.setPublisher(resolveFieldAsString(metadataMap, fieldOptions.getPublisher(), BookMetadata::getPublisher)); } - if (!skipFields.isPublishedDate()) { + if (enabledFields.isPublishedDate()) { metadata.setPublishedDate(resolveField(metadataMap, fieldOptions.getPublishedDate(), BookMetadata::getPublishedDate)); } - if (!skipFields.isSeriesName()) { + if (enabledFields.isSeriesName()) { metadata.setSeriesName(resolveFieldAsString(metadataMap, fieldOptions.getSeriesName(), BookMetadata::getSeriesName)); } - if (!skipFields.isSeriesNumber()) { + if (enabledFields.isSeriesNumber()) { metadata.setSeriesNumber(resolveField(metadataMap, fieldOptions.getSeriesNumber(), BookMetadata::getSeriesNumber)); } - if (!skipFields.isSeriesTotal()) { + if (enabledFields.isSeriesTotal()) { metadata.setSeriesTotal(resolveFieldAsInteger(metadataMap, fieldOptions.getSeriesTotal(), BookMetadata::getSeriesTotal)); } - if (!skipFields.isIsbn13()) { + if (enabledFields.isIsbn13()) { metadata.setIsbn13(resolveFieldAsString(metadataMap, fieldOptions.getIsbn13(), BookMetadata::getIsbn13)); } - if (!skipFields.isIsbn10()) { + if (enabledFields.isIsbn10()) { metadata.setIsbn10(resolveFieldAsString(metadataMap, fieldOptions.getIsbn10(), BookMetadata::getIsbn10)); } - if (!skipFields.isLanguage()) { + if (enabledFields.isLanguage()) { metadata.setLanguage(resolveFieldAsString(metadataMap, fieldOptions.getLanguage(), BookMetadata::getLanguage)); } - if (!skipFields.isPageCount()) { + if (enabledFields.isPageCount()) { metadata.setPageCount(resolveFieldAsInteger(metadataMap, fieldOptions.getPageCount(), BookMetadata::getPageCount)); } - if (!skipFields.isCover()) { + if (enabledFields.isCover()) { metadata.setThumbnailUrl(resolveFieldAsString(metadataMap, fieldOptions.getCover(), BookMetadata::getThumbnailUrl)); } - if (!skipFields.isAmazonRating()) { + if (enabledFields.isAmazonRating()) { if (metadataMap.containsKey(Amazon)) { metadata.setAmazonRating(metadataMap.get(Amazon).getAmazonRating()); } } - if (!skipFields.isAmazonReviewCount()) { + if (enabledFields.isAmazonReviewCount()) { if (metadataMap.containsKey(Amazon)) { metadata.setAmazonReviewCount(metadataMap.get(Amazon).getAmazonReviewCount()); } } - if (!skipFields.isGoodreadsRating()) { + if (enabledFields.isGoodreadsRating()) { if (metadataMap.containsKey(GoodReads)) { metadata.setGoodreadsRating(metadataMap.get(GoodReads).getGoodreadsRating()); } } - if (!skipFields.isGoodreadsReviewCount()) { + if (enabledFields.isGoodreadsReviewCount()) { if (metadataMap.containsKey(GoodReads)) { metadata.setGoodreadsReviewCount(metadataMap.get(GoodReads).getGoodreadsReviewCount()); } } - if (!skipFields.isHardcoverRating()) { + if (enabledFields.isHardcoverRating()) { if (metadataMap.containsKey(Hardcover)) { metadata.setHardcoverRating(metadataMap.get(Hardcover).getHardcoverRating()); } } - if (!skipFields.isHardcoverReviewCount()) { + if (enabledFields.isHardcoverReviewCount()) { if (metadataMap.containsKey(Hardcover)) { metadata.setHardcoverReviewCount(metadataMap.get(Hardcover).getHardcoverReviewCount()); } } - if (!skipFields.isAsin()) { + if (enabledFields.isAsin()) { if (metadataMap.containsKey(Amazon)) { metadata.setAsin(metadataMap.get(Amazon).getAsin()); } } - if (!skipFields.isGoodreadsId()) { + if (enabledFields.isGoodreadsId()) { if (metadataMap.containsKey(GoodReads)) { metadata.setGoodreadsId(metadataMap.get(GoodReads).getGoodreadsId()); } } - if (!skipFields.isHardcoverId()) { + if (enabledFields.isHardcoverId()) { if (metadataMap.containsKey(Hardcover)) { metadata.setHardcoverId(metadataMap.get(Hardcover).getHardcoverId()); } } - if (!skipFields.isGoogleId()) { + if (enabledFields.isGoogleId()) { if (metadataMap.containsKey(Google)) { metadata.setGoogleId(metadataMap.get(Google).getGoogleId()); } } - if (!skipFields.isComicvineId()) { + if (enabledFields.isComicvineId()) { if (metadataMap.containsKey(Comicvine)) { metadata.setComicvineId(metadataMap.get(Comicvine).getComicvineId()); } } - if (!skipFields.isMoods()) { + if (enabledFields.isMoods()) { if (metadataMap.containsKey(Hardcover)) { metadata.setMoods(metadataMap.get(Hardcover).getMoods()); } } - if (!skipFields.isTags()) { + if (enabledFields.isTags()) { if (metadataMap.containsKey(Hardcover)) { metadata.setTags(metadataMap.get(Hardcover).getTags()); } } - if (!skipFields.isCategories()) { + if (enabledFields.isCategories()) { if (refreshOptions.isMergeCategories()) { metadata.setCategories(getAllCategories(metadataMap, fieldOptions.getCategories(), BookMetadata::getCategories)); } else { @@ -596,4 +596,5 @@ public class MetadataRefreshService { case BOOKS -> request.getBookIds(); }; } -} \ No newline at end of file +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/DefaultUserSettingsProvider.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/DefaultUserSettingsProvider.java index 5ccded2f1..75b18e0c5 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/DefaultUserSettingsProvider.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/DefaultUserSettingsProvider.java @@ -69,6 +69,7 @@ public class DefaultUserSettingsProvider { .letterSpacing(null) .lineHeight(null) .flow("paginated") + .spread("double") .build(); } diff --git a/booklore-api/src/main/resources/db/migration/V54__Add_Spread_Column_Epub.sql b/booklore-api/src/main/resources/db/migration/V54__Add_Spread_Column_Epub.sql new file mode 100644 index 000000000..5ee28f020 --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V54__Add_Spread_Column_Epub.sql @@ -0,0 +1,2 @@ +ALTER TABLE epub_viewer_preference + ADD COLUMN IF NOT EXISTS spread VARCHAR(20) DEFAULT 'double'; \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/MetadataRefreshServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/MetadataRefreshServiceTest.java index fba06ea77..4344f023b 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/MetadataRefreshServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/MetadataRefreshServiceTest.java @@ -82,6 +82,39 @@ class MetadataRefreshServiceTest { setupTestEntities(); } + private MetadataRefreshOptions.EnabledFields allEnabledFields() { + return MetadataRefreshOptions.EnabledFields.builder() + .title(true) + .subtitle(true) + .description(true) + .authors(true) + .publisher(true) + .publishedDate(true) + .seriesName(true) + .seriesNumber(true) + .seriesTotal(true) + .isbn13(true) + .isbn10(true) + .language(true) + .categories(true) + .cover(true) + .pageCount(true) + .asin(true) + .goodreadsId(true) + .comicvineId(true) + .hardcoverId(true) + .googleId(true) + .amazonRating(true) + .amazonReviewCount(true) + .goodreadsRating(true) + .goodreadsReviewCount(true) + .hardcoverRating(true) + .hardcoverReviewCount(true) + .moods(true) + .tags(true) + .build(); + } + private void setupDefaultOptions() { MetadataRefreshOptions.FieldProvider titleProvider = MetadataRefreshOptions.FieldProvider.builder() .p3(MetadataProvider.Google) @@ -116,14 +149,14 @@ class MetadataRefreshServiceTest { .cover(coverProvider) .build(); - MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder().build(); + MetadataRefreshOptions.EnabledFields skipFields = allEnabledFields(); defaultOptions = MetadataRefreshOptions.builder() .refreshCovers(true) .mergeCategories(false) .reviewBeforeApply(false) .fieldOptions(fieldOptions) - .skipFields(skipFields) + .enabledFields(skipFields) .build(); } @@ -136,7 +169,7 @@ class MetadataRefreshServiceTest { .title(titleProvider) .build(); - MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder().build(); + MetadataRefreshOptions.EnabledFields skipFields = allEnabledFields(); libraryOptions = MetadataRefreshOptions.builder() .libraryId(1L) @@ -144,7 +177,7 @@ class MetadataRefreshServiceTest { .mergeCategories(true) .reviewBeforeApply(true) .fieldOptions(fieldOptions) - .skipFields(skipFields) + .enabledFields(skipFields) .build(); } @@ -160,7 +193,6 @@ class MetadataRefreshServiceTest { testLibrary.setId(1L); testLibrary.setName("Test Library"); - // Create AuthorEntity for proper type compatibility AuthorEntity authorEntity = new AuthorEntity(); authorEntity.setName("Test Author"); @@ -252,21 +284,20 @@ class MetadataRefreshServiceTest { @Test void testRefreshMetadata_WithRequestOptions_ShouldUseRequestOptions() { - // Given MetadataRefreshOptions.FieldProvider titleProvider = MetadataRefreshOptions.FieldProvider.builder() .p1(MetadataProvider.Hardcover) .build(); MetadataRefreshOptions.FieldOptions fieldOptions = MetadataRefreshOptions.FieldOptions.builder() .title(titleProvider) .build(); - MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder().build(); + MetadataRefreshOptions.EnabledFields skipFields = allEnabledFields(); MetadataRefreshOptions requestOptions = MetadataRefreshOptions.builder() .refreshCovers(true) .mergeCategories(false) .reviewBeforeApply(false) .fieldOptions(fieldOptions) - .skipFields(skipFields) + .enabledFields(skipFields) .build(); MetadataRefreshRequest request = MetadataRefreshRequest.builder() @@ -322,14 +353,14 @@ class MetadataRefreshServiceTest { @Test void testRefreshMetadata_WithReviewMode_ShouldCreateTaskAndProposals() throws JsonProcessingException { - MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder().build(); + MetadataRefreshOptions.EnabledFields skipFields = allEnabledFields(); MetadataRefreshOptions reviewOptions = MetadataRefreshOptions.builder() .refreshCovers(true) .mergeCategories(false) .reviewBeforeApply(true) .fieldOptions(defaultOptions.getFieldOptions()) - .skipFields(skipFields) + .enabledFields(skipFields) .build(); MetadataRefreshRequest request = MetadataRefreshRequest.builder() @@ -522,14 +553,14 @@ class MetadataRefreshServiceTest { .cover(coverProvider) .build(); - MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder().build(); + MetadataRefreshOptions.EnabledFields skipFields = allEnabledFields(); MetadataRefreshOptions mergeOptions = MetadataRefreshOptions.builder() .refreshCovers(true) .mergeCategories(true) .reviewBeforeApply(false) .fieldOptions(fieldOptions) - .skipFields(skipFields) + .enabledFields(skipFields) .build(); Map metadataMap = new HashMap<>(); diff --git a/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.html b/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.html index c76c78411..807eda4c8 100644 --- a/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.html +++ b/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.html @@ -109,6 +109,36 @@
+ @if (selectedFlow === 'paginated' && !isMobileDevice()) { + + +
+ +
+
+ + + +
+
+ + + +
+
+
+ } +
diff --git a/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.scss b/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.scss index 083c31eac..c51dd2920 100644 --- a/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.scss +++ b/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.scss @@ -178,3 +178,4 @@ ::ng-deep .p-divider.p-divider-horizontal { margin: 0.25rem 0 0.5rem 0 !important; } + diff --git a/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.ts b/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.ts index dd97a35ae..755e8906b 100644 --- a/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.ts +++ b/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.ts @@ -49,6 +49,7 @@ export class EpubViewerComponent implements OnInit, OnDestroy { selectedFlow?: string = 'paginated'; selectedTheme?: string = 'white'; selectedFontType?: string | null = null; + selectedSpread?: string = 'double'; lineHeight?: number; letterSpacing?: number; @@ -115,6 +116,7 @@ export class EpubViewerComponent implements OnInit, OnDestroy { const resolvedTheme = settingScope === 'Global' ? globalSettings.theme : individualSetting?.theme; const resolvedLineHeight = settingScope === 'Global' ? globalSettings.lineHeight : individualSetting?.lineHeight; const resolvedLetterSpacing = settingScope === 'Global' ? globalSettings.letterSpacing : individualSetting?.letterSpacing; + const resolvedSpread = settingScope === 'Global' ? globalSettings.spread || 'double' : individualSetting?.spread || 'double'; if (resolvedTheme != null) this.selectedTheme = resolvedTheme; if (resolvedFontFamily != null) this.selectedFontType = resolvedFontFamily; @@ -122,12 +124,14 @@ export class EpubViewerComponent implements OnInit, OnDestroy { if (resolvedLineHeight != null) this.lineHeight = resolvedLineHeight; if (resolvedLetterSpacing != null) this.letterSpacing = resolvedLetterSpacing; if (resolvedFlow != null) this.selectedFlow = resolvedFlow; + if (resolvedSpread != null) this.selectedSpread = resolvedSpread; this.rendition = this.book.renderTo(this.epubContainer.nativeElement, { flow: this.selectedFlow ?? 'paginated', manager: this.selectedFlow === 'scrolled' ? 'continuous' : 'default', width: '100%', height: '100%', + spread: this.selectedFlow === 'paginated' && !this.isMobileDevice() ? (this.selectedSpread === 'single' ? 'none' : this.selectedSpread) : 'none', allowScriptedContent: true, }); @@ -193,11 +197,35 @@ export class EpubViewerComponent implements OnInit, OnDestroy { manager: this.selectedFlow === 'scrolled' ? 'continuous' : 'default', width: '100%', height: '100%', + spread: this.selectedFlow === 'paginated' && !this.isMobileDevice() ? (this.selectedSpread === 'single' ? 'none' : this.selectedSpread) : 'none', allowScriptedContent: true, }); + this.rendition.themes.override('font-size', `${this.fontSize}%`); this.applyCombinedTheme(); + this.setupKeyListener(); + this.setupTouchListeners(); + this.rendition.display(cfi || undefined); + this.updateViewerSetting(); + } + changeSpreadMode(): void { + if (!this.rendition || !this.book || this.selectedFlow === 'scrolled' || this.isMobileDevice()) return; + + const cfi = this.rendition.currentLocation()?.start?.cfi; + this.rendition.destroy(); + + this.rendition = this.book.renderTo(this.epubContainer.nativeElement, { + flow: this.selectedFlow, + manager: 'default', + width: '100%', + height: '100%', + spread: this.selectedSpread === 'single' ? 'none' : this.selectedSpread, + allowScriptedContent: true, + }); + + this.rendition.themes.override('font-size', `${this.fontSize}%`); + this.applyCombinedTheme(); this.setupKeyListener(); this.setupTouchListeners(); this.rendition.display(cfi || undefined); @@ -255,6 +283,7 @@ export class EpubViewerComponent implements OnInit, OnDestroy { if (this.selectedFontType) epubSettings.font = this.selectedFontType; if (this.fontSize) epubSettings.fontSize = this.fontSize; if (this.selectedFlow) epubSettings.flow = this.selectedFlow; + if (this.selectedSpread === 'single' || this.selectedSpread === 'double') epubSettings.spread = this.selectedSpread; if (this.lineHeight) epubSettings.lineHeight = this.lineHeight; if (this.letterSpacing) epubSettings.letterSpacing = this.letterSpacing; @@ -287,19 +316,21 @@ export class EpubViewerComponent implements OnInit, OnDestroy { private setupTouchListeners(): void { if (!this.isMobileDevice() || this.selectedFlow === 'scrolled') return; - this.rendition.on('rendered', () => { + const container = this.epubContainer.nativeElement; + container.removeEventListener('touchstart', this.onTouchStart.bind(this)); + container.removeEventListener('touchend', this.onTouchEnd.bind(this)); + + container.addEventListener('touchstart', this.onTouchStart.bind(this), {passive: true}); + container.addEventListener('touchend', this.onTouchEnd.bind(this), {passive: true}); + + setTimeout(() => { const iframe = this.epubContainer.nativeElement.querySelector('iframe'); if (iframe && iframe.contentDocument) { const iframeDoc = iframe.contentDocument; - iframeDoc.addEventListener('touchstart', this.onTouchStart.bind(this), {passive: true}); iframeDoc.addEventListener('touchend', this.onTouchEnd.bind(this), {passive: true}); } - }); - - const container = this.epubContainer.nativeElement; - container.addEventListener('touchstart', this.onTouchStart.bind(this), {passive: true}); - container.addEventListener('touchend', this.onTouchEnd.bind(this), {passive: true}); + }, 500); } onTouchStart(event: TouchEvent): void { @@ -328,7 +359,7 @@ export class EpubViewerComponent implements OnInit, OnDestroy { } } - private isMobileDevice(): boolean { + public isMobileDevice(): boolean { return window.innerWidth <= 768; } diff --git a/booklore-ui/src/app/book/model/book.model.ts b/booklore-ui/src/app/book/model/book.model.ts index 081235f9f..6d87e5aac 100644 --- a/booklore-ui/src/app/book/model/book.model.ts +++ b/booklore-ui/src/app/book/model/book.model.ts @@ -187,6 +187,7 @@ export interface EpubViewerSetting { flow: string; lineHeight: number; letterSpacing: number; + spread: string; } export interface CbxViewerSetting { diff --git a/booklore-ui/src/app/metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component.html b/booklore-ui/src/app/metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component.html index 773dc9865..c79e5c284 100644 --- a/booklore-ui/src/app/metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component.html +++ b/booklore-ui/src/app/metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component.html @@ -2,8 +2,8 @@ - - + + - - + + @for (field of nonProviderSpecificFields; track field) { - - - +
SkipMetadata FieldEnabledField 4th Priority
Set All:Set All:
- + + {{ formatLabel(field) }}{{ formatLabel(field) }} @@ -107,12 +106,12 @@

Provider-Specific Fields

-

These fields are unique to specific providers and cannot have custom priority settings. Use the checkboxes to skip fetching these fields entirely.

+

These fields are unique to specific providers and cannot have custom priority settings. Use the checkboxes to enable/disable fetching these fields.

@for (field of providerSpecificFields; track field) {
- {{ formatLabel(field) }}
diff --git a/booklore-ui/src/app/metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component.ts b/booklore-ui/src/app/metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component.ts index c4a6e6864..5d340a5a1 100644 --- a/booklore-ui/src/app/metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component.ts +++ b/booklore-ui/src/app/metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component.ts @@ -50,7 +50,7 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges { reviewBeforeApply: boolean = false; fieldOptions: FieldOptions = this.initializeFieldOptions(); - skipFields: Record = this.initializeSkipFields(); + enabledFields: Record = this.initializeEnabledFields(); bulkP1: string | null = null; bulkP2: string | null = null; @@ -74,9 +74,9 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges { }, {} as FieldOptions); } - private initializeSkipFields(): Record { + private initializeEnabledFields(): Record { return this.fields.reduce((acc, field) => { - acc[field] = false; + acc[field] = true; return acc; }, {} as Record); } @@ -97,10 +97,10 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges { } this.fieldOptions = backendFieldOptions; - if (this.currentMetadataOptions.skipFields) { - this.skipFields = {...this.skipFields, ...this.currentMetadataOptions.skipFields}; + if (this.currentMetadataOptions.enabledFields) { + this.enabledFields = {...this.enabledFields, ...this.currentMetadataOptions.enabledFields}; } else { - this.skipFields = this.initializeSkipFields(); + this.enabledFields = this.initializeEnabledFields(); } } } @@ -120,7 +120,7 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges { submit() { const allFieldsHaveProvider = Object.entries(this.fieldOptions).every(([field, opt]) => - this.skipFields[field as keyof FieldOptions] || + !this.enabledFields[field as keyof FieldOptions] || this.isProviderSpecificField(field as keyof FieldOptions) || opt.p1 !== null || opt.p2 !== null || opt.p3 !== null || opt.p4 !== null ); @@ -134,7 +134,7 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges { mergeCategories: this.mergeCategories, reviewBeforeApply: this.reviewBeforeApply, fieldOptions: this.fieldOptions, - skipFields: this.skipFields + enabledFields: this.enabledFields }; this.metadataOptionsSubmitted.emit(metadataRefreshOptions); @@ -146,7 +146,7 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges { this.messageService.add({ severity: 'error', summary: 'Error', - detail: 'At least one provider (P1–P4) must be selected for each non-skipped book field.', + detail: 'At least one provider (P1–P4) must be selected for each enabled book field.', life: 5000 }); } @@ -158,7 +158,7 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges { const value = provider === 'Clear All' ? null : provider; for (const field of this.nonProviderSpecificFields) { - if (!this.skipFields[field]) { + if (this.enabledFields[field]) { this.fieldOptions[field][priority] = value; } } @@ -189,7 +189,7 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges { p4: null }; } - this.skipFields = this.initializeSkipFields(); + this.enabledFields = this.initializeEnabledFields(); // Reset bulk selectors this.bulkP1 = null; diff --git a/booklore-ui/src/app/metadata/model/request/metadata-refresh-options.model.ts b/booklore-ui/src/app/metadata/model/request/metadata-refresh-options.model.ts index 737993f72..8f1cd97a3 100644 --- a/booklore-ui/src/app/metadata/model/request/metadata-refresh-options.model.ts +++ b/booklore-ui/src/app/metadata/model/request/metadata-refresh-options.model.ts @@ -4,7 +4,7 @@ export interface MetadataRefreshOptions { mergeCategories: boolean; reviewBeforeApply: boolean; fieldOptions?: FieldOptions; - skipFields?: Record; + enabledFields?: Record; } export interface FieldProvider { diff --git a/booklore-ui/src/app/settings/library-metadata-settings-component/library-metadata-settings.component.html b/booklore-ui/src/app/settings/library-metadata-settings-component/library-metadata-settings.component.html index 35a34ff30..3f62426d1 100644 --- a/booklore-ui/src/app/settings/library-metadata-settings-component/library-metadata-settings.component.html +++ b/booklore-ui/src/app/settings/library-metadata-settings-component/library-metadata-settings.component.html @@ -22,6 +22,7 @@ The system checks your 1st priority provider first - if that provider doesn't have the specific field (like description or author), it automatically moves to your 2nd priority, then 3rd, and finally 4th. Leave a priority empty to skip it entirely. For example, if Amazon (1st) has no description but Google Books (2nd) does, Google's description will be used. + Use the Enable checkboxes to completely disable fetching for specific fields - disabled fields will be skipped entirely regardless of provider settings.

@@ -42,6 +43,7 @@

Override the default priority settings for specific libraries. For example, you might prefer Amazon for fiction but Google Books for technical books. Each library can have its own provider priority order while falling back to defaults for unspecified fields. + You can also enable or disable specific metadata fields per library - useful if certain libraries don't need specific data types like descriptions or covers.

diff --git a/booklore-ui/src/app/settings/reader-preferences/cbx-reader-preferences-component/cbx-reader-preferences-component.scss b/booklore-ui/src/app/settings/reader-preferences/cbx-reader-preferences-component/cbx-reader-preferences-component.scss index 8e6450366..2e740666f 100644 --- a/booklore-ui/src/app/settings/reader-preferences/cbx-reader-preferences-component/cbx-reader-preferences-component.scss +++ b/booklore-ui/src/app/settings/reader-preferences/cbx-reader-preferences-component/cbx-reader-preferences-component.scss @@ -43,7 +43,7 @@ .setting-label { margin-bottom: 0; flex-shrink: 0; - min-width: 120px; + min-width: 100px; } .radio-group { diff --git a/booklore-ui/src/app/settings/reader-preferences/cbx-reader-preferences-component/cbx-reader-preferences-component.ts b/booklore-ui/src/app/settings/reader-preferences/cbx-reader-preferences-component/cbx-reader-preferences-component.ts index 8ab7436bb..0b0bf286b 100644 --- a/booklore-ui/src/app/settings/reader-preferences/cbx-reader-preferences-component/cbx-reader-preferences-component.ts +++ b/booklore-ui/src/app/settings/reader-preferences/cbx-reader-preferences-component/cbx-reader-preferences-component.ts @@ -21,18 +21,19 @@ export class CbxReaderPreferencesComponent { private readonly readerPreferencesService = inject(ReaderPreferencesService); readonly cbxSpreads = [ - { name: 'Even', key: 'EVEN' }, - { name: 'Odd', key: 'ODD' } + {name: 'Even', key: 'EVEN'}, + {name: 'Odd', key: 'ODD'} ]; readonly cbxViewModes = [ - { name: 'Single Page', key: 'SINGLE_PAGE' }, - { name: 'Two Page', key: 'TWO_PAGE' }, + {name: 'Single Page', key: 'SINGLE_PAGE'}, + {name: 'Two Page', key: 'TWO_PAGE'}, ]; get selectedCbxSpread(): CbxPageSpread { return this.userSettings.cbxReaderSetting.pageSpread; } + set selectedCbxSpread(value: CbxPageSpread) { this.userSettings.cbxReaderSetting.pageSpread = value; this.readerPreferencesService.updatePreference(['cbxReaderSetting', 'pageSpread'], value); @@ -41,6 +42,7 @@ export class CbxReaderPreferencesComponent { get selectedCbxViewMode(): CbxPageViewMode { return this.userSettings.cbxReaderSetting.pageViewMode; } + set selectedCbxViewMode(value: CbxPageViewMode) { this.userSettings.cbxReaderSetting.pageViewMode = value; this.readerPreferencesService.updatePreference(['cbxReaderSetting', 'pageViewMode'], value); diff --git a/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.html b/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.html index 48c6cf11f..aff22c9ff 100644 --- a/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.html +++ b/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.html @@ -1,77 +1,115 @@ -
-
-
-
- - - -
-

- Choose the visual theme for EPUB reading experience. -

-
-
- -
-
-
- - - -
-

- Select the font family for text display. -

-
-
- -
-
-
- - - -
-

- Configure text flow and reading direction. -

-
-
- -
-
-
- -
- - {{ fontSize }}% - +
+
+
+
+
+ +
+ @for (theme of themes; track theme) { +
+ + + +
+ } +
+

+ Choose the visual theme for EPUB reading experience. +

+
+
+ +
+
+
+ +
+ @for (font of fonts; track font) { +
+ + + +
+ } +
+
+

+ Select the font family for text display. +

+
+
+ +
+
+
+ +
+ @for (flow of flowOptions; track flow) { +
+ + + +
+ } +
+
+

+ Configure text flow and reading direction. +

+
+
+ +
+
+
+ +
+ @for (spread of spreadOptions; track spread) { +
+ + + +
+ } +
+
+

+ Choose between single page or double page spread view. +

+
+
+ +
+
+
+ +
+ + {{ fontSize }}% + +
+
+

+ Adjust the text size for comfortable reading. +

-

- Adjust the text size for comfortable reading. -

diff --git a/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.scss b/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.scss index a014fbbed..bd27e4209 100644 --- a/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.scss +++ b/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.scss @@ -1,7 +1,7 @@ .epub-preferences-container { display: flex; flex-direction: column; - gap: 1rem; + gap: 1.5rem; } .setting-item { @@ -46,26 +46,13 @@ min-width: 100px; } - p-select { - flex: 1; - min-width: 200px; - max-width: 300px; + .radio-group { + display: flex; + gap: 1.5rem; @media (max-width: 768px) { - min-width: 180px; - } - } - - .font-size-controls { - display: flex; - align-items: center; - gap: 0.75rem; - - .font-size-value { - min-width: 3rem; - text-align: center; - font-weight: 500; - color: var(--p-text-color); + flex-direction: column; + gap: 0.75rem; } } } @@ -77,3 +64,38 @@ margin: 0; } } + +.radio-group { + display: flex; + gap: 1.5rem; + + @media (max-width: 768px) { + flex-direction: column; + gap: 0.75rem; + } +} + +.radio-option { + display: flex; + align-items: center; + gap: 0.5rem; + + label { + font-size: 0.875rem; + color: var(--p-text-color); + cursor: pointer; + } +} + +.font-size-controls { + display: flex; + align-items: center; + gap: 1rem; + + .font-size-value { + min-width: 3rem; + text-align: center; + font-weight: 500; + color: var(--p-text-color); + } +} diff --git a/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.ts b/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.ts index f68ca3e08..10fe02518 100644 --- a/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.ts +++ b/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.ts @@ -1,6 +1,6 @@ import {Component, inject, Input} from '@angular/core'; import {Button} from 'primeng/button'; -import {Select} from 'primeng/select'; +import {RadioButton} from 'primeng/radiobutton'; import {FormsModule} from '@angular/forms'; import {ReaderPreferencesService} from '../reader-preferences-service'; import {UserSettings} from '../../user-management/user.service'; @@ -9,7 +9,7 @@ import {UserSettings} from '../../user-management/user.service'; selector: 'app-epub-reader-preferences-component', imports: [ Button, - Select, + RadioButton, FormsModule ], templateUrl: './epub-reader-preferences-component.html', @@ -31,13 +31,16 @@ export class EpubReaderPreferencesComponent { ]; readonly flowOptions = [ - {name: 'Book Default', key: null}, {name: 'Paginated', key: 'paginated'}, {name: 'Scrolled', key: 'scrolled'} ]; + readonly spreadOptions = [ + {name: 'Single Page', key: 'single'}, + {name: 'Double Page', key: 'double'} + ]; + readonly themes = [ - {name: 'Book Default', key: null}, {name: 'White', key: 'white'}, {name: 'Black', key: 'black'}, {name: 'Grey', key: 'grey'}, @@ -77,6 +80,17 @@ export class EpubReaderPreferencesComponent { this.readerPreferencesService.updatePreference(['epubReaderSetting', 'flow'], value); } + get selectedSpread(): string | null { + return this.userSettings.epubReaderSetting.spread; + } + + set selectedSpread(value: string | null) { + if (typeof value === "string") { + this.userSettings.epubReaderSetting.spread = value; + } + this.readerPreferencesService.updatePreference(['epubReaderSetting', 'spread'], value); + } + get fontSize(): number { return this.userSettings.epubReaderSetting.fontSize; } diff --git a/booklore-ui/src/app/settings/reader-preferences/pdf-reader-preferences-component/pdf-reader-preferences-component.scss b/booklore-ui/src/app/settings/reader-preferences/pdf-reader-preferences-component/pdf-reader-preferences-component.scss index 88afb754f..212d8bd35 100644 --- a/booklore-ui/src/app/settings/reader-preferences/pdf-reader-preferences-component/pdf-reader-preferences-component.scss +++ b/booklore-ui/src/app/settings/reader-preferences/pdf-reader-preferences-component/pdf-reader-preferences-component.scss @@ -43,7 +43,7 @@ .setting-label { margin-bottom: 0; flex-shrink: 0; - min-width: 120px; + min-width: 100px; } .radio-group { diff --git a/booklore-ui/src/app/settings/user-management/user.service.ts b/booklore-ui/src/app/settings/user-management/user.service.ts index e3c276f2b..05d0f7ea1 100644 --- a/booklore-ui/src/app/settings/user-management/user.service.ts +++ b/booklore-ui/src/app/settings/user-management/user.service.ts @@ -55,6 +55,7 @@ export interface EpubReaderSetting { font: string; fontSize: number; flow: string; + spread: string; lineHeight: number; margin: number; letterSpacing: number; From 7e4a40e30c1d20c8e577cf950f126f9433b25846 Mon Sep 17 00:00:00 2001 From: Aditya Chandel <8075870+adityachandelgit@users.noreply.github.com> Date: Sun, 5 Oct 2025 11:12:18 -0600 Subject: [PATCH 06/11] Resolve CBX page sequence issue (#1270) --- .../controller/BookMediaController.java | 1 - .../service/reader/CbxReaderService.java | 61 ++++++++++++------- 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java index 5d42ff6ee..3234c0b1c 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java @@ -25,7 +25,6 @@ import java.io.IOException; public class BookMediaController { private final BookService bookService; - private final BookMetadataService bookMetadataService; private final PdfReaderService pdfReaderService; private final CbxReaderService cbxReaderService; private final BookDropService bookDropService; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/reader/CbxReaderService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/reader/CbxReaderService.java index 530063ce1..7cf21605d 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/reader/CbxReaderService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/reader/CbxReaderService.java @@ -40,6 +40,7 @@ public class CbxReaderService { private static final String CBZ_EXTENSION = ".cbz"; private static final String CBR_EXTENSION = ".cbr"; private static final String CB7_EXTENSION = ".cb7"; + private static final String[] SUPPORTED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}; private final BookRepository bookRepository; private final AppSettingService appSettingService; @@ -81,11 +82,13 @@ public class CbxReaderService { } try (var stream = Files.list(cacheDir)) { - return stream - .filter(p -> p.toString().endsWith(".jpg")) + List imageFiles = stream + .filter(p -> isImageFile(p.getFileName().toString())) .sorted(Comparator.comparing(Path::getFileName)) - .map(p -> extractPageNumber(p.getFileName().toString())) - .filter(p -> p != -1) + .toList(); + + return java.util.stream.IntStream.rangeClosed(1, imageFiles.size()) + .boxed() .collect(Collectors.toList()); } catch (IOException e) { log.error("Failed to list pages for book {}", bookId, e); @@ -94,8 +97,21 @@ public class CbxReaderService { } public void streamPageImage(Long bookId, int page, OutputStream outputStream) throws IOException { - Path pagePath = Path.of(fileService.getCbxCachePath(), String.valueOf(bookId), String.format("%04d.jpg", page)); - if (!Files.exists(pagePath)) throw new FileNotFoundException("Page not found: " + page); + Path bookDir = Path.of(fileService.getCbxCachePath(), String.valueOf(bookId)); + List images; + try (Stream files = Files.list(bookDir)) { + images = files + .filter(p -> isImageFile(p.getFileName().toString())) + .sorted(Comparator.comparing(p -> p.getFileName().toString())) + .toList(); + } + if (images.isEmpty()) { + throw new FileNotFoundException("No image files found for book: " + bookId); + } + if (page < 1 || page > images.size()) { + throw new FileNotFoundException("Page out of range: " + page); + } + Path pagePath = images.get(page - 1); try (InputStream in = Files.newInputStream(pagePath)) { IOUtils.copy(in, outputStream); } @@ -119,12 +135,11 @@ public class CbxReaderService { ZipInputStream zis = new ZipInputStream(fis)) { ZipEntry entry; - int index = 1; while ((entry = zis.getNextEntry()) != null) { - isImageFile(entry.getName()); if (!entry.isDirectory() && isImageFile(entry.getName())) { - Path target = targetDir.resolve(String.format("%04d.jpg", index++)); + String fileName = extractFileNameFromPath(entry.getName()); + Path target = targetDir.resolve(fileName); Files.copy(zis, target, StandardCopyOption.REPLACE_EXISTING); } zis.closeEntry(); @@ -135,10 +150,10 @@ public class CbxReaderService { private void extract7zArchive(Path cb7Path, Path targetDir) throws IOException { try (SevenZFile sevenZFile = new SevenZFile(cb7Path.toFile())) { SevenZArchiveEntry entry; - int index = 1; while ((entry = sevenZFile.getNextEntry()) != null) { if (!entry.isDirectory() && isImageFile(entry.getName())) { - Path target = targetDir.resolve(String.format("%04d.jpg", index++)); + String fileName = extractFileNameFromPath(entry.getName()); + Path target = targetDir.resolve(fileName); try (OutputStream out = Files.newOutputStream(target)) { copySevenZEntry(sevenZFile, out, entry.getSize()); } @@ -160,10 +175,10 @@ public class CbxReaderService { private void extractRarArchive(Path cbrPath, Path targetDir) throws IOException { try (Archive archive = new Archive(cbrPath.toFile())) { List headers = archive.getFileHeaders(); - int index = 1; for (FileHeader header : headers) { if (!header.isDirectory() && isImageFile(header.getFileName())) { - Path target = targetDir.resolve(String.format("%04d.jpg", index++)); + String fileName = extractFileNameFromPath(header.getFileName()); + Path target = targetDir.resolve(fileName); try (OutputStream out = Files.newOutputStream(target)) { archive.extractFile(header, out); } @@ -174,19 +189,23 @@ public class CbxReaderService { } } - private boolean isImageFile(String name) { - String lower = name.toLowerCase().replace("\\", "/"); - return lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png") || lower.endsWith(".webp"); + private String extractFileNameFromPath(String fullPath) { + String normalizedPath = fullPath.replace("\\", "/"); + int lastSlash = normalizedPath.lastIndexOf('/'); + return lastSlash >= 0 ? normalizedPath.substring(lastSlash + 1) : normalizedPath; } - private int extractPageNumber(String filename) { - try { - return Integer.parseInt(filename.replaceAll("\\D+", "")); - } catch (Exception e) { - return -1; + private boolean isImageFile(String name) { + String lower = name.toLowerCase().replace("\\", "/"); + for (String extension : SUPPORTED_IMAGE_EXTENSIONS) { + if (lower.endsWith(extension)) { + return true; + } } + return false; } + private boolean needsCacheRefresh(Path cbzPath, Path cacheInfoPath) throws IOException { if (!Files.exists(cacheInfoPath)) return true; From 78fd5091079dc47b598cd123658dcb0c0c9f154c Mon Sep 17 00:00:00 2001 From: Aditya Chandel <8075870+adityachandelgit@users.noreply.github.com> Date: Sun, 5 Oct 2025 12:54:02 -0600 Subject: [PATCH 07/11] Add guide for obtaining a Hardcover API token (#1272) --- .../metadata-provider-settings.component.html | 16 ++++++++++++---- .../metadata-provider-settings.component.ts | 4 ++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/booklore-ui/src/app/settings/global-preferences/metadata-provider-settings/metadata-provider-settings.component.html b/booklore-ui/src/app/settings/global-preferences/metadata-provider-settings/metadata-provider-settings.component.html index a7530a50d..823ceea57 100644 --- a/booklore-ui/src/app/settings/global-preferences/metadata-provider-settings/metadata-provider-settings.component.html +++ b/booklore-ui/src/app/settings/global-preferences/metadata-provider-settings/metadata-provider-settings.component.html @@ -49,7 +49,7 @@ placeholder="Paste your Amazon cookie" fluid class="max-w-[60rem]" - [(ngModel)]="amazonCookie" /> + [(ngModel)]="amazonCookie"/>
@@ -86,7 +86,15 @@
- +
+ + + +
+ (ngModelChange)="onTokenChange($event)"/>
@@ -121,7 +129,7 @@ fluid class="max-w-[60rem]" [(ngModel)]="comicvineToken" - (ngModelChange)="onComicTokenChange($event)" /> + (ngModelChange)="onComicTokenChange($event)"/>
diff --git a/booklore-ui/src/app/settings/global-preferences/metadata-provider-settings/metadata-provider-settings.component.ts b/booklore-ui/src/app/settings/global-preferences/metadata-provider-settings/metadata-provider-settings.component.ts index a887307f2..788070776 100644 --- a/booklore-ui/src/app/settings/global-preferences/metadata-provider-settings/metadata-provider-settings.component.ts +++ b/booklore-ui/src/app/settings/global-preferences/metadata-provider-settings/metadata-provider-settings.component.ts @@ -148,4 +148,8 @@ export class MetadataProviderSettingsComponent implements OnInit { navigateToAmazonCookieDocumentation() { window.open('https://booklore-app.github.io/booklore-docs/docs/metadata/amazon-cookie', '_blank'); } + + navigateToHardcoverTokenDocumentation() { + window.open('https://booklore-app.github.io/booklore-docs/docs/metadata/hardcover-token', '_blank'); + } } From fd0e5de66c0e3cf63fbead70b319a55f8baaee39 Mon Sep 17 00:00:00 2001 From: Aditya Chandel <8075870+adityachandelgit@users.noreply.github.com> Date: Sun, 5 Oct 2025 13:39:08 -0600 Subject: [PATCH 08/11] Add step-by-step documentation for setting up email integration (#1273) --- .../email/email-provider/email-provider.component.html | 6 ++++++ .../email/email-provider/email-provider.component.scss | 5 +++++ .../email/email-provider/email-provider.component.ts | 8 ++++++-- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/booklore-ui/src/app/settings/email/email-provider/email-provider.component.html b/booklore-ui/src/app/settings/email/email-provider/email-provider.component.html index 195a0b56a..70b6990b4 100644 --- a/booklore-ui/src/app/settings/email/email-provider/email-provider.component.html +++ b/booklore-ui/src/app/settings/email/email-provider/email-provider.component.html @@ -3,6 +3,12 @@

Email Providers + +

Configure email-sending services like Gmail, Outlook, or custom SMTP servers for sending books via email. The default email provider will be used for 'Quick Book Send' located in the Book Card menu. diff --git a/booklore-ui/src/app/settings/email/email-provider/email-provider.component.scss b/booklore-ui/src/app/settings/email/email-provider/email-provider.component.scss index 98306e3d1..080e8c8a9 100644 --- a/booklore-ui/src/app/settings/email/email-provider/email-provider.component.scss +++ b/booklore-ui/src/app/settings/email/email-provider/email-provider.component.scss @@ -20,6 +20,11 @@ color: var(--p-primary-color); font-size: 1.25rem; } + + .external-link-icon { + color: #0ea5e9 !important; + font-size: 0.875rem !important; + } } .settings-description { diff --git a/booklore-ui/src/app/settings/email/email-provider/email-provider.component.ts b/booklore-ui/src/app/settings/email/email-provider/email-provider.component.ts index 8dd96a671..06576b8d3 100644 --- a/booklore-ui/src/app/settings/email/email-provider/email-provider.component.ts +++ b/booklore-ui/src/app/settings/email/email-provider/email-provider.component.ts @@ -23,7 +23,7 @@ import {CreateEmailProviderDialogComponent} from '../create-email-provider-dialo TableModule, Tooltip, FormsModule -], + ], templateUrl: './email-provider.component.html', styleUrl: './email-provider.component.scss' }) @@ -117,7 +117,7 @@ export class EmailProviderComponent implements OnInit { header: 'Create Email Provider', modal: true, closable: true, - style: { position: 'absolute', top: '15%' }, + style: {position: 'absolute', top: '15%'}, }); this.ref.onClose.subscribe((result) => { if (result) { @@ -136,4 +136,8 @@ export class EmailProviderComponent implements OnInit { }); }); } + + navigateToEmailDocumentation() { + window.open('https://booklore-app.github.io/booklore-docs/docs/email-setup', '_blank'); + } } From b591fa95ae1e89abcbbfaf4c96ff7db4e588cc2c Mon Sep 17 00:00:00 2001 From: Aditya Chandel <8075870+adityachandelgit@users.noreply.github.com> Date: Sun, 5 Oct 2025 14:08:37 -0600 Subject: [PATCH 09/11] Add OPDS setup and usage documentation (#1274) --- .../src/app/settings/opds-settings-v2/opds-settings-v2.html | 6 ++++++ .../src/app/settings/opds-settings-v2/opds-settings-v2.scss | 5 +++++ .../src/app/settings/opds-settings-v2/opds-settings-v2.ts | 4 ++++ 3 files changed, 15 insertions(+) diff --git a/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.html b/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.html index 502182df8..d7de68a05 100644 --- a/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.html +++ b/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.html @@ -3,6 +3,12 @@

OPDS Settings (New) + +

Manage your OPDS credentials and control how your book collection is shared with reading apps. diff --git a/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.scss b/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.scss index 1cf145cba..0675ec017 100644 --- a/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.scss +++ b/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.scss @@ -34,6 +34,11 @@ color: var(--p-primary-color); font-size: 1.25rem; } + + .external-link-icon { + color: #0ea5e9 !important; + font-size: 0.875rem !important; + } } .settings-description { diff --git a/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.ts b/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.ts index d0b2e5f2f..29af37e58 100644 --- a/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.ts +++ b/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.ts @@ -203,6 +203,10 @@ export class OpdsSettingsV2 implements OnInit, OnDestroy { this.messageService.add({severity, summary, detail}); } + navigateToOpdsDoc(): void { + window.open('https://booklore-app.github.io/booklore-docs/docs/integration/opds', '_blank'); + } + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); From 7133ef1a69b42ca975f4541f2dd324a37f97025b Mon Sep 17 00:00:00 2001 From: Aditya Chandel <8075870+adityachandelgit@users.noreply.github.com> Date: Mon, 6 Oct 2025 12:53:34 -0600 Subject: [PATCH 10/11] Upgrade project environment to Java 25 and Gradle 9 (#1277) --- Dockerfile | 4 +-- booklore-api/build.gradle | 26 ++++++++---------- .../gradle/wrapper/gradle-wrapper.jar | Bin 43583 -> 43764 bytes .../gradle/wrapper/gradle-wrapper.properties | 2 +- booklore-api/gradlew | 9 +++--- booklore-api/gradlew.bat | 6 ++-- .../service/bookdrop/BookDropServiceTest.java | 4 --- .../util/builder/LibraryTestBuilder.java | 5 ++-- 8 files changed, 24 insertions(+), 32 deletions(-) diff --git a/Dockerfile b/Dockerfile index 19748a334..43b66e65f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ COPY ./booklore-ui /angular-app/ RUN npm run build --configuration=production # Stage 2: Build the Spring Boot app with Gradle -FROM gradle:8-jdk21-alpine AS springboot-build +FROM gradle:9.1-jdk25-alpine AS springboot-build WORKDIR /springboot-app @@ -29,7 +29,7 @@ RUN apk add --no-cache yq && \ RUN gradle clean build -x test # Stage 3: Final image -FROM eclipse-temurin:21-jre-alpine +FROM eclipse-temurin:25-jre-alpine RUN apk update && apk add nginx gettext su-exec diff --git a/booklore-api/build.gradle b/booklore-api/build.gradle index fe290f70d..464d0573c 100644 --- a/booklore-api/build.gradle +++ b/booklore-api/build.gradle @@ -2,8 +2,8 @@ plugins { id 'java' id 'org.springframework.boot' version '3.5.1' id 'io.spring.dependency-management' version '1.1.7' - id 'org.hibernate.orm' version '7.1.0.Final' - id 'com.github.ben-manes.versions' version '0.52.0' + id 'org.hibernate.orm' version '7.1.3.Final' + id 'com.github.ben-manes.versions' version '0.53.0' } group = 'com.adityachandel' @@ -11,7 +11,7 @@ version = '0.0.1-SNAPSHOT' java { toolchain { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(25) } } @@ -39,18 +39,18 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' // --- Database & Migration --- - implementation 'org.mariadb.jdbc:mariadb-java-client:3.5.4' - implementation 'org.flywaydb:flyway-mysql:11.11.0' + implementation 'org.mariadb.jdbc:mariadb-java-client:3.5.6' + implementation 'org.flywaydb:flyway-mysql:11.13.2' implementation 'jakarta.persistence:jakarta.persistence-api:3.2.0' // --- Security & Authentication --- - implementation 'io.jsonwebtoken:jjwt-api:0.12.6' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + implementation 'io.jsonwebtoken:jjwt-api:0.13.0' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0' // --- Lombok (For Clean Code) --- - compileOnly 'org.projectlombok:lombok:1.18.38' - annotationProcessor 'org.projectlombok:lombok:1.18.38' + compileOnly 'org.projectlombok:lombok:1.18.42' + annotationProcessor 'org.projectlombok:lombok:1.18.42' // --- Book & Image Processing --- implementation 'org.apache.pdfbox:pdfbox:3.0.5' @@ -64,8 +64,8 @@ dependencies { implementation 'com.github.junrar:junrar:7.5.5' // --- JSON & Web Scraping --- - implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.19.2' - implementation 'org.jsoup:jsoup:1.21.1' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.0' + implementation 'org.jsoup:jsoup:1.21.2' // --- Mapping (DTOs & Entities) --- implementation 'org.mapstruct:mapstruct:1.6.3' @@ -80,8 +80,6 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.assertj:assertj-core:3.27.3' testImplementation "org.mockito:mockito-inline:5.2.0" - testImplementation 'org.testcontainers:junit-jupiter:1.20.4' - testImplementation 'org.testcontainers:mariadb:1.20.4' } hibernate { diff --git a/booklore-api/gradle/wrapper/gradle-wrapper.jar b/booklore-api/gradle/wrapper/gradle-wrapper.jar index a4b76b9530d66f5e68d973ea569d8e19de379189..1b33c55baabb587c669f562ae36f953de2481846 100644 GIT binary patch delta 34943 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

Gn4F+B~IivciU9^t0j-Mgao3 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 DDw4oP diff --git a/booklore-api/gradle/wrapper/gradle-wrapper.properties b/booklore-api/gradle/wrapper/gradle-wrapper.properties index 002b867c4..2e1113280 100644 --- a/booklore-api/gradle/wrapper/gradle-wrapper.properties +++ b/booklore-api/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/booklore-api/gradlew b/booklore-api/gradlew index f5feea6d6..23d15a936 100755 --- a/booklore-api/gradlew +++ b/booklore-api/gradlew @@ -86,8 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s -' "$PWD" ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -115,7 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -206,7 +205,7 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. @@ -214,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/booklore-api/gradlew.bat b/booklore-api/gradlew.bat index 339847418..db3a6ac20 100644 --- a/booklore-api/gradlew.bat +++ b/booklore-api/gradlew.bat @@ -1,5 +1,5 @@ @rem -@rem Copyright 2015 the original authorEntity or authorEntities. +@rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @@ -70,11 +70,11 @@ goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/bookdrop/BookDropServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/bookdrop/BookDropServiceTest.java index a742f7795..eea172eb0 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/bookdrop/BookDropServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/bookdrop/BookDropServiceTest.java @@ -1,7 +1,6 @@ package com.adityachandel.booklore.service.bookdrop; import com.adityachandel.booklore.config.AppProperties; -import com.adityachandel.booklore.exception.APIException; import com.adityachandel.booklore.mapper.BookdropFileMapper; import com.adityachandel.booklore.model.FileProcessResult; import com.adityachandel.booklore.model.dto.Book; @@ -14,7 +13,6 @@ import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.BookdropFileEntity; 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.repository.BookRepository; import com.adityachandel.booklore.repository.BookdropFileRepository; import com.adityachandel.booklore.repository.LibraryRepository; @@ -25,7 +23,6 @@ import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistr import com.adityachandel.booklore.service.metadata.MetadataRefreshService; import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.Ignore; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -44,7 +41,6 @@ import org.springframework.data.domain.Pageable; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.time.Instant; import java.util.List; import java.util.Optional; diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/util/builder/LibraryTestBuilder.java b/booklore-api/src/test/java/com/adityachandel/booklore/util/builder/LibraryTestBuilder.java index 325154e22..7e6f91dee 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/util/builder/LibraryTestBuilder.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/util/builder/LibraryTestBuilder.java @@ -13,10 +13,9 @@ import com.adityachandel.booklore.service.FileFingerprint; import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor; import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry; import com.adityachandel.booklore.util.FileUtils; +import jakarta.validation.constraints.NotNull; import org.apache.commons.io.FilenameUtils; -import org.jetbrains.annotations.NotNull; import org.mockito.MockedStatic; -import org.mockito.Mockito; import java.nio.file.Path; import java.security.MessageDigest; @@ -28,8 +27,8 @@ import java.util.Map; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.when; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; /** * Test builder for creating Library-related test objects. From 93d496157e1ffc5992514bcea99e5847646e34e8 Mon Sep 17 00:00:00 2001 From: WorldTeacher <41587052+WorldTeacher@users.noreply.github.com> Date: Mon, 6 Oct 2025 20:58:16 +0200 Subject: [PATCH 11/11] Feat: set reading status using Koreader Sync Plugin (#1275) Co-authored-by: WorldTeacher --- .../adityachandel/booklore/service/BookService.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java index 5cacb26de..f8b5975b4 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java @@ -157,10 +157,16 @@ public class BookService { .percentage(userProgress.getEpubProgressPercent()) .build()); if (userProgress.getKoreaderProgressPercent() != null) { - if (book.getKoreaderProgress() == null) { - book.setKoreaderProgress(KoProgress.builder().build()); + var userReadProgress = userProgress.getKoreaderProgressPercent() * 100; + book.getKoreaderProgress().setPercentage(userReadProgress); + if (userReadProgress >= 99.5f) { + book.setReadStatus(String.valueOf(ReadStatus.READ)); + } else if (userReadProgress > 0.01f) { + book.setReadStatus(String.valueOf(ReadStatus.READING)); + } else { + book.setReadStatus(String.valueOf(ReadStatus.UNREAD)); } - book.getKoreaderProgress().setPercentage(userProgress.getKoreaderProgressPercent() * 100); + } } if (bookEntity.getBookType() == BookFileType.CBX) {