diff --git a/client/src/features/book/components/BookFilterBuilder.vue b/client/src/features/book/components/BookFilterBuilder.vue index 323ec392e..d126e2dee 100644 --- a/client/src/features/book/components/BookFilterBuilder.vue +++ b/client/src/features/book/components/BookFilterBuilder.vue @@ -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'] diff --git a/client/src/features/book/components/__tests__/BookFilterBuilder.spec.ts b/client/src/features/book/components/__tests__/BookFilterBuilder.spec.ts index da24f89dd..dc9ba29ae 100644 --- a/client/src/features/book/components/__tests__/BookFilterBuilder.spec.ts +++ b/client/src/features/book/components/__tests__/BookFilterBuilder.spec.ts @@ -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) + }) }) diff --git a/client/src/features/book/lib/__tests__/filter-labels.spec.ts b/client/src/features/book/lib/__tests__/filter-labels.spec.ts new file mode 100644 index 000000000..3988689f4 --- /dev/null +++ b/client/src/features/book/lib/__tests__/filter-labels.spec.ts @@ -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: '' }) + }) +}) diff --git a/client/src/features/book/lib/filter-labels.ts b/client/src/features/book/lib/filter-labels.ts index 68318e2c4..dc1e02dff 100644 --- a/client/src/features/book/lib/filter-labels.ts +++ b/client/src/features/book/lib/filter-labels.ts @@ -48,6 +48,7 @@ export const FIELD_LABELS: Record = { isbn: 'ISBN', metadataScore: 'Metadata Score', cover: 'Cover', + lockStatus: 'Lock Status', } export const OPERATOR_LABELS: Record = { @@ -75,9 +76,21 @@ export const OPERATOR_LABELS: Record = { 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 diff --git a/packages/types/src/query.ts b/packages/types/src/query.ts index 3ab377fa4..a1e5df442 100644 --- a/packages/types/src/query.ts +++ b/packages/types/src/query.ts @@ -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 = { title: ["contains", "notContains", "startsWith", "endsWith", "eq", "notEq", "isEmpty", "isNotEmpty"], @@ -92,6 +96,7 @@ export const FIELD_OPERATORS: Record = { 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 = { diff --git a/server/src/modules/book/book-query-builder.service.test.ts b/server/src/modules/book/book-query-builder.service.test.ts index 7282bf92d..606dbbd6e 100644 --- a/server/src/modules/book/book-query-builder.service.test.ts +++ b/server/src/modules/book/book-query-builder.service.test.ts @@ -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); diff --git a/server/src/modules/book/book-query-builder.service.ts b/server/src/modules/book/book-query-builder.service.ts index 6802ca51d..09a9777c2 100644 --- a/server/src/modules/book/book-query-builder.service.ts +++ b/server/src/modules/book/book-query-builder.service.ts @@ -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) => {