From df52e9924e5e8354a30efffb56dc69e218ade14d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Tue, 27 Jan 2026 18:27:19 +0100 Subject: [PATCH] fix(book-rule-evaluator): fix file type handling and add mapping for specific formats to fix magic shelve based on filetype (#2480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(book-rule-evaluator): fix file type handling and add mapping for specific formats to fix magic shelve based on filetype Signed-off-by: Balázs Szücs * fix(book-rule-evaluator): update test to simplify book creation for group rule evaluation Signed-off-by: Balázs Szücs --------- Signed-off-by: Balázs Szücs --- .../book-rule-evaluator.service.spec.ts | 208 ++++++++++++++++++ .../service/book-rule-evaluator.service.ts | 55 +++-- 2 files changed, 249 insertions(+), 14 deletions(-) create mode 100644 booklore-ui/src/app/features/magic-shelf/service/book-rule-evaluator.service.spec.ts diff --git a/booklore-ui/src/app/features/magic-shelf/service/book-rule-evaluator.service.spec.ts b/booklore-ui/src/app/features/magic-shelf/service/book-rule-evaluator.service.spec.ts new file mode 100644 index 000000000..87d726ad7 --- /dev/null +++ b/booklore-ui/src/app/features/magic-shelf/service/book-rule-evaluator.service.spec.ts @@ -0,0 +1,208 @@ +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', () => { + 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 + }); + + describe('fileType filtering', () => { + it('should filter EPUB books correctly when rule uses "epub"', () => { + const book = createBook({ bookType: 'EPUB' }); + const group: GroupRule = { + name: 'test', + type: 'group', + join: 'and', + rules: [ + { + field: 'fileType', + operator: 'equals', + value: 'epub' + } + ] + }; + + const result = service.evaluateGroup(book, group); + expect(result).toBe(true); + }); + + it('should filter PDF books correctly when rule uses "pdf"', () => { + const book = createBook({ bookType: 'PDF' }); + const group: GroupRule = { + name: 'test', + type: 'group', + join: 'and', + rules: [ + { + field: 'fileType', + operator: 'equals', + value: 'pdf' + } + ] + }; + + const result = service.evaluateGroup(book, group); + expect(result).toBe(true); + }); + + it('should handle CBX books correctly for cbr, cbz, and cb7 rules', () => { + const book = createBook({ bookType: 'CBX' }); + + // Test CBR + const cbrGroup: GroupRule = { + name: 'test', + type: 'group', + join: 'and', + rules: [ + { + field: 'fileType', + operator: 'equals', + value: 'cbr' + } + ] + }; + expect(service.evaluateGroup(book, cbrGroup)).toBe(true); + + // Test CBZ + const cbzGroup: GroupRule = { + name: 'test', + type: 'group', + join: 'and', + rules: [ + { + field: 'fileType', + operator: 'equals', + value: 'cbz' + } + ] + }; + expect(service.evaluateGroup(book, cbzGroup)).toBe(true); + + // Test CB7 + const cb7Group: GroupRule = { + name: 'test', + type: 'group', + join: 'and', + rules: [ + { + field: 'fileType', + operator: 'equals', + value: 'cb7' + } + ] + }; + expect(service.evaluateGroup(book, cb7Group)).toBe(true); + }); + + it('should handle not_equals operator correctly', () => { + const epubBook = createBook({ bookType: 'EPUB' }); + const group: GroupRule = { + name: 'test', + type: 'group', + join: 'and', + rules: [ + { + field: 'fileType', + operator: 'not_equals', + value: 'pdf' + } + ] + }; + + const result = service.evaluateGroup(epubBook, group); + expect(result).toBe(true); + + // Test the opposite case + const pdfBook = createBook({ bookType: 'PDF' }); + const result2 = service.evaluateGroup(pdfBook, group); + expect(result2).toBe(false); + }); + + it('should filter EPUB books correctly when rule uses "not_equals" with "epub"', () => { + const epubBook = createBook({ bookType: 'EPUB' }); + const pdfBook = createBook({ bookType: 'PDF' }); + + const group: GroupRule = { + name: 'test', + type: 'group', + join: 'and', + rules: [ + { + field: 'fileType', + operator: 'not_equals', + value: 'epub' + } + ] + }; + + // EPUB book should not match when rule is "not_equals epub" + expect(service.evaluateGroup(epubBook, group)).toBe(false); + + // PDF book should match when rule is "not_equals epub" + expect(service.evaluateGroup(pdfBook, group)).toBe(true); + }); + }); + + describe('evaluateGroup', () => { + it('should evaluate group rules with AND logic', () => { + const book = createBook({ + bookType: 'EPUB' + }); + + const group: GroupRule = { + name: 'test', + type: 'group', + join: 'and', + rules: [ + { field: 'fileType', operator: 'equals', value: 'epub' }, + { field: 'language', operator: 'equals', value: 'en' } + ] + }; + + const result = service.evaluateGroup(book, group); + expect(result).toBe(true); + }); + + it('should evaluate group rules with OR logic', () => { + const book = createBook({ bookType: 'PDF' }); + + const group: GroupRule = { + name: 'test', + type: 'group', + join: 'or', + rules: [ + { field: 'fileType', operator: 'equals', value: 'epub' }, + { field: 'fileType', operator: 'equals', value: 'pdf' } + ] + }; + + const result = service.evaluateGroup(book, group); + expect(result).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 725aa2abd..f0a3b10e8 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 @@ -1,6 +1,6 @@ -import { Injectable } from '@angular/core'; -import { Book } from '../../book/model/book.model'; -import { GroupRule, Rule, RuleField } from '../component/magic-shelf-component'; +import {Injectable} from '@angular/core'; +import {Book} from '../../book/model/book.model'; +import {GroupRule, Rule, RuleField} from '../component/magic-shelf-component'; @Injectable({ providedIn: 'root' }) export class BookRuleEvaluatorService { @@ -35,6 +35,20 @@ export class BookRuleEvaluatorService { const ruleStart = normalize(rule.valueStart); const ruleEnd = normalize(rule.valueEnd); + const mapFileTypeValue = (uiValue: string): string => { + const lowerValue = uiValue.toLowerCase(); + switch (lowerValue) { + case 'cbr': + case 'cbz': + case 'cb7': + return 'cbx'; + case 'azw': + return 'azw3'; + default: + return lowerValue; + } + }; + const getArrayField = (field: RuleField): string[] => { switch (field) { case 'authors': @@ -48,7 +62,7 @@ export class BookRuleEvaluatorService { case 'readStatus': return [String(book.readStatus ?? 'UNSET').toLowerCase()]; case 'fileType': - return [String(this.getFileExtension(book.fileName) ?? '').toLowerCase()]; + return [String(book.bookType ?? '').toLowerCase()]; case 'library': return [String(book.libraryId)]; case 'shelf': @@ -73,9 +87,21 @@ export class BookRuleEvaluatorService { }; const isNumericIdField = rule.field === 'library' || rule.field === 'shelf'; + const isFileTypeField = rule.field === 'fileType'; + const ruleList = Array.isArray(rule.value) - ? rule.value.map(v => isNumericIdField ? String(v) : String(v).toLowerCase()) - : (rule.value ? [isNumericIdField ? String(rule.value) : String(rule.value).toLowerCase()] : []); + ? rule.value.map(v => { + if (isNumericIdField) return String(v); + const lowerValue = String(v).toLowerCase(); + return isFileTypeField ? mapFileTypeValue(lowerValue) : lowerValue; + }) + : (rule.value ? [ + isNumericIdField + ? String(rule.value) + : isFileTypeField + ? mapFileTypeValue(String(rule.value).toLowerCase()) + : String(rule.value).toLowerCase() + ] : []); switch (rule.operator) { case 'equals': @@ -85,6 +111,10 @@ export class BookRuleEvaluatorService { if (value instanceof Date && ruleVal instanceof Date) { return value.getTime() === ruleVal.getTime(); } + if (isFileTypeField && typeof ruleVal === 'string') { + const mappedRuleVal = mapFileTypeValue(ruleVal.toLowerCase()); + return value === mappedRuleVal; + } return value === ruleVal; case 'not_equals': @@ -94,6 +124,10 @@ export class BookRuleEvaluatorService { if (value instanceof Date && ruleVal instanceof Date) { return value.getTime() !== ruleVal.getTime(); } + if (isFileTypeField && typeof ruleVal === 'string') { + const mappedRuleVal = mapFileTypeValue(ruleVal.toLowerCase()); + return value !== mappedRuleVal; + } return value !== ruleVal; case 'contains': @@ -204,7 +238,7 @@ export class BookRuleEvaluatorService { case 'readStatus': return book.readStatus ?? 'UNSET'; case 'fileType': - return this.getFileExtension(book.fileName)?.toLowerCase() ?? null; + return book.bookType?.toLowerCase() ?? null; case 'fileSize': return book.fileSizeKb; case 'metadataScore': @@ -263,11 +297,4 @@ export class BookRuleEvaluatorService { return (book as Record)[field]; } } - - private getFileExtension(filePath?: string): string | null { - if (!filePath) return null; - const parts = filePath.split('.'); - if (parts.length < 2) return null; - return parts.pop() ?? null; - } }