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.
This commit is contained in:
neon
2026-08-28 15:32:28 +00:00
parent f39e0e8ab5
commit 1dd39bf0eb
9 changed files with 149 additions and 18 deletions
@@ -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"
/>
<!-- Primary file size: MB input serialized as bytes -->
<template v-else-if="node.rule.field === 'fileSize' && !NO_VALUE_OPERATORS.includes(node.rule.operator)">
<div class="flex items-center gap-1.5">
<input
v-model="node.rule.value"
@input="emitUpdate"
type="number"
min="0"
step="0.1"
:aria-label="t('book.filter.fileSizeValueAria')"
:placeholder="t('book.filter.fileSizePlaceholder')"
class="h-9 w-28 rounded-md border border-input bg-background px-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<span class="text-xs text-muted-foreground">{{ t('book.filter.unit.megabytes') }}</span>
</div>
<template v-if="showValueToInput(node.rule.operator)">
<span class="text-xs text-muted-foreground">{{ t('book.filter.to') }}</span>
<div class="flex items-center gap-1.5">
<input
v-model="node.rule.valueTo"
@input="emitUpdate"
type="number"
min="0"
step="0.1"
:aria-label="t('book.filter.fileSizeMaximumAria')"
:placeholder="t('book.filter.maxPlaceholder')"
class="h-9 w-24 rounded-md border border-input bg-background px-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<span class="text-xs text-muted-foreground">{{ t('book.filter.unit.megabytes') }}</span>
</div>
</template>
</template>
<!-- Metadata score: preset range chips + numeric input -->
<template v-else-if="node.rule.field === 'metadataScore' && !NO_VALUE_OPERATORS.includes(node.rule.operator)">
<div class="flex flex-wrap items-center gap-1.5">
@@ -83,6 +83,34 @@ describe('BookFilterBuilder', () => {
expect(optionText).toContain('Date Finished')
})
it('serializes primary file size input from megabytes to bytes', async () => {
const wrapper = mount(BookFilterBuilder, {
props: {
modelValue: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'Dune' }] },
},
})
await wrapper.findAll('select')[0]!.setValue('fileSize')
await wrapper.findAll('select')[1]!.setValue('gte')
await wrapper.get('input[aria-label="Primary file size in megabytes"]').setValue('10.5')
expect(lastUpdate(wrapper)).toEqual({
type: 'group',
join: 'AND',
rules: [{ type: 'rule', field: 'fileSize', operator: 'gte', value: 11_010_048, valueTo: undefined }],
})
})
it('hydrates saved primary file size bytes as megabytes', () => {
const wrapper = mount(BookFilterBuilder, {
props: {
modelValue: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'fileSize', operator: 'lte', value: 26_214_400 }] },
},
})
expect((wrapper.get('input[aria-label="Primary file size in megabytes"]').element as HTMLInputElement).value).toBe('25')
})
it('offers date and empty/not-empty operators for Date Started', async () => {
const wrapper = mount(BookFilterBuilder, {
props: {
@@ -170,6 +170,11 @@ describe('ruleToParts', () => {
expect(ruleToParts(rule)).toEqual({ field: 'Page Count', operator: 'between', value: '100 - 200' })
})
it('formats primary file size values as readable byte sizes', () => {
const rule: Rule = { type: 'rule', field: 'fileSize', operator: 'between', value: 10_485_760, valueTo: 26_214_400 }
expect(ruleToParts(rule)).toEqual({ field: 'Primary File Size', operator: 'between', value: '10 MB - 25 MB' })
})
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: '' })
@@ -3,6 +3,7 @@ import { parseCustomRuleFieldId, parseCustomSortFieldId } from '@bookorbit/types
import { i18n } from '@/i18n'
import { activeCustomFieldLabel } from '@/features/book/composables/useActiveCustomFields'
import { PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
import { formatBytes } from '@/lib/formatting'
// Resolved through the global composer rather than useI18n() because these helpers also run
// outside a component setup (useViewSort, plain computeds). Composer.t tracks the active locale,
@@ -54,6 +55,11 @@ export function ruleToParts(rule: Rule): { field: string; operator: string; valu
const operator = operatorLabel(rule.operator)
if (NO_VALUE_OPS.includes(rule.operator)) return { field, operator, value: null }
if (rule.operator === 'withinLast') return { field, operator, value: `${rule.value} ${t('book.filter.unit.days')}` }
if (rule.field === 'fileSize') {
const value = typeof rule.value === 'number' ? formatBytes(rule.value) : ''
const valueTo = typeof rule.valueTo === 'number' ? formatBytes(rule.valueTo) : null
return { field, operator, value: valueTo === null ? value : `${value} - ${valueTo}` }
}
const val = Array.isArray(rule.value) ? (rule.value as string[]).join(', ') : String(rule.value ?? '')
return { field, operator, value: rule.valueTo !== undefined ? `${val} - ${rule.valueTo}` : val }
}
+6 -1
View File
@@ -4054,6 +4054,7 @@
"collection": "Collection",
"library": "Library",
"format": "Format",
"fileSize": "Primary File Size",
"addedAt": "Added Date",
"startedAt": "Date Started",
"finishedAt": "Date Finished",
@@ -4112,7 +4113,8 @@
"unit": {
"days": "days",
"weeks": "weeks",
"months": "months"
"months": "months",
"megabytes": "MB"
},
"scorePreset": {
"outstanding": "Outstanding",
@@ -4121,6 +4123,9 @@
"poor": "Poor"
},
"scoreRangePlaceholder": "0-100",
"fileSizePlaceholder": "e.g. 10",
"fileSizeValueAria": "Primary file size in megabytes",
"fileSizeMaximumAria": "Maximum primary file size in megabytes",
"valuePlaceholder": "value",
"maxPlaceholder": "max",
"to": "to",
+3
View File
@@ -17,6 +17,7 @@ import type { CommunityRatingProviderKey } from "./metadata-fetch";
* - `collection` - resolved via `collection_books` join to `collections.name`
* - `library` - resolved via `books.library_id` join to `libraries.name`
* - `format` - resolved via `book_files.format` (primary file)
* - `fileSize` - resolved via `book_files.size_bytes` (primary file; rule values are bytes)
* - `isbn` - matches both `isbn10` and `isbn13` in `book_metadata`
* - `publishedDate` - uses full dates when available and falls back to published year
* - `lockStatus` - derived from `book_metadata.locked_fields` (non-empty array = locked)
@@ -39,6 +40,7 @@ export type StaticRuleField =
| "collection"
| "library"
| "format"
| "fileSize"
| "addedAt"
| "startedAt"
| "finishedAt"
@@ -109,6 +111,7 @@ export const FIELD_OPERATORS: Record<StaticRuleField, RuleOperator[]> = {
collection: ["includesAny", "excludesAll", "isEmpty", "isNotEmpty"],
library: ["includesAny", "excludesAll"],
format: ["includesAny", "excludesAll"],
fileSize: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
publishedDate: ["before", "after", "between", "withinLast", "isEmpty", "isNotEmpty"],
publishedYear: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
seriesIndex: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
@@ -136,7 +136,7 @@ const USER_CTX = { accessibleLibraryIds: [1] as number[], userId: 10 };
* new operator is added to RuleOperator but not handled here.
*/
function buildValueFor(operator: RuleOperator, field: RuleField): { value?: unknown; valueTo?: unknown } {
const numericFields: RuleField[] = ['publishedYear', 'seriesIndex', 'pageCount', 'rating', 'communityRating', 'metadataScore'];
const numericFields: RuleField[] = ['publishedYear', 'seriesIndex', 'pageCount', 'fileSize', 'rating', 'communityRating', 'metadataScore'];
const dateFields: RuleField[] = ['publishedDate', 'addedAt', 'startedAt', 'finishedAt'];
const isNumericField = numericFields.includes(field);
@@ -879,6 +879,16 @@ describe('numericRuleToSql (via pageCount and publishedYear)', () => {
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'publishedYear', operator: 'notEq', value: 2001 }) as never, BASE_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'ne' });
});
it('filters file size through the selected primary file', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'fileSize', operator: 'gte', value: 10_485_760 }) as never, BASE_CTX) as any;
const clause = getRuleSql(where) as any;
expect(clause).toMatchObject({ type: 'gte', right: 10_485_760 });
expect(clause.left).toMatchObject({ type: 'sql' });
expect(collectColumnNames(clause.left)).toEqual(expect.arrayContaining(['size_bytes', 'id', 'primary_file_id']));
});
});
describe('authorRuleToSql', () => {
@@ -186,6 +186,13 @@ export class BookQueryBuilder {
return this.libraryRuleToSql(operator, value as string[]);
case 'format':
return this.formatRuleToSql(operator, value as string[]);
case 'fileSize':
return this.numericRuleToSql(
sql<number>`(SELECT ${bookFiles.sizeBytes} FROM ${bookFiles} WHERE ${bookFiles.id} = ${books.primaryFileId})`,
operator,
value as number,
valueTo as number | undefined,
);
case 'addedAt':
return this.dateRuleToSql(operator, value as string | number, valueTo as string | undefined);
case 'startedAt':
@@ -254,34 +261,35 @@ export class BookQueryBuilder {
}
}
private numericRuleToSql(col: AnyColumn, operator: string, value?: number, valueTo?: number): SQL {
private numericRuleToSql(col: AnyColumn | SQL, operator: string, value?: number, valueTo?: number): SQL {
const expression = col as SQL;
switch (operator) {
case 'eq':
this.assertNumber(value, operator, 'value');
return eq(col, value!);
return eq(expression, value!);
case 'notEq':
this.assertNumber(value, operator, 'value');
return ne(col, value!);
return ne(expression, value!);
case 'gt':
this.assertNumber(value, operator, 'value');
return gt(col, value!);
return gt(expression, value!);
case 'gte':
this.assertNumber(value, operator, 'value');
return gte(col, value!);
return gte(expression, value!);
case 'lt':
this.assertNumber(value, operator, 'value');
return lt(col, value!);
return lt(expression, value!);
case 'lte':
this.assertNumber(value, operator, 'value');
return lte(col, value!);
return lte(expression, value!);
case 'between':
this.assertNumber(value, operator, 'value');
this.assertNumber(valueTo, operator, 'valueTo');
return and(gte(col, value!), lte(col, valueTo!))!;
return and(gte(expression, value!), lte(expression, valueTo!))!;
case 'isEmpty':
return isNull(col);
return isNull(expression);
case 'isNotEmpty':
return isNotNull(col);
return isNotNull(expression);
default:
throw new BadRequestException(`Invalid operator '${operator}' for numeric field`);
}
+28 -1
View File
@@ -1,7 +1,7 @@
import { randomUUID } from 'crypto';
import { eq } from 'drizzle-orm';
import { rm } from 'fs/promises';
import { rm, stat } from 'fs/promises';
import { join } from 'path';
import * as unzipper from 'unzipper';
@@ -317,6 +317,33 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
expectError(invalidLimit, 400, 'limit must not be greater than 20');
});
it('filters books by the selected primary file size', async () => {
const primaryFileSize = (await stat(visibleEpub.absolutePath)).size;
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/books/query',
headers: authHeader(limitedUser.accessToken),
payload: {
filter: {
type: 'group',
join: 'AND',
rules: [
{ type: 'rule', field: 'title', operator: 'eq', value: 'Alpha Contract EPUB' },
{ type: 'rule', field: 'fileSize', operator: 'eq', value: primaryFileSize },
],
},
sort: [{ field: 'title', dir: 'asc' }],
pagination: { page: 0, size: 10 },
},
});
expect(response.statusCode).toBe(201);
expect(response.json()).toMatchObject({
total: 1,
items: [{ id: visibleEpub.bookId, files: [{ id: visibleEpub.bookFileId, role: 'primary', sizeBytes: primaryFileSize }] }],
});
});
});
describe('bulk query selections', () => {