mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
@@ -22,7 +22,17 @@ const emit = defineEmits<{
|
||||
const MAX_DEPTH = 5
|
||||
const NUMERIC_FIELDS: RuleField[] = ['seriesIndex', 'publishedYear', 'pageCount', 'rating', 'metadataScore']
|
||||
const DATE_FIELDS: RuleField[] = ['addedAt', 'startedAt', 'finishedAt']
|
||||
const NO_VALUE_OPERATORS: RuleOperator[] = ['isEmpty', 'isNotEmpty', 'isMissing', 'isPresent', 'isUnread', 'isInProgress', 'isFinished']
|
||||
const NO_VALUE_OPERATORS: RuleOperator[] = [
|
||||
'isEmpty',
|
||||
'isNotEmpty',
|
||||
'isMissing',
|
||||
'isPresent',
|
||||
'isUnread',
|
||||
'isInProgress',
|
||||
'isFinished',
|
||||
'isLocked',
|
||||
'isUnlocked',
|
||||
]
|
||||
const BETWEEN_OPERATORS: RuleOperator[] = ['between']
|
||||
const COLLECTION_OPERATORS: RuleOperator[] = ['includesAny', 'includesAll', 'excludesAll']
|
||||
|
||||
|
||||
@@ -76,4 +76,33 @@ describe('BookFilterBuilder', () => {
|
||||
const operatorOptions = operatorSelect!.findAll('option').map((opt) => opt.text())
|
||||
expect(operatorOptions).toEqual(expect.arrayContaining(['before', 'after', 'between', 'within last', 'is empty', 'is not empty']))
|
||||
})
|
||||
|
||||
it('includes Lock Status in field options', () => {
|
||||
const wrapper = mount(BookFilterBuilder, {
|
||||
props: {
|
||||
modelValue: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'Dune' }] },
|
||||
},
|
||||
})
|
||||
|
||||
const [fieldSelect] = wrapper.findAll('select')
|
||||
const optionText = fieldSelect!.findAll('option').map((opt) => opt.text())
|
||||
expect(optionText).toContain('Lock Status')
|
||||
})
|
||||
|
||||
it('offers only is locked / is unlocked operators and no value input for Lock Status', async () => {
|
||||
const wrapper = mount(BookFilterBuilder, {
|
||||
props: {
|
||||
modelValue: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'Dune' }] },
|
||||
},
|
||||
})
|
||||
|
||||
const [fieldSelect] = wrapper.findAll('select')
|
||||
await fieldSelect!.setValue('lockStatus')
|
||||
|
||||
const operatorSelect = wrapper.findAll('select')[1]
|
||||
const operatorOptions = operatorSelect!.findAll('option').map((opt) => opt.text())
|
||||
expect(operatorOptions).toEqual(['is locked', 'is unlocked'])
|
||||
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Rule } from '@bookorbit/types'
|
||||
import { FIELD_LABELS, OPERATOR_LABELS, ruleToParts } from '../filter-labels'
|
||||
|
||||
describe('lock status labels', () => {
|
||||
it('exposes a Lock Status field label', () => {
|
||||
expect(FIELD_LABELS.lockStatus).toBe('Lock Status')
|
||||
})
|
||||
|
||||
it('exposes is locked / is unlocked operator labels', () => {
|
||||
expect(OPERATOR_LABELS.isLocked).toBe('is locked')
|
||||
expect(OPERATOR_LABELS.isUnlocked).toBe('is unlocked')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ruleToParts', () => {
|
||||
it('renders a locked rule with no value', () => {
|
||||
const rule: Rule = { type: 'rule', field: 'lockStatus', operator: 'isLocked' }
|
||||
expect(ruleToParts(rule)).toEqual({ field: 'Lock Status', operator: 'is locked', value: null })
|
||||
})
|
||||
|
||||
it('renders an unlocked rule with no value', () => {
|
||||
const rule: Rule = { type: 'rule', field: 'lockStatus', operator: 'isUnlocked' }
|
||||
expect(ruleToParts(rule)).toEqual({ field: 'Lock Status', operator: 'is unlocked', value: null })
|
||||
})
|
||||
|
||||
it('renders withinLast rules with a days suffix', () => {
|
||||
const rule: Rule = { type: 'rule', field: 'addedAt', operator: 'withinLast', value: 7 }
|
||||
expect(ruleToParts(rule)).toEqual({ field: 'Added Date', operator: 'within last', value: '7 days' })
|
||||
})
|
||||
|
||||
it('joins array values with commas', () => {
|
||||
const rule: Rule = { type: 'rule', field: 'author', operator: 'includesAny', value: ['Tolkien', 'Le Guin'] }
|
||||
expect(ruleToParts(rule)).toEqual({ field: 'Author', operator: 'includes any of', value: 'Tolkien, Le Guin' })
|
||||
})
|
||||
|
||||
it('renders a range when valueTo is present', () => {
|
||||
const rule: Rule = { type: 'rule', field: 'pageCount', operator: 'between', value: 100, valueTo: 200 }
|
||||
expect(ruleToParts(rule)).toEqual({ field: 'Page Count', operator: 'between', value: '100 - 200' })
|
||||
})
|
||||
|
||||
it('renders an empty value when a valued operator has no value', () => {
|
||||
const rule: Rule = { type: 'rule', field: 'title', operator: 'eq' }
|
||||
expect(ruleToParts(rule)).toEqual({ field: 'Title', operator: 'is', value: '' })
|
||||
})
|
||||
})
|
||||
@@ -48,6 +48,7 @@ export const FIELD_LABELS: Record<RuleField, string> = {
|
||||
isbn: 'ISBN',
|
||||
metadataScore: 'Metadata Score',
|
||||
cover: 'Cover',
|
||||
lockStatus: 'Lock Status',
|
||||
}
|
||||
|
||||
export const OPERATOR_LABELS: Record<RuleOperator, string> = {
|
||||
@@ -75,9 +76,21 @@ export const OPERATOR_LABELS: Record<RuleOperator, string> = {
|
||||
isUnread: 'is unread',
|
||||
isInProgress: 'is in progress',
|
||||
isFinished: 'is finished',
|
||||
isLocked: 'is locked',
|
||||
isUnlocked: 'is unlocked',
|
||||
}
|
||||
|
||||
const NO_VALUE_OPS: RuleOperator[] = ['isEmpty', 'isNotEmpty', 'isMissing', 'isPresent', 'isUnread', 'isInProgress', 'isFinished']
|
||||
const NO_VALUE_OPS: RuleOperator[] = [
|
||||
'isEmpty',
|
||||
'isNotEmpty',
|
||||
'isMissing',
|
||||
'isPresent',
|
||||
'isUnread',
|
||||
'isInProgress',
|
||||
'isFinished',
|
||||
'isLocked',
|
||||
'isUnlocked',
|
||||
]
|
||||
|
||||
export function ruleToParts(rule: Rule): { field: string; operator: string; value: string | null } {
|
||||
const field = FIELD_LABELS[rule.field] ?? rule.field
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* - `library` - resolved via `books.library_id` join to `libraries.name`
|
||||
* - `format` - resolved via `book_files.format` (primary file)
|
||||
* - `isbn` - matches both `isbn10` and `isbn13` in `book_metadata`
|
||||
* - `lockStatus` - derived from `book_metadata.locked_fields` (non-empty array = locked)
|
||||
*/
|
||||
export type RuleField =
|
||||
| "title"
|
||||
@@ -39,7 +40,8 @@ export type RuleField =
|
||||
| "description"
|
||||
| "isbn"
|
||||
| "metadataScore"
|
||||
| "cover";
|
||||
| "cover"
|
||||
| "lockStatus";
|
||||
|
||||
export type RuleOperator =
|
||||
| "contains"
|
||||
@@ -65,7 +67,9 @@ export type RuleOperator =
|
||||
| "isPresent"
|
||||
| "isUnread"
|
||||
| "isInProgress"
|
||||
| "isFinished";
|
||||
| "isFinished"
|
||||
| "isLocked"
|
||||
| "isUnlocked";
|
||||
|
||||
export const FIELD_OPERATORS: Record<RuleField, RuleOperator[]> = {
|
||||
title: ["contains", "notContains", "startsWith", "endsWith", "eq", "notEq", "isEmpty", "isNotEmpty"],
|
||||
@@ -92,6 +96,7 @@ export const FIELD_OPERATORS: Record<RuleField, RuleOperator[]> = {
|
||||
isbn: ["isEmpty", "isNotEmpty", "eq"],
|
||||
metadataScore: ["gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
|
||||
cover: ["isMissing", "isPresent"],
|
||||
lockStatus: ["isLocked", "isUnlocked"],
|
||||
};
|
||||
|
||||
export const RULE_FIELDS = Object.keys(FIELD_OPERATORS) as RuleField[];
|
||||
@@ -121,6 +126,8 @@ export const RULE_OPERATORS: RuleOperator[] = [
|
||||
"isUnread",
|
||||
"isInProgress",
|
||||
"isFinished",
|
||||
"isLocked",
|
||||
"isUnlocked",
|
||||
];
|
||||
|
||||
export type Rule = {
|
||||
|
||||
@@ -86,6 +86,8 @@ function buildValueFor(operator: RuleOperator, field: RuleField): { value?: unkn
|
||||
case 'isUnread':
|
||||
case 'isInProgress':
|
||||
case 'isFinished':
|
||||
case 'isLocked':
|
||||
case 'isUnlocked':
|
||||
return {};
|
||||
case 'contains':
|
||||
case 'notContains':
|
||||
@@ -1140,6 +1142,33 @@ describe('coverRuleToSql', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('lockStatusRuleToSql', () => {
|
||||
it('isLocked produces cardinality(lockedFields) > 0', () => {
|
||||
const { builder } = makeBuilder();
|
||||
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'lockStatus', operator: 'isLocked' }) as never, BASE_CTX) as any;
|
||||
const clause = getRuleSql(where);
|
||||
expect(clause).toMatchObject({ type: 'sql' });
|
||||
expect(clause.text).toContain('cardinality');
|
||||
expect(clause.text).toContain('> 0');
|
||||
});
|
||||
|
||||
it('isUnlocked produces cardinality(lockedFields) = 0', () => {
|
||||
const { builder } = makeBuilder();
|
||||
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'lockStatus', operator: 'isUnlocked' }) as never, BASE_CTX) as any;
|
||||
const clause = getRuleSql(where);
|
||||
expect(clause).toMatchObject({ type: 'sql' });
|
||||
expect(clause.text).toContain('cardinality');
|
||||
expect(clause.text).toContain('= 0');
|
||||
});
|
||||
|
||||
it('rejects an invalid operator for lockStatus', () => {
|
||||
const { builder } = makeBuilder();
|
||||
expect(() => builder.buildWhere(wrapRule({ type: 'rule', field: 'lockStatus', operator: 'isPresent' }) as never, BASE_CTX)).toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BookQueryBuilder.hasSeriesFilter', () => {
|
||||
it('returns false for undefined', () => {
|
||||
expect(BookQueryBuilder.hasSeriesFilter(undefined)).toBe(false);
|
||||
|
||||
@@ -167,6 +167,8 @@ export class BookQueryBuilder {
|
||||
return this.numericRuleToSql(bookMetadata.metadataScore, operator, value as number, valueTo as number | undefined);
|
||||
case 'cover':
|
||||
return this.coverRuleToSql(operator);
|
||||
case 'lockStatus':
|
||||
return this.lockStatusRuleToSql(operator);
|
||||
default:
|
||||
throw new BadRequestException(`Unknown filter field: ${String(field)}`);
|
||||
}
|
||||
@@ -489,6 +491,17 @@ export class BookQueryBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
private lockStatusRuleToSql(operator: string): SQL {
|
||||
switch (operator) {
|
||||
case 'isLocked':
|
||||
return sql`coalesce(cardinality(${bookMetadata.lockedFields}), 0) > 0`;
|
||||
case 'isUnlocked':
|
||||
return sql`coalesce(cardinality(${bookMetadata.lockedFields}), 0) = 0`;
|
||||
default:
|
||||
throw new BadRequestException(`Invalid operator '${operator}' for lockStatus field`);
|
||||
}
|
||||
}
|
||||
|
||||
private readProgressRuleToSql(operator: string, userId?: number): SQL {
|
||||
if (userId === undefined) throw new BadRequestException('Reading progress filter requires an authenticated user');
|
||||
const existsReadingProgress = (whereClause: SQL) => {
|
||||
|
||||
Reference in New Issue
Block a user