From 0899b9918822d9f320cc0967f8fa531f99eca504 Mon Sep 17 00:00:00 2001 From: ACX <8075870+acx10@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:41:03 -0700 Subject: [PATCH] feat: add metadata presence filter to magic shelf (#2757) --- .../org/booklore/model/dto/RuleField.java | 4 +- .../service/BookRuleEvaluatorService.java | 104 +- ...okRuleEvaluatorServiceIntegrationTest.java | 903 ++++++++++++++++++ .../component/magic-shelf-component.html | 2 + .../component/magic-shelf-component.spec.ts | 76 +- .../component/magic-shelf-component.ts | 86 +- ...k-rule-evaluator-metadata-presence.spec.ts | 533 +++++++++++ .../service/book-rule-evaluator.service.ts | 74 ++ booklore-ui/src/i18n/en/magic-shelf.json | 73 +- booklore-ui/src/i18n/es/magic-shelf.json | 73 +- 10 files changed, 1913 insertions(+), 15 deletions(-) create mode 100644 booklore-ui/src/app/features/magic-shelf/service/book-rule-evaluator-metadata-presence.spec.ts diff --git a/booklore-api/src/main/java/org/booklore/model/dto/RuleField.java b/booklore-api/src/main/java/org/booklore/model/dto/RuleField.java index 3587e0ab2..14cb66224 100644 --- a/booklore-api/src/main/java/org/booklore/model/dto/RuleField.java +++ b/booklore-api/src/main/java/org/booklore/model/dto/RuleField.java @@ -96,6 +96,8 @@ public enum RuleField { @JsonProperty("seriesPosition") SERIES_POSITION, @JsonProperty("readingProgress") - READING_PROGRESS + READING_PROGRESS, + @JsonProperty("metadataPresence") + METADATA_PRESENCE } diff --git a/booklore-api/src/main/java/org/booklore/service/BookRuleEvaluatorService.java b/booklore-api/src/main/java/org/booklore/service/BookRuleEvaluatorService.java index 4b68cecfb..afa7d0930 100644 --- a/booklore-api/src/main/java/org/booklore/service/BookRuleEvaluatorService.java +++ b/booklore-api/src/main/java/org/booklore/service/BookRuleEvaluatorService.java @@ -7,8 +7,8 @@ import org.booklore.model.dto.GroupRule; import org.booklore.model.dto.Rule; import org.booklore.model.dto.RuleField; import org.booklore.model.dto.RuleOperator; -import org.booklore.model.entity.BookEntity; -import org.booklore.model.entity.UserBookProgressEntity; +import org.booklore.model.entity.*; +import org.booklore.model.enums.ComicCreatorRole; import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import tools.jackson.core.type.TypeReference; @@ -89,6 +89,10 @@ public class BookRuleEvaluatorService { private Predicate buildRulePredicate(Rule rule, CriteriaQuery query, CriteriaBuilder cb, Root root, Join progressJoin, Long userId) { if (rule.getField() == null || rule.getOperator() == null) return null; + if (rule.getField() == RuleField.METADATA_PRESENCE) { + return buildMetadataPresencePredicate(rule, query, cb, root, progressJoin); + } + if (COMPOSITE_FIELDS.contains(rule.getField())) { return buildCompositeFieldPredicate(rule, query, cb, root, progressJoin, userId); } @@ -206,6 +210,102 @@ public class BookRuleEvaluatorService { return negate ? cb.not(result) : result; } + private Predicate buildMetadataPresencePredicate(Rule rule, CriteriaQuery query, CriteriaBuilder cb, Root root, Join progressJoin) { + boolean hasOperator = rule.getOperator() == RuleOperator.EQUALS; + String metadataField = rule.getValue() != null ? rule.getValue().toString() : ""; + Predicate isPresent = buildFieldPresencePredicate(metadataField, query, cb, root, progressJoin); + return hasOperator ? isPresent : cb.not(isPresent); + } + + private Predicate buildFieldPresencePredicate(String metadataField, CriteriaQuery query, CriteriaBuilder cb, Root root, Join progressJoin) { + return switch (metadataField) { + // Cover image — stored as hash on BookEntity + case "thumbnailUrl" -> cb.isNotNull(root.get("bookCoverHash")); + + // Personal rating — on progress join + case "personalRating" -> cb.isNotNull(progressJoin.get("personalRating")); + + // Audiobook duration — on BookFileEntity + case "audiobookDuration" -> { + Subquery sub = query.subquery(Long.class); + Root subRoot = sub.from(BookFileEntity.class); + sub.select(cb.literal(1L)).where( + cb.equal(subRoot.get("book").get("id"), root.get("id")), + cb.isNotNull(subRoot.get("durationSeconds")) + ); + yield cb.exists(sub); + } + + // Collection fields on BookMetadataEntity + case "authors" -> collectionPresence(query, cb, root, "authors"); + case "categories" -> collectionPresence(query, cb, root, "categories"); + case "moods" -> collectionPresence(query, cb, root, "moods"); + case "tags" -> collectionPresence(query, cb, root, "tags"); + + // Comic collection fields + case "comicCharacters" -> comicCollectionPresence(query, cb, root, "characters"); + case "comicTeams" -> comicCollectionPresence(query, cb, root, "teams"); + case "comicLocations" -> comicCollectionPresence(query, cb, root, "locations"); + + // Comic creator role fields + case "comicPencillers" -> comicCreatorPresence(query, cb, root, ComicCreatorRole.PENCILLER); + case "comicInkers" -> comicCreatorPresence(query, cb, root, ComicCreatorRole.INKER); + case "comicColorists" -> comicCreatorPresence(query, cb, root, ComicCreatorRole.COLORIST); + case "comicLetterers" -> comicCreatorPresence(query, cb, root, ComicCreatorRole.LETTERER); + case "comicCoverArtists" -> comicCreatorPresence(query, cb, root, ComicCreatorRole.COVER_ARTIST); + case "comicEditors" -> comicCreatorPresence(query, cb, root, ComicCreatorRole.EDITOR); + + // String fields on BookMetadataEntity + case "title", "subtitle", "description", "publisher", "language", "seriesName", + "isbn13", "isbn10", "asin", "contentRating", "narrator", + "goodreadsId", "hardcoverId", "googleId", "audibleId", + "lubimyczytacId", "ranobedbId", "comicvineId" -> + stringPresence(cb, root.get("metadata").get(metadataField)); + + // Numeric/date/boolean fields on BookMetadataEntity + case "pageCount", "seriesNumber", "seriesTotal", "ageRating", "publishedDate", "abridged", + "amazonRating", "goodreadsRating", "hardcoverRating", "ranobedbRating", + "lubimyczytacRating", "audibleRating", + "amazonReviewCount", "goodreadsReviewCount", "hardcoverReviewCount", "audibleReviewCount" -> + cb.isNotNull(root.get("metadata").get(metadataField)); + + default -> cb.conjunction(); + }; + } + + private Predicate stringPresence(CriteriaBuilder cb, Expression field) { + return cb.and(cb.isNotNull(field), cb.notEqual(cb.trim(field.as(String.class)), "")); + } + + private Predicate collectionPresence(CriteriaQuery query, CriteriaBuilder cb, Root root, String collectionName) { + Subquery sub = query.subquery(Long.class); + Root subRoot = sub.from(BookEntity.class); + Join metadataJoin = subRoot.join("metadata", JoinType.INNER); + metadataJoin.join(collectionName, JoinType.INNER); + sub.select(cb.literal(1L)).where(cb.equal(subRoot.get("id"), root.get("id"))); + return cb.exists(sub); + } + + private Predicate comicCollectionPresence(CriteriaQuery query, CriteriaBuilder cb, Root root, String collectionName) { + Subquery sub = query.subquery(Long.class); + Root subRoot = sub.from(BookEntity.class); + Join metadataJoin = subRoot.join("metadata", JoinType.INNER); + Join comicJoin = metadataJoin.join("comicMetadata", JoinType.INNER); + comicJoin.join(collectionName, JoinType.INNER); + sub.select(cb.literal(1L)).where(cb.equal(subRoot.get("id"), root.get("id"))); + return cb.exists(sub); + } + + private Predicate comicCreatorPresence(CriteriaQuery query, CriteriaBuilder cb, Root root, ComicCreatorRole role) { + Subquery sub = query.subquery(Long.class); + Root subRoot = sub.from(ComicCreatorMappingEntity.class); + sub.select(cb.literal(1L)).where( + cb.equal(subRoot.get("comicMetadata").get("bookId"), root.get("id")), + cb.equal(subRoot.get("role"), role) + ); + return cb.exists(sub); + } + private Predicate buildSeriesStatusPredicate(String value, CriteriaQuery query, CriteriaBuilder cb, Root root, Predicate hasSeries, Long userId) { Predicate condition = switch (value) { case "reading" -> seriesHasReadStatus(query, cb, root, userId, List.of("READING", "RE_READING")); diff --git a/booklore-api/src/test/java/org/booklore/service/BookRuleEvaluatorServiceIntegrationTest.java b/booklore-api/src/test/java/org/booklore/service/BookRuleEvaluatorServiceIntegrationTest.java index 271851bec..322b1f3ce 100644 --- a/booklore-api/src/test/java/org/booklore/service/BookRuleEvaluatorServiceIntegrationTest.java +++ b/booklore-api/src/test/java/org/booklore/service/BookRuleEvaluatorServiceIntegrationTest.java @@ -4,6 +4,8 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import org.booklore.model.dto.*; import org.booklore.model.entity.*; +import org.booklore.model.enums.BookFileType; +import org.booklore.model.enums.ComicCreatorRole; import org.booklore.model.enums.ReadStatus; import org.booklore.repository.BookRepository; import org.junit.jupiter.api.BeforeEach; @@ -1493,6 +1495,907 @@ class BookRuleEvaluatorServiceIntegrationTest { } + @Nested + class MetadataPresenceTests { + @Test + void has_stringField_matchesWhenPresent() { + BookEntity withDesc = createBook("With Description"); + withDesc.getMetadata().setDescription("A great book"); + em.merge(withDesc.getMetadata()); + + BookEntity noDesc = createBook("No Description"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "description")); + assertThat(ids).contains(withDesc.getId()); + assertThat(ids).doesNotContain(noDesc.getId()); + } + + @Test + void hasNot_stringField_matchesWhenAbsent() { + BookEntity withDesc = createBook("With Description"); + withDesc.getMetadata().setDescription("A great book"); + em.merge(withDesc.getMetadata()); + + BookEntity noDesc = createBook("No Description"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.NOT_EQUALS, "description")); + assertThat(ids).contains(noDesc.getId()); + assertThat(ids).doesNotContain(withDesc.getId()); + } + + @Test + void has_emptyStringTreatedAsAbsent() { + BookEntity emptyDesc = createBook("Empty Description"); + emptyDesc.getMetadata().setDescription(" "); + em.merge(emptyDesc.getMetadata()); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "description")); + assertThat(ids).doesNotContain(emptyDesc.getId()); + } + + @Test + void has_numericField_matchesWhenPresent() { + BookEntity withPages = createBook("With Pages"); + withPages.getMetadata().setPageCount(300); + em.merge(withPages.getMetadata()); + + BookEntity noPages = createBook("No Pages"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "pageCount")); + assertThat(ids).contains(withPages.getId()); + assertThat(ids).doesNotContain(noPages.getId()); + } + + @Test + void has_isbn13_matchesWhenPresent() { + BookEntity withIsbn = createBook("With ISBN"); + withIsbn.getMetadata().setIsbn13("9781234567890"); + em.merge(withIsbn.getMetadata()); + + BookEntity noIsbn = createBook("No ISBN"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "isbn13")); + assertThat(ids).contains(withIsbn.getId()); + assertThat(ids).doesNotContain(noIsbn.getId()); + } + + @Test + void has_coverImage_matchesWhenPresent() { + BookEntity withCover = createBook("With Cover"); + withCover.setBookCoverHash("abc123"); + em.merge(withCover); + + BookEntity noCover = createBook("No Cover"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "thumbnailUrl")); + assertThat(ids).contains(withCover.getId()); + assertThat(ids).doesNotContain(noCover.getId()); + } + + @Test + void has_authors_matchesWhenPresent() { + BookEntity withAuthors = createBook("With Authors"); + AuthorEntity author = AuthorEntity.builder().name("Test Author MP").build(); + em.persist(author); + withAuthors.getMetadata().setAuthors(new HashSet<>(Set.of(author))); + em.merge(withAuthors.getMetadata()); + + BookEntity noAuthors = createBook("No Authors"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "authors")); + assertThat(ids).contains(withAuthors.getId()); + assertThat(ids).doesNotContain(noAuthors.getId()); + } + + @Test + void hasNot_authors_matchesWhenAbsent() { + BookEntity withAuthors = createBook("With Authors"); + AuthorEntity author = AuthorEntity.builder().name("Test Author MP2").build(); + em.persist(author); + withAuthors.getMetadata().setAuthors(new HashSet<>(Set.of(author))); + em.merge(withAuthors.getMetadata()); + + BookEntity noAuthors = createBook("No Authors"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.NOT_EQUALS, "authors")); + assertThat(ids).contains(noAuthors.getId()); + assertThat(ids).doesNotContain(withAuthors.getId()); + } + + @Test + void has_categories_matchesWhenPresent() { + BookEntity withCats = createBook("With Categories"); + CategoryEntity cat = CategoryEntity.builder().name("Fiction MP").build(); + em.persist(cat); + withCats.getMetadata().setCategories(new HashSet<>(Set.of(cat))); + em.merge(withCats.getMetadata()); + + BookEntity noCats = createBook("No Categories"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "categories")); + assertThat(ids).contains(withCats.getId()); + assertThat(ids).doesNotContain(noCats.getId()); + } + + @Test + void has_rating_matchesWhenPresent() { + BookEntity withRating = createBook("With Rating"); + withRating.getMetadata().setAmazonRating(4.5); + em.merge(withRating.getMetadata()); + + BookEntity noRating = createBook("No Rating"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "amazonRating")); + assertThat(ids).contains(withRating.getId()); + assertThat(ids).doesNotContain(noRating.getId()); + } + + @Test + void has_externalId_matchesWhenPresent() { + BookEntity withId = createBook("With Goodreads ID"); + withId.getMetadata().setGoodreadsId("12345"); + em.merge(withId.getMetadata()); + + BookEntity noId = createBook("No Goodreads ID"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "goodreadsId")); + assertThat(ids).contains(withId.getId()); + assertThat(ids).doesNotContain(noId.getId()); + } + + @Test + void has_personalRating_matchesWhenPresent() { + BookEntity withRating = createBook("With Personal Rating"); + UserBookProgressEntity progress = createProgress(withRating, ReadStatus.READ); + progress.setPersonalRating(4); + em.merge(progress); + + BookEntity noRating = createBook("No Personal Rating"); + createProgress(noRating, ReadStatus.READING); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "personalRating")); + assertThat(ids).contains(withRating.getId()); + assertThat(ids).doesNotContain(noRating.getId()); + } + + @Test + void has_publishedDate_matchesWhenPresent() { + BookEntity withDate = createBook("With Published Date"); + withDate.getMetadata().setPublishedDate(LocalDate.of(2023, 1, 1)); + em.merge(withDate.getMetadata()); + + BookEntity noDate = createBook("No Published Date"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "publishedDate")); + assertThat(ids).contains(withDate.getId()); + assertThat(ids).doesNotContain(noDate.getId()); + } + + @Test + void has_seriesInfo_matchesWhenPresent() { + BookEntity withSeries = createBook("With Series", "My Series", 1f, 3); + + BookEntity noSeries = createBook("No Series"); + em.flush(); + em.clear(); + + List idsName = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "seriesName")); + assertThat(idsName).contains(withSeries.getId()); + assertThat(idsName).doesNotContain(noSeries.getId()); + + List idsNumber = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "seriesNumber")); + assertThat(idsNumber).contains(withSeries.getId()); + assertThat(idsNumber).doesNotContain(noSeries.getId()); + + List idsTotal = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "seriesTotal")); + assertThat(idsTotal).contains(withSeries.getId()); + assertThat(idsTotal).doesNotContain(noSeries.getId()); + } + + @Test + void has_narrator_matchesWhenPresent() { + BookEntity withNarrator = createBook("With Narrator"); + withNarrator.getMetadata().setNarrator("John Smith"); + em.merge(withNarrator.getMetadata()); + + BookEntity noNarrator = createBook("No Narrator"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "narrator")); + assertThat(ids).contains(withNarrator.getId()); + assertThat(ids).doesNotContain(noNarrator.getId()); + } + + @Test + void unknownField_matchesAll() { + BookEntity book = createBook("Any Book"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "nonExistentField")); + assertThat(ids).contains(book.getId()); + } + + @Test + void has_moods_matchesWhenPresent() { + BookEntity withMoods = createBook("With Moods"); + MoodEntity mood = MoodEntity.builder().name("Dark MP").build(); + em.persist(mood); + withMoods.getMetadata().setMoods(new HashSet<>(Set.of(mood))); + em.merge(withMoods.getMetadata()); + + BookEntity noMoods = createBook("No Moods"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "moods")); + assertThat(ids).contains(withMoods.getId()); + assertThat(ids).doesNotContain(noMoods.getId()); + } + + @Test + void has_tags_matchesWhenPresent() { + BookEntity withTags = createBook("With Tags"); + TagEntity tag = TagEntity.builder().name("Favorite MP").build(); + em.persist(tag); + withTags.getMetadata().setTags(new HashSet<>(Set.of(tag))); + em.merge(withTags.getMetadata()); + + BookEntity noTags = createBook("No Tags"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "tags")); + assertThat(ids).contains(withTags.getId()); + assertThat(ids).doesNotContain(noTags.getId()); + } + + @Test + void has_multipleFieldsCombined_andLogic() { + BookEntity withBoth = createBook("With Both"); + withBoth.getMetadata().setDescription("A book"); + withBoth.getMetadata().setIsbn13("9781234567890"); + em.merge(withBoth.getMetadata()); + + BookEntity withOneOnly = createBook("With One Only"); + withOneOnly.getMetadata().setDescription("A book"); + em.merge(withOneOnly.getMetadata()); + em.flush(); + em.clear(); + + Rule rule1 = new Rule(); + rule1.setField(RuleField.METADATA_PRESENCE); + rule1.setOperator(RuleOperator.EQUALS); + rule1.setValue("description"); + + Rule rule2 = new Rule(); + rule2.setField(RuleField.METADATA_PRESENCE); + rule2.setOperator(RuleOperator.EQUALS); + rule2.setValue("isbn13"); + + GroupRule group = new GroupRule(); + group.setJoin(JoinType.AND); + group.setRules(List.of(rule1, rule2)); + + List ids = findMatchingIds(group); + assertThat(ids).contains(withBoth.getId()); + assertThat(ids).doesNotContain(withOneOnly.getId()); + } + + @Test + void hasNot_multipleFieldsCombined_orLogic() { + BookEntity missingBoth = createBook("Missing Both"); + + BookEntity missingOne = createBook("Missing One"); + missingOne.getMetadata().setDescription("A book"); + em.merge(missingOne.getMetadata()); + + BookEntity hasBoth = createBook("Has Both"); + hasBoth.getMetadata().setDescription("A book"); + hasBoth.getMetadata().setPublisher("Penguin"); + em.merge(hasBoth.getMetadata()); + em.flush(); + em.clear(); + + Rule rule1 = new Rule(); + rule1.setField(RuleField.METADATA_PRESENCE); + rule1.setOperator(RuleOperator.NOT_EQUALS); + rule1.setValue("description"); + + Rule rule2 = new Rule(); + rule2.setField(RuleField.METADATA_PRESENCE); + rule2.setOperator(RuleOperator.NOT_EQUALS); + rule2.setValue("publisher"); + + GroupRule group = new GroupRule(); + group.setJoin(JoinType.OR); + group.setRules(List.of(rule1, rule2)); + + List ids = findMatchingIds(group); + assertThat(ids).contains(missingBoth.getId(), missingOne.getId()); + assertThat(ids).doesNotContain(hasBoth.getId()); + } + + @Test + void has_booleanField_matchesWhenPresent() { + BookEntity withAbridged = createBook("Abridged Book"); + withAbridged.getMetadata().setAbridged(true); + em.merge(withAbridged.getMetadata()); + + BookEntity noAbridged = createBook("Not Set Abridged"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "abridged")); + assertThat(ids).contains(withAbridged.getId()); + assertThat(ids).doesNotContain(noAbridged.getId()); + } + + @Test + void has_booleanField_falseCountsAsPresent() { + BookEntity abridgedFalse = createBook("Not Abridged"); + abridgedFalse.getMetadata().setAbridged(false); + em.merge(abridgedFalse.getMetadata()); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "abridged")); + assertThat(ids).contains(abridgedFalse.getId()); + } + + @Test + void has_ageRating_matchesWhenPresent() { + BookEntity withAge = createBook("With Age Rating"); + withAge.getMetadata().setAgeRating(16); + em.merge(withAge.getMetadata()); + + BookEntity noAge = createBook("No Age Rating"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "ageRating")); + assertThat(ids).contains(withAge.getId()); + assertThat(ids).doesNotContain(noAge.getId()); + } + + @Test + void has_audiobookDuration_matchesWhenPresent() { + BookEntity withDuration = createBook("Audiobook"); + BookFileEntity audioFile = BookFileEntity.builder() + .book(withDuration) + .fileName("book.m4b") + .fileSubPath("") + .isBookFormat(true) + .bookType(BookFileType.AUDIOBOOK) + .durationSeconds(3600L) + .build(); + em.persist(audioFile); + + BookEntity noDuration = createBook("No Duration"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "audiobookDuration")); + assertThat(ids).contains(withDuration.getId()); + assertThat(ids).doesNotContain(noDuration.getId()); + } + + @Test + void hasNot_audiobookDuration_matchesWhenAbsent() { + BookEntity withDuration = createBook("Audiobook"); + BookFileEntity audioFile = BookFileEntity.builder() + .book(withDuration) + .fileName("book2.m4b") + .fileSubPath("") + .isBookFormat(true) + .bookType(BookFileType.AUDIOBOOK) + .durationSeconds(3600L) + .build(); + em.persist(audioFile); + + BookEntity noDuration = createBook("No Duration"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.NOT_EQUALS, "audiobookDuration")); + assertThat(ids).contains(noDuration.getId()); + assertThat(ids).doesNotContain(withDuration.getId()); + } + + @Test + void has_comicCharacters_matchesWhenPresent() { + BookEntity comicBook = createBook("Comic Book"); + ComicMetadataEntity comicMeta = ComicMetadataEntity.builder() + .bookId(comicBook.getId()) + .bookMetadata(comicBook.getMetadata()) + .build(); + ComicCharacterEntity character = ComicCharacterEntity.builder().name("Spider-Man MP").build(); + em.persist(character); + comicMeta.setCharacters(new HashSet<>(Set.of(character))); + em.persist(comicMeta); + comicBook.getMetadata().setComicMetadata(comicMeta); + em.merge(comicBook.getMetadata()); + + BookEntity noComic = createBook("Not a Comic"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "comicCharacters")); + assertThat(ids).contains(comicBook.getId()); + assertThat(ids).doesNotContain(noComic.getId()); + } + + @Test + void has_comicTeams_matchesWhenPresent() { + BookEntity comicBook = createBook("Team Comic"); + ComicMetadataEntity comicMeta = ComicMetadataEntity.builder() + .bookId(comicBook.getId()) + .bookMetadata(comicBook.getMetadata()) + .build(); + ComicTeamEntity team = ComicTeamEntity.builder().name("Avengers MP").build(); + em.persist(team); + comicMeta.setTeams(new HashSet<>(Set.of(team))); + em.persist(comicMeta); + comicBook.getMetadata().setComicMetadata(comicMeta); + em.merge(comicBook.getMetadata()); + + BookEntity noTeams = createBook("No Teams"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "comicTeams")); + assertThat(ids).contains(comicBook.getId()); + assertThat(ids).doesNotContain(noTeams.getId()); + } + + @Test + void has_comicLocations_matchesWhenPresent() { + BookEntity comicBook = createBook("Location Comic"); + ComicMetadataEntity comicMeta = ComicMetadataEntity.builder() + .bookId(comicBook.getId()) + .bookMetadata(comicBook.getMetadata()) + .build(); + ComicLocationEntity location = ComicLocationEntity.builder().name("Gotham MP").build(); + em.persist(location); + comicMeta.setLocations(new HashSet<>(Set.of(location))); + em.persist(comicMeta); + comicBook.getMetadata().setComicMetadata(comicMeta); + em.merge(comicBook.getMetadata()); + + BookEntity noLocations = createBook("No Locations"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "comicLocations")); + assertThat(ids).contains(comicBook.getId()); + assertThat(ids).doesNotContain(noLocations.getId()); + } + + @Test + void has_comicPencillers_matchesWhenPresent() { + BookEntity comicBook = createBook("Penciller Comic"); + ComicMetadataEntity comicMeta = ComicMetadataEntity.builder() + .bookId(comicBook.getId()) + .bookMetadata(comicBook.getMetadata()) + .build(); + em.persist(comicMeta); + comicBook.getMetadata().setComicMetadata(comicMeta); + em.merge(comicBook.getMetadata()); + + ComicCreatorEntity creator = ComicCreatorEntity.builder().name("Jim Lee MP").build(); + em.persist(creator); + ComicCreatorMappingEntity mapping = ComicCreatorMappingEntity.builder() + .comicMetadata(comicMeta) + .creator(creator) + .role(ComicCreatorRole.PENCILLER) + .build(); + em.persist(mapping); + + BookEntity noCreators = createBook("No Creators"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "comicPencillers")); + assertThat(ids).contains(comicBook.getId()); + assertThat(ids).doesNotContain(noCreators.getId()); + } + + @Test + void has_comicCreator_onlyMatchesCorrectRole() { + BookEntity comicBook = createBook("Colorist Comic"); + ComicMetadataEntity comicMeta = ComicMetadataEntity.builder() + .bookId(comicBook.getId()) + .bookMetadata(comicBook.getMetadata()) + .build(); + em.persist(comicMeta); + comicBook.getMetadata().setComicMetadata(comicMeta); + em.merge(comicBook.getMetadata()); + + ComicCreatorEntity creator = ComicCreatorEntity.builder().name("Colorist Person MP").build(); + em.persist(creator); + ComicCreatorMappingEntity mapping = ComicCreatorMappingEntity.builder() + .comicMetadata(comicMeta) + .creator(creator) + .role(ComicCreatorRole.COLORIST) + .build(); + em.persist(mapping); + em.flush(); + em.clear(); + + // Has colorist → should match + List coloristIds = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "comicColorists")); + assertThat(coloristIds).contains(comicBook.getId()); + + // Has penciller → should NOT match (only colorist assigned) + List pencillerIds = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "comicPencillers")); + assertThat(pencillerIds).doesNotContain(comicBook.getId()); + + // Has inker → should NOT match + List inkerIds = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "comicInkers")); + assertThat(inkerIds).doesNotContain(comicBook.getId()); + } + + @Test + void hasNot_coverImage_matchesWhenAbsent() { + BookEntity withCover = createBook("With Cover 2"); + withCover.setBookCoverHash("xyz789"); + em.merge(withCover); + + BookEntity noCover = createBook("No Cover 2"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.NOT_EQUALS, "thumbnailUrl")); + assertThat(ids).contains(noCover.getId()); + assertThat(ids).doesNotContain(withCover.getId()); + } + + @Test + void hasNot_numericField_matchesWhenAbsent() { + BookEntity withPages = createBook("With Pages 2"); + withPages.getMetadata().setPageCount(200); + em.merge(withPages.getMetadata()); + + BookEntity noPages = createBook("No Pages 2"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.NOT_EQUALS, "pageCount")); + assertThat(ids).contains(noPages.getId()); + assertThat(ids).doesNotContain(withPages.getId()); + } + + @Test + void hasNot_unknownField_matchesNothing() { + BookEntity book = createBook("Any Book 2"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.NOT_EQUALS, "nonExistentField")); + assertThat(ids).doesNotContain(book.getId()); + } + + @Test + void has_nullValue_matchesAll() { + BookEntity book = createBook("Null Value Book"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, null)); + assertThat(ids).contains(book.getId()); + } + + @Test + void has_subtitle_matchesWhenPresent() { + BookEntity withSubtitle = createBook("With Subtitle"); + withSubtitle.getMetadata().setSubtitle("A Subtitle"); + em.merge(withSubtitle.getMetadata()); + + BookEntity noSubtitle = createBook("No Subtitle"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "subtitle")); + assertThat(ids).contains(withSubtitle.getId()); + assertThat(ids).doesNotContain(noSubtitle.getId()); + } + + @Test + void has_publisher_matchesWhenPresent() { + BookEntity withPublisher = createBook("With Publisher MP"); + withPublisher.getMetadata().setPublisher("Penguin"); + em.merge(withPublisher.getMetadata()); + + BookEntity noPublisher = createBook("No Publisher MP"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "publisher")); + assertThat(ids).contains(withPublisher.getId()); + assertThat(ids).doesNotContain(noPublisher.getId()); + } + + @Test + void has_language_matchesWhenPresent() { + BookEntity withLang = createBook("With Language"); + withLang.getMetadata().setLanguage("en"); + em.merge(withLang.getMetadata()); + + BookEntity noLang = createBook("No Language"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "language")); + assertThat(ids).contains(withLang.getId()); + assertThat(ids).doesNotContain(noLang.getId()); + } + + @Test + void has_asin_matchesWhenPresent() { + BookEntity withAsin = createBook("With ASIN"); + withAsin.getMetadata().setAsin("B00TEST123"); + em.merge(withAsin.getMetadata()); + + BookEntity noAsin = createBook("No ASIN"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "asin")); + assertThat(ids).contains(withAsin.getId()); + assertThat(ids).doesNotContain(noAsin.getId()); + } + + @Test + void has_contentRating_matchesWhenPresent() { + BookEntity withCR = createBook("With Content Rating"); + withCR.getMetadata().setContentRating("MATURE"); + em.merge(withCR.getMetadata()); + + BookEntity noCR = createBook("No Content Rating"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "contentRating")); + assertThat(ids).contains(withCR.getId()); + assertThat(ids).doesNotContain(noCR.getId()); + } + + @Test + void has_isbn10_matchesWhenPresent() { + BookEntity withIsbn10 = createBook("With ISBN10"); + withIsbn10.getMetadata().setIsbn10("0123456789"); + em.merge(withIsbn10.getMetadata()); + + BookEntity noIsbn10 = createBook("No ISBN10"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "isbn10")); + assertThat(ids).contains(withIsbn10.getId()); + assertThat(ids).doesNotContain(noIsbn10.getId()); + } + + @Test + void has_reviewCount_matchesWhenPresent() { + BookEntity withReviews = createBook("With Reviews"); + withReviews.getMetadata().setAudibleReviewCount(150); + em.merge(withReviews.getMetadata()); + + BookEntity noReviews = createBook("No Reviews"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "audibleReviewCount")); + assertThat(ids).contains(withReviews.getId()); + assertThat(ids).doesNotContain(noReviews.getId()); + } + + @Test + void has_otherExternalIds_matchWhenPresent() { + BookEntity withIds = createBook("With External IDs"); + withIds.getMetadata().setAudibleId("AUD123"); + withIds.getMetadata().setComicvineId("CV456"); + withIds.getMetadata().setHardcoverId("HC789"); + withIds.getMetadata().setGoogleId("G012"); + withIds.getMetadata().setLubimyczytacId("LUB345"); + withIds.getMetadata().setRanobedbId("RAN678"); + em.merge(withIds.getMetadata()); + + BookEntity noIds = createBook("No External IDs"); + em.flush(); + em.clear(); + + for (String field : List.of("audibleId", "comicvineId", "hardcoverId", "googleId", "lubimyczytacId", "ranobedbId")) { + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, field)); + assertThat(ids).as("Has %s", field).contains(withIds.getId()); + assertThat(ids).as("Has %s", field).doesNotContain(noIds.getId()); + } + } + + @Test + void has_otherRatings_matchWhenPresent() { + BookEntity withRatings = createBook("With Ratings"); + withRatings.getMetadata().setGoodreadsRating(4.2); + withRatings.getMetadata().setHardcoverRating(3.8); + withRatings.getMetadata().setRanobedbRating(4.0); + withRatings.getMetadata().setLubimyczytacRating(4.5); + withRatings.getMetadata().setAudibleRating(4.1); + em.merge(withRatings.getMetadata()); + + BookEntity noRatings = createBook("No Ratings"); + em.flush(); + em.clear(); + + for (String field : List.of("goodreadsRating", "hardcoverRating", "ranobedbRating", "lubimyczytacRating", "audibleRating")) { + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, field)); + assertThat(ids).as("Has %s", field).contains(withRatings.getId()); + assertThat(ids).as("Has %s", field).doesNotContain(noRatings.getId()); + } + } + + @Test + void has_personalRating_noProgressEntity_treatedAsAbsent() { + BookEntity noProgress = createBook("No Progress At All"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "personalRating")); + assertThat(ids).doesNotContain(noProgress.getId()); + } + + @Test + void mixedWithOtherRuleTypes() { + BookEntity matchesBoth = createBook("Matching Book"); + matchesBoth.getMetadata().setDescription("A great story"); + matchesBoth.getMetadata().setPageCount(250); + em.merge(matchesBoth.getMetadata()); + + BookEntity hasDescNoPages = createBook("Has Desc No Pages"); + hasDescNoPages.getMetadata().setDescription("Another story"); + em.merge(hasDescNoPages.getMetadata()); + + BookEntity shortWithPages = createBook("Short with Pages"); + shortWithPages.getMetadata().setPageCount(100); + em.merge(shortWithPages.getMetadata()); + em.flush(); + em.clear(); + + Rule presenceRule = new Rule(); + presenceRule.setField(RuleField.METADATA_PRESENCE); + presenceRule.setOperator(RuleOperator.EQUALS); + presenceRule.setValue("description"); + + Rule pageRule = new Rule(); + pageRule.setField(RuleField.PAGE_COUNT); + pageRule.setOperator(RuleOperator.GREATER_THAN); + pageRule.setValue(200); + + GroupRule group = new GroupRule(); + group.setJoin(JoinType.AND); + group.setRules(List.of(presenceRule, pageRule)); + + List ids = findMatchingIds(group); + assertThat(ids).contains(matchesBoth.getId()); + assertThat(ids).doesNotContain(hasDescNoPages.getId(), shortWithPages.getId()); + } + + @Test + void has_otherReviewCounts_matchWhenPresent() { + BookEntity withReviews = createBook("With All Reviews"); + withReviews.getMetadata().setAmazonReviewCount(100); + withReviews.getMetadata().setGoodreadsReviewCount(200); + withReviews.getMetadata().setHardcoverReviewCount(50); + em.merge(withReviews.getMetadata()); + + BookEntity noReviews = createBook("No Review Counts"); + em.flush(); + em.clear(); + + for (String field : List.of("amazonReviewCount", "goodreadsReviewCount", "hardcoverReviewCount")) { + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, field)); + assertThat(ids).as("Has %s", field).contains(withReviews.getId()); + assertThat(ids).as("Has %s", field).doesNotContain(noReviews.getId()); + } + } + + @Test + void has_comicEditors_matchesWhenPresent() { + BookEntity comicBook = createBook("Editor Comic"); + ComicMetadataEntity comicMeta = ComicMetadataEntity.builder() + .bookId(comicBook.getId()) + .bookMetadata(comicBook.getMetadata()) + .build(); + em.persist(comicMeta); + comicBook.getMetadata().setComicMetadata(comicMeta); + em.merge(comicBook.getMetadata()); + + ComicCreatorEntity editor = ComicCreatorEntity.builder().name("Editor Person MP").build(); + em.persist(editor); + ComicCreatorMappingEntity mapping = ComicCreatorMappingEntity.builder() + .comicMetadata(comicMeta) + .creator(editor) + .role(ComicCreatorRole.EDITOR) + .build(); + em.persist(mapping); + + BookEntity noEditors = createBook("No Editors"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "comicEditors")); + assertThat(ids).contains(comicBook.getId()); + assertThat(ids).doesNotContain(noEditors.getId()); + } + + @Test + void hasNot_comicCharacters_matchesWhenAbsent() { + BookEntity comicBook = createBook("Comic With Chars"); + ComicMetadataEntity comicMeta = ComicMetadataEntity.builder() + .bookId(comicBook.getId()) + .bookMetadata(comicBook.getMetadata()) + .build(); + ComicCharacterEntity character = ComicCharacterEntity.builder().name("Batman MP").build(); + em.persist(character); + comicMeta.setCharacters(new HashSet<>(Set.of(character))); + em.persist(comicMeta); + comicBook.getMetadata().setComicMetadata(comicMeta); + em.merge(comicBook.getMetadata()); + + BookEntity noChars = createBook("No Characters"); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.NOT_EQUALS, "comicCharacters")); + assertThat(ids).contains(noChars.getId()); + assertThat(ids).doesNotContain(comicBook.getId()); + } + + @Test + void has_audiobookDuration_fileWithoutDuration_doesNotMatch() { + BookEntity bookNoDuration = createBook("File Without Duration"); + BookFileEntity fileNoDuration = BookFileEntity.builder() + .book(bookNoDuration) + .fileName("noduration.m4b") + .fileSubPath("") + .isBookFormat(true) + .bookType(BookFileType.AUDIOBOOK) + .build(); + em.persist(fileNoDuration); + em.flush(); + em.clear(); + + List ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "audiobookDuration")); + assertThat(ids).doesNotContain(bookNoDuration.getId()); + } + } + @Nested class IntegerFieldTests { @Test diff --git a/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.html b/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.html index d8cfe9511..08eea088c 100644 --- a/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.html +++ b/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.html @@ -159,6 +159,8 @@ } @else if (ruleCtrl.get('field')?.value === 'seriesPosition') { + } @else if (ruleCtrl.get('field')?.value === 'metadataPresence') { + } @else if (['includes_any', 'includes_all', 'excludes_all'].includes(ruleCtrl.get('operator')?.value)) { @if (ruleCtrl.get('field')?.value === 'readStatus') { diff --git a/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.spec.ts b/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.spec.ts index c92f7593f..0ebf1f777 100644 --- a/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.spec.ts +++ b/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.spec.ts @@ -63,6 +63,14 @@ describe('MagicShelfComponent (Part 3)', () => { expect(operators[0].label).toContain('has'); }); + it('should return has/hasNot operators for metadataPresence', () => { + const operators = component.getOperatorOptionsForField('metadataPresence'); + expect(operators).toHaveLength(2); + expect(operators.map(o => o.value)).toEqual(['equals', 'not_equals']); + expect(operators[0].label).toContain('has'); + expect(operators[1].label).toContain('hasNot'); + }); + it('should include relative date operators for date fields', () => { const operators = component.getOperatorOptionsForField('dateFinished'); const values = operators.map(o => o.value); @@ -496,8 +504,8 @@ describe('MagicShelfComponent (Part 3)', () => { }); describe('fieldOptions completeness', () => { - it('should have 8 groups total', () => { - expect(component.fieldOptions.length).toBe(8); + it('should have 9 groups total', () => { + expect(component.fieldOptions.length).toBe(9); }); it('should map categories field to genre translation key', () => { @@ -526,6 +534,70 @@ describe('MagicShelfComponent (Part 3)', () => { expect(fieldValues).toContain('lastReadTime'); expect(fieldValues).toContain('addedOn'); }); + + it('should include metadataScore and metadataPresence in qualityMetadata group', () => { + const groups = component.fieldOptions; + const qualityGroup = groups.find(g => g.label.includes('qualityMetadata')); + const fieldValues = qualityGroup?.items.map(i => i.value) ?? []; + expect(fieldValues).toContain('metadataScore'); + expect(fieldValues).toContain('metadataPresence'); + }); + + it('should NOT include metadataScore in ratingsReviews group', () => { + const groups = component.fieldOptions; + const ratingsGroup = groups.find(g => g.label.includes('ratingsReviews')); + const fieldValues = ratingsGroup?.items.map(i => i.value) ?? []; + expect(fieldValues).not.toContain('metadataScore'); + }); + }); + + describe('metadataPresenceOptions', () => { + it('should return 10 groups', () => { + const options = component.metadataPresenceOptions; + expect(options).toHaveLength(10); + }); + + it('should have grouped structure with label and items', () => { + const options = component.metadataPresenceOptions; + options.forEach(group => { + expect(group.label).toBeDefined(); + expect(group.items).toBeDefined(); + expect(group.items.length).toBeGreaterThan(0); + group.items.forEach(item => { + expect(item.label).toBeDefined(); + expect(item.value).toBeDefined(); + }); + }); + }); + + it('should include key metadata fields', () => { + const options = component.metadataPresenceOptions; + const allValues = options.flatMap(g => g.items.map(i => i.value)); + expect(allValues).toContain('title'); + expect(allValues).toContain('thumbnailUrl'); + expect(allValues).toContain('authors'); + expect(allValues).toContain('isbn13'); + expect(allValues).toContain('goodreadsId'); + expect(allValues).toContain('audiobookDuration'); + expect(allValues).toContain('comicCharacters'); + }); + }); + + describe('buildRuleFromData for metadataPresence', () => { + it('should parse metadataPresence rule with string value', () => { + const rule: Rule = {field: 'metadataPresence', operator: 'equals', value: 'thumbnailUrl'}; + const formGroup = component.buildRuleFromData(rule); + expect(formGroup.get('field')?.value).toBe('metadataPresence'); + expect(formGroup.get('operator')?.value).toBe('equals'); + expect(formGroup.get('value')?.value).toBe('thumbnailUrl'); + }); + + it('should parse metadataPresence rule with not_equals', () => { + const rule: Rule = {field: 'metadataPresence', operator: 'not_equals', value: 'description'}; + const formGroup = component.buildRuleFromData(rule); + expect(formGroup.get('operator')?.value).toBe('not_equals'); + expect(formGroup.get('value')?.value).toBe('description'); + }); }); describe('numericFieldConfigMap', () => { diff --git a/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.ts b/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.ts index 2d1cda0a3..0ecc445a5 100644 --- a/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.ts +++ b/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.ts @@ -92,7 +92,8 @@ export type RuleField = | 'seriesStatus' | 'seriesGaps' | 'seriesPosition' - | 'readingProgress'; + | 'readingProgress' + | 'metadataPresence'; interface FullFieldConfig { @@ -183,7 +184,8 @@ const FIELD_CONFIGS: Record = { seriesStatus: {label: 'seriesStatus'}, seriesGaps: {label: 'seriesGaps'}, seriesPosition: {label: 'seriesPosition'}, - readingProgress: {label: 'readingProgress', type: 'decimal', max: 100} + readingProgress: {label: 'readingProgress', type: 'decimal', max: 100}, + metadataPresence: {label: 'metadataPresence'} }; interface FieldGroup { @@ -196,7 +198,8 @@ const FIELD_GROUPS: FieldGroup[] = [ { translationKey: 'bookInfo', fields: ['title', 'subtitle', 'description', 'authors', 'categories', 'publisher', 'language', 'pageCount', 'ageRating', 'contentRating'] }, { translationKey: 'series', fields: ['seriesName', 'seriesNumber', 'seriesTotal', 'seriesStatus', 'seriesGaps', 'seriesPosition'] }, { translationKey: 'dates', fields: ['publishedDate', 'dateFinished', 'lastReadTime', 'addedOn'] }, - { translationKey: 'ratingsReviews', fields: ['personalRating', 'metadataScore', 'amazonRating', 'amazonReviewCount', 'goodreadsRating', 'goodreadsReviewCount', 'hardcoverRating', 'hardcoverReviewCount', 'ranobedbRating', 'lubimyczytacRating', 'audibleRating', 'audibleReviewCount'] }, + { translationKey: 'ratingsReviews', fields: ['personalRating', 'amazonRating', 'amazonReviewCount', 'goodreadsRating', 'goodreadsReviewCount', 'hardcoverRating', 'hardcoverReviewCount', 'ranobedbRating', 'lubimyczytacRating', 'audibleRating', 'audibleReviewCount'] }, + { translationKey: 'qualityMetadata', fields: ['metadataScore', 'metadataPresence'] }, { translationKey: 'tagsMoods', fields: ['moods', 'tags'] }, { translationKey: 'audiobook', fields: ['narrator', 'abridged', 'audiobookDuration'] }, { translationKey: 'fileIdentifiers', fields: ['fileType', 'fileSize', 'isbn13', 'isbn10', 'isPhysical'] } @@ -330,6 +333,81 @@ export class MagicShelfComponent implements OnInit { ]; } + get metadataPresenceOptions() { + return [ + { label: this.t.translate('magicShelf.metadataFieldGroups.bookInfo'), items: [ + {label: this.t.translate('magicShelf.metadataFields.title'), value: 'title'}, + {label: this.t.translate('magicShelf.metadataFields.subtitle'), value: 'subtitle'}, + {label: this.t.translate('magicShelf.metadataFields.description'), value: 'description'}, + {label: this.t.translate('magicShelf.metadataFields.thumbnailUrl'), value: 'thumbnailUrl'}, + {label: this.t.translate('magicShelf.metadataFields.publisher'), value: 'publisher'}, + {label: this.t.translate('magicShelf.metadataFields.publishedDate'), value: 'publishedDate'}, + {label: this.t.translate('magicShelf.metadataFields.language'), value: 'language'}, + {label: this.t.translate('magicShelf.metadataFields.pageCount'), value: 'pageCount'}, + ]}, + { label: this.t.translate('magicShelf.metadataFieldGroups.authorsCategories'), items: [ + {label: this.t.translate('magicShelf.metadataFields.authors'), value: 'authors'}, + {label: this.t.translate('magicShelf.metadataFields.categories'), value: 'categories'}, + {label: this.t.translate('magicShelf.metadataFields.moods'), value: 'moods'}, + {label: this.t.translate('magicShelf.metadataFields.tags'), value: 'tags'}, + ]}, + { label: this.t.translate('magicShelf.metadataFieldGroups.series'), items: [ + {label: this.t.translate('magicShelf.metadataFields.seriesName'), value: 'seriesName'}, + {label: this.t.translate('magicShelf.metadataFields.seriesNumber'), value: 'seriesNumber'}, + {label: this.t.translate('magicShelf.metadataFields.seriesTotal'), value: 'seriesTotal'}, + ]}, + { label: this.t.translate('magicShelf.metadataFieldGroups.identifiers'), items: [ + {label: this.t.translate('magicShelf.metadataFields.isbn13'), value: 'isbn13'}, + {label: this.t.translate('magicShelf.metadataFields.isbn10'), value: 'isbn10'}, + {label: this.t.translate('magicShelf.metadataFields.asin'), value: 'asin'}, + ]}, + { label: this.t.translate('magicShelf.metadataFieldGroups.contentClassification'), items: [ + {label: this.t.translate('magicShelf.metadataFields.ageRating'), value: 'ageRating'}, + {label: this.t.translate('magicShelf.metadataFields.contentRating'), value: 'contentRating'}, + ]}, + { label: this.t.translate('magicShelf.metadataFieldGroups.ratings'), items: [ + {label: this.t.translate('magicShelf.metadataFields.personalRating'), value: 'personalRating'}, + {label: this.t.translate('magicShelf.metadataFields.amazonRating'), value: 'amazonRating'}, + {label: this.t.translate('magicShelf.metadataFields.goodreadsRating'), value: 'goodreadsRating'}, + {label: this.t.translate('magicShelf.metadataFields.hardcoverRating'), value: 'hardcoverRating'}, + {label: this.t.translate('magicShelf.metadataFields.ranobedbRating'), value: 'ranobedbRating'}, + {label: this.t.translate('magicShelf.metadataFields.lubimyczytacRating'), value: 'lubimyczytacRating'}, + {label: this.t.translate('magicShelf.metadataFields.audibleRating'), value: 'audibleRating'}, + ]}, + { label: this.t.translate('magicShelf.metadataFieldGroups.reviewCounts'), items: [ + {label: this.t.translate('magicShelf.metadataFields.amazonReviewCount'), value: 'amazonReviewCount'}, + {label: this.t.translate('magicShelf.metadataFields.goodreadsReviewCount'), value: 'goodreadsReviewCount'}, + {label: this.t.translate('magicShelf.metadataFields.hardcoverReviewCount'), value: 'hardcoverReviewCount'}, + {label: this.t.translate('magicShelf.metadataFields.audibleReviewCount'), value: 'audibleReviewCount'}, + ]}, + { label: this.t.translate('magicShelf.metadataFieldGroups.externalIds'), items: [ + {label: this.t.translate('magicShelf.metadataFields.goodreadsId'), value: 'goodreadsId'}, + {label: this.t.translate('magicShelf.metadataFields.hardcoverId'), value: 'hardcoverId'}, + {label: this.t.translate('magicShelf.metadataFields.googleId'), value: 'googleId'}, + {label: this.t.translate('magicShelf.metadataFields.audibleId'), value: 'audibleId'}, + {label: this.t.translate('magicShelf.metadataFields.lubimyczytacId'), value: 'lubimyczytacId'}, + {label: this.t.translate('magicShelf.metadataFields.ranobedbId'), value: 'ranobedbId'}, + {label: this.t.translate('magicShelf.metadataFields.comicvineId'), value: 'comicvineId'}, + ]}, + { label: this.t.translate('magicShelf.metadataFieldGroups.audiobook'), items: [ + {label: this.t.translate('magicShelf.metadataFields.narrator'), value: 'narrator'}, + {label: this.t.translate('magicShelf.metadataFields.abridged'), value: 'abridged'}, + {label: this.t.translate('magicShelf.metadataFields.audiobookDuration'), value: 'audiobookDuration'}, + ]}, + { label: this.t.translate('magicShelf.metadataFieldGroups.comic'), items: [ + {label: this.t.translate('magicShelf.metadataFields.comicCharacters'), value: 'comicCharacters'}, + {label: this.t.translate('magicShelf.metadataFields.comicTeams'), value: 'comicTeams'}, + {label: this.t.translate('magicShelf.metadataFields.comicLocations'), value: 'comicLocations'}, + {label: this.t.translate('magicShelf.metadataFields.comicPencillers'), value: 'comicPencillers'}, + {label: this.t.translate('magicShelf.metadataFields.comicInkers'), value: 'comicInkers'}, + {label: this.t.translate('magicShelf.metadataFields.comicColorists'), value: 'comicColorists'}, + {label: this.t.translate('magicShelf.metadataFields.comicLetterers'), value: 'comicLetterers'}, + {label: this.t.translate('magicShelf.metadataFields.comicCoverArtists'), value: 'comicCoverArtists'}, + {label: this.t.translate('magicShelf.metadataFields.comicEditors'), value: 'comicEditors'}, + ]}, + ]; + } + get dateUnitOptions() { return [ {label: this.t.translate('magicShelf.dateUnits.days'), value: 'days'}, @@ -540,7 +618,7 @@ export class MagicShelfComponent implements OnInit { {label: this.t.translate('magicShelf.operators.isNot'), value: 'not_equals'}, ]; } - if (field === 'seriesGaps') { + if (field === 'seriesGaps' || field === 'metadataPresence') { return [ {label: this.t.translate('magicShelf.operators.has'), value: 'equals'}, {label: this.t.translate('magicShelf.operators.hasNot'), value: 'not_equals'}, diff --git a/booklore-ui/src/app/features/magic-shelf/service/book-rule-evaluator-metadata-presence.spec.ts b/booklore-ui/src/app/features/magic-shelf/service/book-rule-evaluator-metadata-presence.spec.ts new file mode 100644 index 000000000..d1110c339 --- /dev/null +++ b/booklore-ui/src/app/features/magic-shelf/service/book-rule-evaluator-metadata-presence.spec.ts @@ -0,0 +1,533 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {BookRuleEvaluatorService} from './book-rule-evaluator.service'; +import {Book, ReadStatus} from '../../book/model/book.model'; +import {GroupRule} from '../component/magic-shelf-component'; + +describe('BookRuleEvaluatorService - metadataPresence', () => { + let service: BookRuleEvaluatorService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(BookRuleEvaluatorService); + }); + + const createBook = (overrides: Partial = {}): Book => ({ + id: 1, + bookType: 'EPUB', + libraryId: 1, + libraryName: 'Test Library', + fileName: 'test.epub', + filePath: '/path/to/test.epub', + readStatus: ReadStatus.UNREAD, + shelves: [], + metadata: { + bookId: 1, + title: 'Test Book', + authors: ['Test Author'], + categories: ['Fiction'], + language: 'en' + }, + ...overrides + }); + + const rule = (operator: string, value: string): GroupRule => ({ + name: 'test', type: 'group', join: 'and', + rules: [{field: 'metadataPresence', operator, value} as never] + }); + + describe('string fields', () => { + it('should detect present title', () => { + const book = createBook({metadata: {bookId: 1, title: 'A Book'}}); + expect(service.evaluateGroup(book, rule('equals', 'title'))).toBe(true); + expect(service.evaluateGroup(book, rule('not_equals', 'title'))).toBe(false); + }); + + it('should detect missing title', () => { + const book = createBook({metadata: {bookId: 1, title: undefined}}); + expect(service.evaluateGroup(book, rule('equals', 'title'))).toBe(false); + expect(service.evaluateGroup(book, rule('not_equals', 'title'))).toBe(true); + }); + + it('should treat empty string as absent', () => { + const book = createBook({metadata: {bookId: 1, title: ''}}); + expect(service.evaluateGroup(book, rule('equals', 'title'))).toBe(false); + }); + + it('should treat whitespace-only string as absent', () => { + const book = createBook({metadata: {bookId: 1, title: ' '}}); + expect(service.evaluateGroup(book, rule('equals', 'title'))).toBe(false); + }); + + it('should detect present subtitle', () => { + const book = createBook({metadata: {bookId: 1, subtitle: 'A Novel'}}); + expect(service.evaluateGroup(book, rule('equals', 'subtitle'))).toBe(true); + }); + + it('should detect missing subtitle', () => { + const book = createBook({metadata: {bookId: 1}}); + expect(service.evaluateGroup(book, rule('equals', 'subtitle'))).toBe(false); + }); + + it('should detect present description', () => { + const book = createBook({metadata: {bookId: 1, description: 'A tale of adventure'}}); + expect(service.evaluateGroup(book, rule('equals', 'description'))).toBe(true); + }); + + it('should detect missing description', () => { + const book = createBook({metadata: {bookId: 1}}); + expect(service.evaluateGroup(book, rule('not_equals', 'description'))).toBe(true); + }); + + it('should detect present publisher', () => { + const book = createBook({metadata: {bookId: 1, publisher: 'Tor Books'}}); + expect(service.evaluateGroup(book, rule('equals', 'publisher'))).toBe(true); + }); + + it('should detect present publishedDate', () => { + const book = createBook({metadata: {bookId: 1, publishedDate: '2024-01-01'}}); + expect(service.evaluateGroup(book, rule('equals', 'publishedDate'))).toBe(true); + }); + + it('should detect missing publishedDate', () => { + const book = createBook({metadata: {bookId: 1}}); + expect(service.evaluateGroup(book, rule('equals', 'publishedDate'))).toBe(false); + }); + + it('should detect present language', () => { + const book = createBook({metadata: {bookId: 1, language: 'en'}}); + expect(service.evaluateGroup(book, rule('equals', 'language'))).toBe(true); + }); + + it('should detect present thumbnailUrl', () => { + const book = createBook({metadata: {bookId: 1, thumbnailUrl: 'https://example.com/cover.jpg'}}); + expect(service.evaluateGroup(book, rule('equals', 'thumbnailUrl'))).toBe(true); + }); + + it('should detect missing thumbnailUrl', () => { + const book = createBook({metadata: {bookId: 1}}); + expect(service.evaluateGroup(book, rule('not_equals', 'thumbnailUrl'))).toBe(true); + }); + + it('should detect present narrator', () => { + const book = createBook({metadata: {bookId: 1, narrator: 'Stephen Fry'}}); + expect(service.evaluateGroup(book, rule('equals', 'narrator'))).toBe(true); + }); + + it('should detect present contentRating', () => { + const book = createBook({metadata: {bookId: 1, contentRating: 'MATURE'}}); + expect(service.evaluateGroup(book, rule('equals', 'contentRating'))).toBe(true); + }); + + it('should detect present seriesName', () => { + const book = createBook({metadata: {bookId: 1, seriesName: 'Dune'}}); + expect(service.evaluateGroup(book, rule('equals', 'seriesName'))).toBe(true); + }); + + it('should detect missing seriesName', () => { + const book = createBook({metadata: {bookId: 1}}); + expect(service.evaluateGroup(book, rule('equals', 'seriesName'))).toBe(false); + }); + }); + + describe('identifier fields', () => { + it('should detect present isbn13', () => { + const book = createBook({metadata: {bookId: 1, isbn13: '9780123456789'}}); + expect(service.evaluateGroup(book, rule('equals', 'isbn13'))).toBe(true); + }); + + it('should detect missing isbn13', () => { + const book = createBook({metadata: {bookId: 1}}); + expect(service.evaluateGroup(book, rule('not_equals', 'isbn13'))).toBe(true); + }); + + it('should detect present isbn10', () => { + const book = createBook({metadata: {bookId: 1, isbn10: '0123456789'}}); + expect(service.evaluateGroup(book, rule('equals', 'isbn10'))).toBe(true); + }); + + it('should detect present asin', () => { + const book = createBook({metadata: {bookId: 1, asin: 'B08N5WRWNW'}}); + expect(service.evaluateGroup(book, rule('equals', 'asin'))).toBe(true); + }); + + it('should detect missing asin', () => { + const book = createBook({metadata: {bookId: 1}}); + expect(service.evaluateGroup(book, rule('not_equals', 'asin'))).toBe(true); + }); + }); + + describe('numeric fields', () => { + it('should detect present pageCount', () => { + const book = createBook({metadata: {bookId: 1, pageCount: 350}}); + expect(service.evaluateGroup(book, rule('equals', 'pageCount'))).toBe(true); + }); + + it('should detect missing pageCount', () => { + const book = createBook({metadata: {bookId: 1}}); + expect(service.evaluateGroup(book, rule('equals', 'pageCount'))).toBe(false); + }); + + it('should treat zero pageCount as present', () => { + const book = createBook({metadata: {bookId: 1, pageCount: 0}}); + expect(service.evaluateGroup(book, rule('equals', 'pageCount'))).toBe(true); + }); + + it('should detect present seriesNumber', () => { + const book = createBook({metadata: {bookId: 1, seriesNumber: 3}}); + expect(service.evaluateGroup(book, rule('equals', 'seriesNumber'))).toBe(true); + }); + + it('should detect present seriesTotal', () => { + const book = createBook({metadata: {bookId: 1, seriesTotal: 10}}); + expect(service.evaluateGroup(book, rule('equals', 'seriesTotal'))).toBe(true); + }); + + it('should detect present ageRating', () => { + const book = createBook({metadata: {bookId: 1, ageRating: 16}}); + expect(service.evaluateGroup(book, rule('equals', 'ageRating'))).toBe(true); + }); + }); + + describe('array fields', () => { + it('should detect present authors', () => { + const book = createBook({metadata: {bookId: 1, authors: ['Author One']}}); + expect(service.evaluateGroup(book, rule('equals', 'authors'))).toBe(true); + }); + + it('should detect empty authors as absent', () => { + const book = createBook({metadata: {bookId: 1, authors: []}}); + expect(service.evaluateGroup(book, rule('equals', 'authors'))).toBe(false); + expect(service.evaluateGroup(book, rule('not_equals', 'authors'))).toBe(true); + }); + + it('should detect missing authors (undefined) as absent', () => { + const book = createBook({metadata: {bookId: 1, authors: undefined}}); + expect(service.evaluateGroup(book, rule('equals', 'authors'))).toBe(false); + }); + + it('should detect present categories', () => { + const book = createBook({metadata: {bookId: 1, categories: ['Fantasy']}}); + expect(service.evaluateGroup(book, rule('equals', 'categories'))).toBe(true); + }); + + it('should detect empty categories as absent', () => { + const book = createBook({metadata: {bookId: 1, categories: []}}); + expect(service.evaluateGroup(book, rule('equals', 'categories'))).toBe(false); + }); + + it('should detect present moods', () => { + const book = createBook({metadata: {bookId: 1, moods: ['Dark', 'Atmospheric']}}); + expect(service.evaluateGroup(book, rule('equals', 'moods'))).toBe(true); + }); + + it('should detect missing moods as absent', () => { + const book = createBook({metadata: {bookId: 1}}); + expect(service.evaluateGroup(book, rule('not_equals', 'moods'))).toBe(true); + }); + + it('should detect present tags', () => { + const book = createBook({metadata: {bookId: 1, tags: ['favorite']}}); + expect(service.evaluateGroup(book, rule('equals', 'tags'))).toBe(true); + }); + + it('should detect missing tags as absent', () => { + const book = createBook({metadata: {bookId: 1}}); + expect(service.evaluateGroup(book, rule('not_equals', 'tags'))).toBe(true); + }); + }); + + describe('rating fields', () => { + it('should detect present personalRating', () => { + const book = createBook({personalRating: 8}); + expect(service.evaluateGroup(book, rule('equals', 'personalRating'))).toBe(true); + }); + + it('should detect missing personalRating', () => { + const book = createBook({personalRating: undefined}); + expect(service.evaluateGroup(book, rule('equals', 'personalRating'))).toBe(false); + expect(service.evaluateGroup(book, rule('not_equals', 'personalRating'))).toBe(true); + }); + + it('should detect present amazonRating', () => { + const book = createBook({metadata: {bookId: 1, amazonRating: 4.5}}); + expect(service.evaluateGroup(book, rule('equals', 'amazonRating'))).toBe(true); + }); + + it('should detect missing amazonRating', () => { + const book = createBook({metadata: {bookId: 1}}); + expect(service.evaluateGroup(book, rule('not_equals', 'amazonRating'))).toBe(true); + }); + + it('should detect present goodreadsRating', () => { + const book = createBook({metadata: {bookId: 1, goodreadsRating: 4.1}}); + expect(service.evaluateGroup(book, rule('equals', 'goodreadsRating'))).toBe(true); + }); + + it('should detect present hardcoverRating', () => { + const book = createBook({metadata: {bookId: 1, hardcoverRating: 3.9}}); + expect(service.evaluateGroup(book, rule('equals', 'hardcoverRating'))).toBe(true); + }); + + it('should detect present ranobedbRating', () => { + const book = createBook({metadata: {bookId: 1, ranobedbRating: 4.0}}); + expect(service.evaluateGroup(book, rule('equals', 'ranobedbRating'))).toBe(true); + }); + + it('should detect present lubimyczytacRating', () => { + const book = createBook({metadata: {bookId: 1, lubimyczytacRating: 4.2}}); + expect(service.evaluateGroup(book, rule('equals', 'lubimyczytacRating'))).toBe(true); + }); + + it('should detect present audibleRating', () => { + const book = createBook({metadata: {bookId: 1, audibleRating: 4.7}}); + expect(service.evaluateGroup(book, rule('equals', 'audibleRating'))).toBe(true); + }); + }); + + describe('review count fields', () => { + it('should detect present amazonReviewCount', () => { + const book = createBook({metadata: {bookId: 1, amazonReviewCount: 1200}}); + expect(service.evaluateGroup(book, rule('equals', 'amazonReviewCount'))).toBe(true); + }); + + it('should detect missing amazonReviewCount', () => { + const book = createBook({metadata: {bookId: 1}}); + expect(service.evaluateGroup(book, rule('not_equals', 'amazonReviewCount'))).toBe(true); + }); + + it('should detect present goodreadsReviewCount', () => { + const book = createBook({metadata: {bookId: 1, goodreadsReviewCount: 5000}}); + expect(service.evaluateGroup(book, rule('equals', 'goodreadsReviewCount'))).toBe(true); + }); + + it('should detect present hardcoverReviewCount', () => { + const book = createBook({metadata: {bookId: 1, hardcoverReviewCount: 300}}); + expect(service.evaluateGroup(book, rule('equals', 'hardcoverReviewCount'))).toBe(true); + }); + + it('should detect present audibleReviewCount', () => { + const book = createBook({metadata: {bookId: 1, audibleReviewCount: 500}}); + expect(service.evaluateGroup(book, rule('equals', 'audibleReviewCount'))).toBe(true); + }); + }); + + describe('external ID fields', () => { + it('should detect present goodreadsId', () => { + const book = createBook({metadata: {bookId: 1, goodreadsId: '12345'}}); + expect(service.evaluateGroup(book, rule('equals', 'goodreadsId'))).toBe(true); + }); + + it('should detect missing goodreadsId', () => { + const book = createBook({metadata: {bookId: 1}}); + expect(service.evaluateGroup(book, rule('not_equals', 'goodreadsId'))).toBe(true); + }); + + it('should detect present hardcoverId', () => { + const book = createBook({metadata: {bookId: 1, hardcoverId: 'hc-123'}}); + expect(service.evaluateGroup(book, rule('equals', 'hardcoverId'))).toBe(true); + }); + + it('should detect present googleId', () => { + const book = createBook({metadata: {bookId: 1, googleId: 'gid-456'}}); + expect(service.evaluateGroup(book, rule('equals', 'googleId'))).toBe(true); + }); + + it('should detect present audibleId', () => { + const book = createBook({metadata: {bookId: 1, audibleId: 'aud-789'}}); + expect(service.evaluateGroup(book, rule('equals', 'audibleId'))).toBe(true); + }); + + it('should detect present lubimyczytacId', () => { + const book = createBook({metadata: {bookId: 1, lubimyczytacId: 'lub-321'}}); + expect(service.evaluateGroup(book, rule('equals', 'lubimyczytacId'))).toBe(true); + }); + + it('should detect present ranobedbId', () => { + const book = createBook({metadata: {bookId: 1, ranobedbId: 'ran-654'}}); + expect(service.evaluateGroup(book, rule('equals', 'ranobedbId'))).toBe(true); + }); + + it('should detect present comicvineId', () => { + const book = createBook({metadata: {bookId: 1, comicvineId: 'cv-987'}}); + expect(service.evaluateGroup(book, rule('equals', 'comicvineId'))).toBe(true); + }); + + it('should detect missing comicvineId', () => { + const book = createBook({metadata: {bookId: 1}}); + expect(service.evaluateGroup(book, rule('not_equals', 'comicvineId'))).toBe(true); + }); + }); + + describe('boolean fields', () => { + it('should detect present abridged (true)', () => { + const book = createBook({metadata: {bookId: 1, abridged: true}}); + expect(service.evaluateGroup(book, rule('equals', 'abridged'))).toBe(true); + }); + + it('should detect present abridged (false)', () => { + const book = createBook({metadata: {bookId: 1, abridged: false}}); + expect(service.evaluateGroup(book, rule('equals', 'abridged'))).toBe(true); + }); + + it('should detect missing abridged', () => { + const book = createBook({metadata: {bookId: 1}}); + expect(service.evaluateGroup(book, rule('equals', 'abridged'))).toBe(false); + expect(service.evaluateGroup(book, rule('not_equals', 'abridged'))).toBe(true); + }); + }); + + describe('audiobook fields', () => { + it('should detect present audiobookDuration', () => { + const book = createBook({metadata: {bookId: 1, audiobookMetadata: {durationSeconds: 36000}}}); + expect(service.evaluateGroup(book, rule('equals', 'audiobookDuration'))).toBe(true); + }); + + it('should detect missing audiobookDuration (no audiobookMetadata)', () => { + const book = createBook({metadata: {bookId: 1}}); + expect(service.evaluateGroup(book, rule('not_equals', 'audiobookDuration'))).toBe(true); + }); + + it('should detect zero audiobookDuration as present', () => { + const book = createBook({metadata: {bookId: 1, audiobookMetadata: {durationSeconds: 0}}}); + expect(service.evaluateGroup(book, rule('equals', 'audiobookDuration'))).toBe(true); + }); + }); + + describe('comic metadata fields', () => { + it('should detect present comicCharacters', () => { + const book = createBook({metadata: {bookId: 1, comicMetadata: {characters: ['Batman', 'Joker']}}}); + expect(service.evaluateGroup(book, rule('equals', 'comicCharacters'))).toBe(true); + }); + + it('should detect empty comicCharacters as absent', () => { + const book = createBook({metadata: {bookId: 1, comicMetadata: {characters: []}}}); + expect(service.evaluateGroup(book, rule('equals', 'comicCharacters'))).toBe(false); + }); + + it('should detect missing comicCharacters (no comicMetadata)', () => { + const book = createBook({metadata: {bookId: 1}}); + expect(service.evaluateGroup(book, rule('not_equals', 'comicCharacters'))).toBe(true); + }); + + it('should detect present comicTeams', () => { + const book = createBook({metadata: {bookId: 1, comicMetadata: {teams: ['Justice League']}}}); + expect(service.evaluateGroup(book, rule('equals', 'comicTeams'))).toBe(true); + }); + + it('should detect present comicLocations', () => { + const book = createBook({metadata: {bookId: 1, comicMetadata: {locations: ['Gotham City']}}}); + expect(service.evaluateGroup(book, rule('equals', 'comicLocations'))).toBe(true); + }); + + it('should detect present comicPencillers', () => { + const book = createBook({metadata: {bookId: 1, comicMetadata: {pencillers: ['Jim Lee']}}}); + expect(service.evaluateGroup(book, rule('equals', 'comicPencillers'))).toBe(true); + }); + + it('should detect present comicInkers', () => { + const book = createBook({metadata: {bookId: 1, comicMetadata: {inkers: ['Scott Williams']}}}); + expect(service.evaluateGroup(book, rule('equals', 'comicInkers'))).toBe(true); + }); + + it('should detect present comicColorists', () => { + const book = createBook({metadata: {bookId: 1, comicMetadata: {colorists: ['Alex Sinclair']}}}); + expect(service.evaluateGroup(book, rule('equals', 'comicColorists'))).toBe(true); + }); + + it('should detect present comicLetterers', () => { + const book = createBook({metadata: {bookId: 1, comicMetadata: {letterers: ['Todd Klein']}}}); + expect(service.evaluateGroup(book, rule('equals', 'comicLetterers'))).toBe(true); + }); + + it('should detect present comicCoverArtists', () => { + const book = createBook({metadata: {bookId: 1, comicMetadata: {coverArtists: ['Alex Ross']}}}); + expect(service.evaluateGroup(book, rule('equals', 'comicCoverArtists'))).toBe(true); + }); + + it('should detect present comicEditors', () => { + const book = createBook({metadata: {bookId: 1, comicMetadata: {editors: ['Mark Doyle']}}}); + expect(service.evaluateGroup(book, rule('equals', 'comicEditors'))).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should return false for unknown metadata field', () => { + const book = createBook(); + expect(service.evaluateGroup(book, rule('equals', 'nonExistentField'))).toBe(false); + }); + + it('should handle book with no metadata', () => { + const book = createBook({metadata: undefined}); + expect(service.evaluateGroup(book, rule('equals', 'title'))).toBe(false); + expect(service.evaluateGroup(book, rule('not_equals', 'title'))).toBe(true); + }); + + it('should handle non-string rule value gracefully', () => { + const book = createBook({metadata: {bookId: 1, title: 'Test'}}); + const g: GroupRule = { + name: 'test', type: 'group', join: 'and', + rules: [{field: 'metadataPresence', operator: 'equals', value: null} as never] + }; + expect(service.evaluateGroup(book, g)).toBe(false); + }); + + it('should not require allBooks parameter', () => { + const book = createBook({metadata: {bookId: 1, title: 'Test'}}); + expect(service.evaluateGroup(book, rule('equals', 'title'))).toBe(true); + }); + }); + + describe('combined rules', () => { + it('should match books missing cover OR description', () => { + const bookNoCover = createBook({metadata: {bookId: 1, title: 'Test', description: 'Has desc'}}); + const bookNoDesc = createBook({metadata: {bookId: 2, title: 'Test', thumbnailUrl: 'http://cover.jpg'}}); + const bookHasBoth = createBook({metadata: {bookId: 3, title: 'Test', description: 'Has desc', thumbnailUrl: 'http://cover.jpg'}}); + + const group: GroupRule = { + name: 'test', type: 'group', join: 'or', + rules: [ + {field: 'metadataPresence', operator: 'not_equals', value: 'thumbnailUrl'}, + {field: 'metadataPresence', operator: 'not_equals', value: 'description'} + ] + }; + + expect(service.evaluateGroup(bookNoCover, group)).toBe(true); + expect(service.evaluateGroup(bookNoDesc, group)).toBe(true); + expect(service.evaluateGroup(bookHasBoth, group)).toBe(false); + }); + + it('should match books with complete identifiers', () => { + const bookComplete = createBook({metadata: {bookId: 1, isbn13: '9780123456789', goodreadsId: '12345'}}); + const bookPartial = createBook({metadata: {bookId: 2, isbn13: '9780123456789'}}); + + const group: GroupRule = { + name: 'test', type: 'group', join: 'and', + rules: [ + {field: 'metadataPresence', operator: 'equals', value: 'isbn13'}, + {field: 'metadataPresence', operator: 'equals', value: 'goodreadsId'} + ] + }; + + expect(service.evaluateGroup(bookComplete, group)).toBe(true); + expect(service.evaluateGroup(bookPartial, group)).toBe(false); + }); + + it('should work with non-metadataPresence rules in same group', () => { + const book = createBook({ + readStatus: ReadStatus.UNREAD, + metadata: {bookId: 1, title: 'Test'} + }); + + const group: GroupRule = { + name: 'test', type: 'group', join: 'and', + rules: [ + {field: 'readStatus', operator: 'equals', value: 'UNREAD'}, + {field: 'metadataPresence', operator: 'not_equals', value: 'thumbnailUrl'} + ] + }; + + expect(service.evaluateGroup(book, group)).toBe(true); + }); + }); +}); diff --git a/booklore-ui/src/app/features/magic-shelf/service/book-rule-evaluator.service.ts b/booklore-ui/src/app/features/magic-shelf/service/book-rule-evaluator.service.ts index 956e9f577..1ce04ef0b 100644 --- a/booklore-ui/src/app/features/magic-shelf/service/book-rule-evaluator.service.ts +++ b/booklore-ui/src/app/features/magic-shelf/service/book-rule-evaluator.service.ts @@ -17,6 +17,10 @@ export class BookRuleEvaluatorService { } private evaluateRule(book: Book, rule: Rule, allBooks: Book[]): boolean { + if (rule.field === 'metadataPresence') { + return this.evaluateMetadataPresence(book, rule); + } + if (rule.field === 'seriesStatus' || rule.field === 'seriesGaps' || rule.field === 'seriesPosition') { return this.evaluateCompositeField(book, rule, allBooks); } @@ -472,6 +476,76 @@ export class BookRuleEvaluatorService { } } + private evaluateMetadataPresence(book: Book, rule: Rule): boolean { + const metadataField = typeof rule.value === 'string' ? rule.value : ''; + const isPresent = this.isMetadataFieldPresent(book, metadataField); + return rule.operator === 'equals' ? isPresent : !isPresent; + } + + private isMetadataFieldPresent(book: Book, field: string): boolean { + const val = this.getMetadataFieldValue(book, field); + if (val == null) return false; + if (typeof val === 'string') return val.trim() !== ''; + if (Array.isArray(val)) return val.length > 0; + return true; + } + + private getMetadataFieldValue(book: Book, field: string): unknown { + switch (field) { + case 'title': return book.metadata?.title; + case 'subtitle': return book.metadata?.subtitle; + case 'description': return book.metadata?.description; + case 'publisher': return book.metadata?.publisher; + case 'publishedDate': return book.metadata?.publishedDate; + case 'language': return book.metadata?.language; + case 'thumbnailUrl': return book.metadata?.thumbnailUrl; + case 'narrator': return book.metadata?.narrator; + case 'contentRating': return book.metadata?.contentRating; + case 'pageCount': return book.metadata?.pageCount; + case 'seriesNumber': return book.metadata?.seriesNumber; + case 'seriesTotal': return book.metadata?.seriesTotal; + case 'ageRating': return book.metadata?.ageRating; + case 'seriesName': return book.metadata?.seriesName; + case 'isbn13': return book.metadata?.isbn13; + case 'isbn10': return book.metadata?.isbn10; + case 'asin': return book.metadata?.asin; + case 'authors': return book.metadata?.authors; + case 'categories': return book.metadata?.categories; + case 'moods': return book.metadata?.moods; + case 'tags': return book.metadata?.tags; + case 'personalRating': return book.personalRating; + case 'amazonRating': return book.metadata?.amazonRating; + case 'goodreadsRating': return book.metadata?.goodreadsRating; + case 'hardcoverRating': return book.metadata?.hardcoverRating; + case 'ranobedbRating': return book.metadata?.ranobedbRating; + case 'lubimyczytacRating': return book.metadata?.lubimyczytacRating; + case 'audibleRating': return book.metadata?.audibleRating; + case 'amazonReviewCount': return book.metadata?.amazonReviewCount; + case 'goodreadsReviewCount': return book.metadata?.goodreadsReviewCount; + case 'hardcoverReviewCount': return book.metadata?.hardcoverReviewCount; + case 'audibleReviewCount': return book.metadata?.audibleReviewCount; + case 'goodreadsId': return book.metadata?.goodreadsId; + case 'hardcoverId': return book.metadata?.hardcoverId; + case 'googleId': return book.metadata?.googleId; + case 'audibleId': return book.metadata?.audibleId; + case 'lubimyczytacId': return book.metadata?.lubimyczytacId; + case 'ranobedbId': return book.metadata?.ranobedbId; + case 'comicvineId': return book.metadata?.comicvineId; + case 'abridged': return book.metadata?.abridged; + case 'audiobookDuration': return book.metadata?.audiobookMetadata?.durationSeconds; + case 'comicCharacters': return book.metadata?.comicMetadata?.characters; + case 'comicTeams': return book.metadata?.comicMetadata?.teams; + case 'comicLocations': return book.metadata?.comicMetadata?.locations; + case 'comicPencillers': return book.metadata?.comicMetadata?.pencillers; + case 'comicInkers': return book.metadata?.comicMetadata?.inkers; + case 'comicColorists': return book.metadata?.comicMetadata?.colorists; + case 'comicLetterers': return book.metadata?.comicMetadata?.letterers; + case 'comicCoverArtists': return book.metadata?.comicMetadata?.coverArtists; + case 'comicEditors': return book.metadata?.comicMetadata?.editors; + default: return null; + } + } + private computeDateThreshold(amount: number, unit: string): Date { const now = new Date(); switch (unit.toLowerCase()) { diff --git a/booklore-ui/src/i18n/en/magic-shelf.json b/booklore-ui/src/i18n/en/magic-shelf.json index d77a14b10..f41e9c230 100644 --- a/booklore-ui/src/i18n/en/magic-shelf.json +++ b/booklore-ui/src/i18n/en/magic-shelf.json @@ -51,7 +51,8 @@ "selectPeriod": "Select Period", "selectSeriesStatus": "Select Status", "selectSeriesGap": "Select Gap Type", - "selectSeriesPosition": "Select Position" + "selectSeriesPosition": "Select Position", + "selectMetadataField": "Select Metadata Field" }, "conditions": { "and": "AND", @@ -104,7 +105,8 @@ "seriesStatus": "Series Status", "seriesGaps": "Series Gaps", "seriesPosition": "Series Position", - "readingProgress": "Reading Progress (%)" + "readingProgress": "Reading Progress (%)", + "metadataPresence": "Metadata Presence" }, "fieldGroups": { "organization": "Organization", @@ -114,7 +116,8 @@ "ratingsReviews": "Ratings & Reviews", "tagsMoods": "Tags & Moods", "audiobook": "Audiobook", - "fileIdentifiers": "File & Identifiers" + "fileIdentifiers": "File & Identifiers", + "qualityMetadata": "Quality & Metadata" }, "operators": { "equals": "Equals", @@ -192,6 +195,70 @@ "month": "This Month", "year": "This Year" }, + "metadataFieldGroups": { + "bookInfo": "Book Info", + "authorsCategories": "Authors & Categories", + "series": "Series", + "identifiers": "Identifiers", + "contentClassification": "Content Classification", + "ratings": "Ratings", + "reviewCounts": "Review Counts", + "externalIds": "External IDs", + "audiobook": "Audiobook", + "comic": "Comic" + }, + "metadataFields": { + "title": "Title", + "subtitle": "Subtitle", + "description": "Description", + "thumbnailUrl": "Cover Image", + "publisher": "Publisher", + "publishedDate": "Published Date", + "language": "Language", + "pageCount": "Page Count", + "authors": "Authors", + "categories": "Genre", + "moods": "Moods", + "tags": "Tags", + "seriesName": "Series Name", + "seriesNumber": "Series Number", + "seriesTotal": "Series Total", + "isbn13": "ISBN-13", + "isbn10": "ISBN-10", + "asin": "ASIN", + "ageRating": "Age Rating", + "contentRating": "Content Rating", + "personalRating": "Personal Rating", + "amazonRating": "Amazon Rating", + "goodreadsRating": "Goodreads Rating", + "hardcoverRating": "Hardcover Rating", + "ranobedbRating": "Ranobedb Rating", + "lubimyczytacRating": "Lubimyczytac Rating", + "audibleRating": "Audible Rating", + "amazonReviewCount": "Amazon Reviews", + "goodreadsReviewCount": "Goodreads Reviews", + "hardcoverReviewCount": "Hardcover Reviews", + "audibleReviewCount": "Audible Reviews", + "goodreadsId": "Goodreads ID", + "hardcoverId": "Hardcover ID", + "googleId": "Google ID", + "audibleId": "Audible ID", + "lubimyczytacId": "Lubimyczytac ID", + "ranobedbId": "Ranobedb ID", + "comicvineId": "ComicVine ID", + "narrator": "Narrator", + "abridged": "Abridged", + "audiobookDuration": "Duration", + "comicCharacters": "Characters", + "comicTeams": "Teams", + "comicLocations": "Locations", + "comicPencillers": "Pencillers", + "comicInkers": "Inkers", + "comicColorists": "Colorists", + "comicLetterers": "Letterers", + "comicCoverArtists": "Cover Artists", + "comicEditors": "Editors" + }, "toast": { "validationErrorSummary": "Validation Error", "validationErrorDetail": "You must add at least one valid rule before saving.", diff --git a/booklore-ui/src/i18n/es/magic-shelf.json b/booklore-ui/src/i18n/es/magic-shelf.json index 74063f942..811911095 100644 --- a/booklore-ui/src/i18n/es/magic-shelf.json +++ b/booklore-ui/src/i18n/es/magic-shelf.json @@ -51,7 +51,8 @@ "selectPeriod": "Seleccionar Per\u00edodo", "selectSeriesStatus": "Seleccionar Estado", "selectSeriesGap": "Seleccionar Tipo de Hueco", - "selectSeriesPosition": "Seleccionar Posici\u00f3n" + "selectSeriesPosition": "Seleccionar Posici\u00f3n", + "selectMetadataField": "Seleccionar Campo de Metadatos" }, "conditions": { "and": "Y", @@ -104,7 +105,8 @@ "seriesStatus": "Estado de la Serie", "seriesGaps": "Huecos en la Serie", "seriesPosition": "Posici\u00f3n en la Serie", - "readingProgress": "Progreso de Lectura (%)" + "readingProgress": "Progreso de Lectura (%)", + "metadataPresence": "Presencia de Metadatos" }, "fieldGroups": { "organization": "Organizaci\u00f3n", @@ -114,7 +116,8 @@ "ratingsReviews": "Calificaciones y Rese\u00f1as", "tagsMoods": "Etiquetas y Estados de \u00c1nimo", "audiobook": "Audiolibro", - "fileIdentifiers": "Archivo e Identificadores" + "fileIdentifiers": "Archivo e Identificadores", + "qualityMetadata": "Calidad y Metadatos" }, "operators": { "equals": "Igual", @@ -192,6 +195,70 @@ "month": "Este Mes", "year": "Este A\u00f1o" }, + "metadataFieldGroups": { + "bookInfo": "Info del Libro", + "authorsCategories": "Autores y Categor\u00edas", + "series": "Serie", + "identifiers": "Identificadores", + "contentClassification": "Clasificaci\u00f3n de Contenido", + "ratings": "Calificaciones", + "reviewCounts": "Cantidad de Rese\u00f1as", + "externalIds": "IDs Externos", + "audiobook": "Audiolibro", + "comic": "C\u00f3mic" + }, + "metadataFields": { + "title": "T\u00edtulo", + "subtitle": "Subt\u00edtulo", + "description": "Descripci\u00f3n", + "thumbnailUrl": "Imagen de Portada", + "publisher": "Editorial", + "publishedDate": "Fecha de Publicaci\u00f3n", + "language": "Idioma", + "pageCount": "N\u00famero de P\u00e1ginas", + "authors": "Autores", + "categories": "G\u00e9nero", + "moods": "Estados de \u00c1nimo", + "tags": "Etiquetas", + "seriesName": "Nombre de la Serie", + "seriesNumber": "N\u00famero de Serie", + "seriesTotal": "Total de la Serie", + "isbn13": "ISBN-13", + "isbn10": "ISBN-10", + "asin": "ASIN", + "ageRating": "Clasificaci\u00f3n por Edad", + "contentRating": "Clasificaci\u00f3n de Contenido", + "personalRating": "Calificaci\u00f3n Personal", + "amazonRating": "Calificaci\u00f3n de Amazon", + "goodreadsRating": "Calificaci\u00f3n de Goodreads", + "hardcoverRating": "Calificaci\u00f3n de Hardcover", + "ranobedbRating": "Calificaci\u00f3n de Ranobedb", + "lubimyczytacRating": "Calificaci\u00f3n de Lubimyczytac", + "audibleRating": "Calificaci\u00f3n de Audible", + "amazonReviewCount": "Rese\u00f1as de Amazon", + "goodreadsReviewCount": "Rese\u00f1as de Goodreads", + "hardcoverReviewCount": "Rese\u00f1as de Hardcover", + "audibleReviewCount": "Rese\u00f1as de Audible", + "goodreadsId": "ID de Goodreads", + "hardcoverId": "ID de Hardcover", + "googleId": "ID de Google", + "audibleId": "ID de Audible", + "lubimyczytacId": "ID de Lubimyczytac", + "ranobedbId": "ID de Ranobedb", + "comicvineId": "ID de ComicVine", + "narrator": "Narrador", + "abridged": "Abreviado", + "audiobookDuration": "Duraci\u00f3n", + "comicCharacters": "Personajes", + "comicTeams": "Equipos", + "comicLocations": "Ubicaciones", + "comicPencillers": "Dibujantes", + "comicInkers": "Entintadores", + "comicColorists": "Coloristas", + "comicLetterers": "Letristas", + "comicCoverArtists": "Artistas de Portada", + "comicEditors": "Editores" + }, "toast": { "validationErrorSummary": "Error de Validaci\u00f3n", "validationErrorDetail": "Debe agregar al menos una regla v\u00e1lida antes de guardar.",