fix(book-rule-evaluator): fix file type handling and add mapping for specific formats to fix magic shelve based on filetype (#2480)

* 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 <bszucs1209@gmail.com>

* fix(book-rule-evaluator): update test to simplify book creation for group rule evaluation

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
This commit is contained in:
Balázs Szücs
2026-01-27 18:27:19 +01:00
committed by GitHub
parent fab7c14030
commit df52e9924e
2 changed files with 249 additions and 14 deletions

View File

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

View File

@@ -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<string, unknown>)[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;
}
}