feat: add metadata presence filter to magic shelf (#2757)

This commit is contained in:
ACX
2026-02-14 21:41:03 -07:00
committed by GitHub
parent af0f5a4080
commit 0899b99188
10 changed files with 1913 additions and 15 deletions

View File

@@ -96,6 +96,8 @@ public enum RuleField {
@JsonProperty("seriesPosition")
SERIES_POSITION,
@JsonProperty("readingProgress")
READING_PROGRESS
READING_PROGRESS,
@JsonProperty("metadataPresence")
METADATA_PRESENCE
}

View File

@@ -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<BookEntity> root, Join<BookEntity, UserBookProgressEntity> 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<BookEntity> root, Join<BookEntity, UserBookProgressEntity> 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<BookEntity> root, Join<BookEntity, UserBookProgressEntity> 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<Long> sub = query.subquery(Long.class);
Root<BookFileEntity> 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<BookEntity> root, String collectionName) {
Subquery<Long> sub = query.subquery(Long.class);
Root<BookEntity> subRoot = sub.from(BookEntity.class);
Join<Object, Object> 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<BookEntity> root, String collectionName) {
Subquery<Long> sub = query.subquery(Long.class);
Root<BookEntity> subRoot = sub.from(BookEntity.class);
Join<Object, Object> metadataJoin = subRoot.join("metadata", JoinType.INNER);
Join<Object, Object> 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<BookEntity> root, ComicCreatorRole role) {
Subquery<Long> sub = query.subquery(Long.class);
Root<ComicCreatorMappingEntity> 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<BookEntity> root, Predicate hasSeries, Long userId) {
Predicate condition = switch (value) {
case "reading" -> seriesHasReadStatus(query, cb, root, userId, List.of("READING", "RE_READING"));

View File

@@ -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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> idsName = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "seriesName"));
assertThat(idsName).contains(withSeries.getId());
assertThat(idsName).doesNotContain(noSeries.getId());
List<Long> idsNumber = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "seriesNumber"));
assertThat(idsNumber).contains(withSeries.getId());
assertThat(idsNumber).doesNotContain(noSeries.getId());
List<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> coloristIds = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "comicColorists"));
assertThat(coloristIds).contains(comicBook.getId());
// Has penciller → should NOT match (only colorist assigned)
List<Long> pencillerIds = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "comicPencillers"));
assertThat(pencillerIds).doesNotContain(comicBook.getId());
// Has inker → should NOT match
List<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Long> ids = findMatchingIds(singleRule(RuleField.METADATA_PRESENCE, RuleOperator.EQUALS, "audiobookDuration"));
assertThat(ids).doesNotContain(bookNoDuration.getId());
}
}
@Nested
class IntegerFieldTests {
@Test

View File

@@ -159,6 +159,8 @@
<p-select [options]="seriesGapsOptions" formControlName="value" [placeholder]="t('placeholders.selectSeriesGap')" appendTo="body" class="value-input"></p-select>
} @else if (ruleCtrl.get('field')?.value === 'seriesPosition') {
<p-select [options]="seriesPositionOptions" formControlName="value" [placeholder]="t('placeholders.selectSeriesPosition')" appendTo="body" class="value-input"></p-select>
} @else if (ruleCtrl.get('field')?.value === 'metadataPresence') {
<p-select [options]="metadataPresenceOptions" [group]="true" optionGroupLabel="label" optionGroupChildren="items" [filter]="true" filterBy="label" formControlName="value" [placeholder]="t('placeholders.selectMetadataField')" appendTo="body" class="value-input"></p-select>
} @else if (['includes_any', 'includes_all', 'excludes_all'].includes(ruleCtrl.get('operator')?.value)) {
@if (ruleCtrl.get('field')?.value === 'readStatus') {
<p-multiSelect [options]="readStatusOptions" formControlName="value" display="chip" class="value-input" appendTo="body" [placeholder]="t('placeholders.selectStatuses')"></p-multiSelect>

View File

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

View File

@@ -92,7 +92,8 @@ export type RuleField =
| 'seriesStatus'
| 'seriesGaps'
| 'seriesPosition'
| 'readingProgress';
| 'readingProgress'
| 'metadataPresence';
interface FullFieldConfig {
@@ -183,7 +184,8 @@ const FIELD_CONFIGS: Record<RuleField, FullFieldConfig> = {
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'},

View File

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

View File

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

View File

@@ -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.",

View File

@@ -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.",