feat(books): add status, rating, progress, and ISBN filter fields

This commit is contained in:
neon
2026-02-15 01:31:54 -06:00
parent 5b84fc9e19
commit 6be97680a6
7 changed files with 118 additions and 11 deletions
@@ -14,9 +14,9 @@ const emit = defineEmits<{
}>()
const MAX_DEPTH = 2
const NUMERIC_FIELDS: RuleField[] = ['seriesIndex', 'publishedYear', 'pageCount']
const NUMERIC_FIELDS: RuleField[] = ['seriesIndex', 'publishedYear', 'pageCount', 'rating']
const DATE_FIELDS: RuleField[] = ['addedAt']
const NO_VALUE_OPERATORS: RuleOperator[] = ['isEmpty', 'isNotEmpty']
const NO_VALUE_OPERATORS: RuleOperator[] = ['isEmpty', 'isNotEmpty', 'isMissing', 'isPresent', 'isUnread', 'isInProgress', 'isFinished']
const BETWEEN_OPERATORS: RuleOperator[] = ['between']
const COLLECTION_OPERATORS: RuleOperator[] = ['includesAny', 'includesAll', 'excludesAll']
@@ -12,6 +12,11 @@ export const FIELD_LABELS: Record<RuleField, string> = {
tag: 'Tag',
format: 'Format',
addedAt: 'Added Date',
fileAvailability: 'File Availability',
rating: 'Rating',
readProgress: 'Reading Progress',
description: 'Description',
isbn: 'ISBN',
}
export const OPERATOR_LABELS: Record<RuleOperator, string> = {
@@ -34,6 +39,11 @@ export const OPERATOR_LABELS: Record<RuleOperator, string> = {
before: 'before',
after: 'after',
withinLast: 'within last (days)',
isMissing: 'is missing',
isPresent: 'is present',
isUnread: 'is unread',
isInProgress: 'is in progress',
isFinished: 'is finished',
}
const NO_VALUE_OPS: RuleOperator[] = ['isEmpty', 'isNotEmpty']
+20
View File
@@ -10,6 +10,11 @@ export type RuleField =
| 'tag'
| 'format'
| 'addedAt'
| 'fileAvailability'
| 'rating'
| 'readProgress'
| 'description'
| 'isbn'
export type RuleOperator =
| 'contains'
@@ -31,6 +36,11 @@ export type RuleOperator =
| 'before'
| 'after'
| 'withinLast'
| 'isMissing'
| 'isPresent'
| 'isUnread'
| 'isInProgress'
| 'isFinished'
export const FIELD_OPERATORS: Record<RuleField, RuleOperator[]> = {
title: ['contains', 'notContains', 'startsWith', 'endsWith', 'eq', 'notEq', 'isEmpty', 'isNotEmpty'],
@@ -44,6 +54,11 @@ export const FIELD_OPERATORS: Record<RuleField, RuleOperator[]> = {
seriesIndex: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
pageCount: ['gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
addedAt: ['before', 'after', 'between', 'withinLast'],
fileAvailability: ['isMissing', 'isPresent'],
rating: ['eq', 'gt', 'gte', 'lt', 'lte', 'isEmpty', 'isNotEmpty'],
readProgress: ['isUnread', 'isInProgress', 'isFinished'],
description: ['isEmpty', 'isNotEmpty'],
isbn: ['isEmpty', 'isNotEmpty', 'eq'],
}
export const RULE_FIELDS = Object.keys(FIELD_OPERATORS) as RuleField[]
@@ -68,6 +83,11 @@ export const RULE_OPERATORS: RuleOperator[] = [
'before',
'after',
'withinLast',
'isMissing',
'isPresent',
'isUnread',
'isInProgress',
'isFinished',
]
export type Rule = {
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1771111792526,
"when": 1771187072614,
"tag": "0000_add_kobo_sync",
"breakpoints": true
}
@@ -5,7 +5,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { GroupRule, Rule, SortField, SortSpec } from '@projectx/types';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { authors, bookAuthors, bookFiles, bookMetadata, books, bookTags, tags } from '../../db/schema';
import { authors, bookAuthors, bookFiles, bookMetadata, books, bookTags, readingProgress, tags } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
@@ -21,7 +21,10 @@ const SORT_FIELD_MAP: Record<SortField, AnyColumn> = {
export class BookQueryBuilder {
constructor(@Inject(DB) private readonly db: Db) {}
buildWhere(filter: GroupRule | null | undefined, ctx: { accessibleLibraryIds: number[]; implicitLibraryId?: number }): SQL | undefined {
buildWhere(
filter: GroupRule | null | undefined,
ctx: { accessibleLibraryIds: number[]; implicitLibraryId?: number; userId?: number },
): SQL | undefined {
if (ctx.accessibleLibraryIds.length === 0) {
return sql`1 = 0`;
}
@@ -33,7 +36,7 @@ export class BookQueryBuilder {
}
if (filter) {
clauses.push(this.groupToSql(filter, 0));
clauses.push(this.groupToSql(filter, 0, ctx.userId));
}
return and(...clauses);
@@ -52,13 +55,13 @@ export class BookQueryBuilder {
return result;
}
private groupToSql(node: GroupRule, depth: number): SQL {
private groupToSql(node: GroupRule, depth: number, userId?: number): SQL {
if (depth > 5) throw new BadRequestException('Filter nesting exceeds maximum depth of 5');
const clauses = node.rules.map((r) => (r.type === 'group' ? this.groupToSql(r, depth + 1) : this.ruleToSql(r)));
const clauses = node.rules.map((r) => (r.type === 'group' ? this.groupToSql(r, depth + 1, userId) : this.ruleToSql(r, userId)));
return node.join === 'AND' ? and(...clauses)! : or(...clauses)!;
}
private ruleToSql(rule: Rule): SQL {
private ruleToSql(rule: Rule, userId?: number): SQL {
const { field, operator, value, valueTo } = rule;
switch (field) {
case 'title':
@@ -75,6 +78,8 @@ export class BookQueryBuilder {
return this.numericRuleToSql(bookMetadata.seriesIndex, operator, value as number, valueTo as number | undefined);
case 'pageCount':
return this.numericRuleToSql(bookMetadata.pageCount, operator, value as number, valueTo as number | undefined);
case 'rating':
return this.numericRuleToSql(bookMetadata.rating, operator, value as number, valueTo as number | undefined);
case 'author':
return this.authorRuleToSql(operator, value as string[]);
case 'tag':
@@ -83,6 +88,14 @@ export class BookQueryBuilder {
return this.formatRuleToSql(operator, value as string[]);
case 'addedAt':
return this.dateRuleToSql(operator, value as string | number, valueTo as string | undefined);
case 'fileAvailability':
return this.statusRuleToSql(operator);
case 'readProgress':
return this.readProgressRuleToSql(operator, userId);
case 'description':
return this.textRuleToSql(bookMetadata.description, operator, value as string);
case 'isbn':
return this.isbnRuleToSql(operator, value as string | undefined);
default:
throw new BadRequestException(`Unknown filter field: ${String(field)}`);
}
@@ -215,4 +228,53 @@ export class BookQueryBuilder {
throw new BadRequestException(`Invalid operator '${operator}' for date field`);
}
}
private statusRuleToSql(operator: string): SQL {
switch (operator) {
case 'isMissing':
return eq(books.status, 'missing');
case 'isPresent':
return eq(books.status, 'present');
default:
throw new BadRequestException(`Invalid operator '${operator}' for status field`);
}
}
private readProgressRuleToSql(operator: string, userId?: number): SQL {
if (userId === undefined) throw new BadRequestException('Reading progress filter requires an authenticated user');
const sq = (whereClause: SQL) =>
this.db
.select({ bookId: bookFiles.bookId })
.from(readingProgress)
.innerJoin(bookFiles, eq(readingProgress.bookFileId, bookFiles.id))
.where(whereClause);
switch (operator) {
case 'isUnread':
return not(inArray(books.id, sq(and(eq(readingProgress.userId, userId), gt(readingProgress.percentage, 0))!)));
case 'isInProgress':
return inArray(
books.id,
sq(and(eq(readingProgress.userId, userId), gt(readingProgress.percentage, 0), lt(readingProgress.percentage, 100))!),
);
case 'isFinished':
return inArray(books.id, sq(and(eq(readingProgress.userId, userId), gte(readingProgress.percentage, 100))!));
default:
throw new BadRequestException(`Invalid operator '${operator}' for readProgress field`);
}
}
private isbnRuleToSql(operator: string, value?: string): SQL {
switch (operator) {
case 'isEmpty':
return and(isNull(bookMetadata.isbn13), isNull(bookMetadata.isbn10))!;
case 'isNotEmpty':
return or(isNotNull(bookMetadata.isbn13), isNotNull(bookMetadata.isbn10))!;
case 'eq':
if (!value) throw new BadRequestException("Operator 'eq' requires a non-empty value");
return or(eq(bookMetadata.isbn13, value), eq(bookMetadata.isbn10, value))!;
default:
throw new BadRequestException(`Invalid operator '${operator}' for isbn field`);
}
}
}
+2 -2
View File
@@ -111,7 +111,7 @@ export class BookService {
async queryForLibrary(user: RequestUser, libraryId: number, query: BookQuery): Promise<BooksPage> {
await this.libraryService.verifyUserAccess(user.id, libraryId, this.isSuperuser(user));
const where = this.queryBuilder.buildWhere(query.filter, { accessibleLibraryIds: [libraryId], implicitLibraryId: libraryId });
const where = this.queryBuilder.buildWhere(query.filter, { accessibleLibraryIds: [libraryId], implicitLibraryId: libraryId, userId: user.id });
const orderBy = this.queryBuilder.buildOrderBy(query.sort);
const { rows, authorRows, fileRows, tagRows, progressRows, total } = await this.bookRepo.findCards({
where,
@@ -131,7 +131,7 @@ export class BookService {
async globalQuery(user: RequestUser, query: BookQuery): Promise<BooksPage> {
const libs = await this.libraryService.findAll(user);
const accessibleLibraryIds = libs.map((l) => l.id);
const where = this.queryBuilder.buildWhere(query.filter, { accessibleLibraryIds });
const where = this.queryBuilder.buildWhere(query.filter, { accessibleLibraryIds, userId: user.id });
const orderBy = this.queryBuilder.buildOrderBy(query.sort);
const { rows, authorRows, fileRows, tagRows, progressRows, total } = await this.bookRepo.findCards({
where,
@@ -17,6 +17,11 @@ const RULE_FIELDS = [
'tag',
'format',
'addedAt',
'fileAvailability',
'rating',
'readProgress',
'description',
'isbn',
] as const;
const RULE_OPERATORS = [
'contains',
@@ -38,6 +43,11 @@ const RULE_OPERATORS = [
'before',
'after',
'withinLast',
'isMissing',
'isPresent',
'isUnread',
'isInProgress',
'isFinished',
] as const;
const VALID_OPERATORS_BY_FIELD: Record<string, string[]> = {
@@ -52,6 +62,11 @@ const VALID_OPERATORS_BY_FIELD: Record<string, string[]> = {
seriesIndex: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
pageCount: ['gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
addedAt: ['before', 'after', 'between', 'withinLast'],
fileAvailability: ['isMissing', 'isPresent'],
rating: ['eq', 'gt', 'gte', 'lt', 'lte', 'isEmpty', 'isNotEmpty'],
readProgress: ['isUnread', 'isInProgress', 'isFinished'],
description: ['isEmpty', 'isNotEmpty'],
isbn: ['isEmpty', 'isNotEmpty', 'eq'],
};
const ruleSchema: z.ZodType<Rule> = z