From 1dd39bf0ebfc7334643982a1857d2d7562787f82 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:32:28 +0000 Subject: [PATCH] feat(books): add primary file size filter rule Add fileSize as a filterable rule field resolved against the book's primary file size in bytes. In the filter builder UI, allow operators to input sizes in megabytes with automatic serialization to bytes for queries. --- .../book/components/BookFilterBuilder.vue | 49 +++++++++++++++++-- .../__tests__/BookFilterBuilder.spec.ts | 28 +++++++++++ .../book/lib/__tests__/filter-labels.spec.ts | 5 ++ client/src/features/book/lib/filter-labels.ts | 6 +++ client/src/locales/en.json | 7 ++- packages/types/src/query.ts | 3 ++ .../book/book-query-builder.service.test.ts | 12 ++++- .../book/book-query-builder.service.ts | 28 +++++++---- server/test/book-api-contract.e2e-spec.ts | 29 ++++++++++- 9 files changed, 149 insertions(+), 18 deletions(-) diff --git a/client/src/features/book/components/BookFilterBuilder.vue b/client/src/features/book/components/BookFilterBuilder.vue index b846dbba1..b197df550 100644 --- a/client/src/features/book/components/BookFilterBuilder.vue +++ b/client/src/features/book/components/BookFilterBuilder.vue @@ -43,7 +43,8 @@ const emit = defineEmits<{ }>() const MAX_DEPTH = 5 -const NUMERIC_FIELDS: RuleField[] = ['seriesIndex', 'publishedYear', 'pageCount', 'rating', 'communityRating', 'metadataScore'] +const BYTES_PER_MIB = 1024 ** 2 +const NUMERIC_FIELDS: RuleField[] = ['seriesIndex', 'publishedYear', 'pageCount', 'fileSize', 'rating', 'communityRating', 'metadataScore'] const DATE_FIELDS: RuleField[] = ['publishedDate', 'addedAt', 'startedAt', 'finishedAt'] const NO_VALUE_OPERATORS: RuleOperator[] = [ 'isEmpty', @@ -167,13 +168,15 @@ function makeEmptyRule(): LocalNode { function toEditableRule(r: Rule): EditableRule { const usesChips = Array.isArray(r.value) + const value = r.field === 'fileSize' && typeof r.value === 'number' ? String(r.value / BYTES_PER_MIB) : String(r.value ?? '') + const valueTo = r.field === 'fileSize' && typeof r.valueTo === 'number' ? String(r.valueTo / BYTES_PER_MIB) : String(r.valueTo ?? '') return { field: r.field, operator: r.operator, provider: r.field === 'communityRating' ? (r.provider ?? 'any') : 'any', - value: usesChips ? '' : String(r.value ?? ''), + value: usesChips ? '' : value, valueChips: usesChips ? (r.value as string[]) : [], - valueTo: String(r.valueTo ?? ''), + valueTo, valueUnit: 'days', } } @@ -190,7 +193,7 @@ function toLocalNodes(group: GroupRule | undefined): LocalNode[] { function parseValue(field: RuleField, operator: RuleOperator, raw: string, chips: string[], unit: WithinLastUnit): Rule['value'] { if (NO_VALUE_OPERATORS.includes(operator)) return undefined if (COLLECTION_OPERATORS.includes(operator)) return chips - if (isNumericField(field)) return raw === '' ? undefined : Number(raw) + if (isNumericField(field)) return raw === '' ? undefined : field === 'fileSize' ? Math.round(Number(raw) * BYTES_PER_MIB) : Number(raw) if (isDateField(field) && operator === 'withinLast') { if (raw === '') return undefined const n = Number(raw) @@ -248,7 +251,9 @@ function emitUpdate() { valueTo: BETWEEN_OPERATORS.includes(n.rule.operator) && n.rule.valueTo !== '' ? isNumericField(n.rule.field) - ? Number(n.rule.valueTo) + ? n.rule.field === 'fileSize' + ? Math.round(Number(n.rule.valueTo) * BYTES_PER_MIB) + : Number(n.rule.valueTo) : n.rule.valueTo : undefined, } @@ -387,6 +392,7 @@ function valueInputType(field: RuleField, operator: RuleOperator): string { } function numericInputMin(field: RuleField): string | undefined { + if (field === 'fileSize') return '0' if (field === 'communityRating') return '0' return undefined } @@ -398,6 +404,7 @@ function numericInputMax(field: RuleField): string | undefined { } function numericInputStep(field: RuleField): string | undefined { + if (field === 'fileSize') return '0.1' if (field === 'communityRating') return '0.1' return undefined } @@ -577,6 +584,38 @@ function showValueToInput(operator: RuleOperator): boolean { :endpoint="ENDPOINT_BY_FIELD[node.rule.field]!" @update:model-value="emitUpdate" /> + +