feat(books): add lock status to library filter rules

Closes #327
This commit is contained in:
neon
2026-06-14 23:33:44 +00:00
parent daa0001877
commit 445bb39978
7 changed files with 151 additions and 4 deletions
@@ -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: '' })
})
})
+14 -1
View File
@@ -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
+9 -2
View File
@@ -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) => {