mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(books): add status, rating, progress, and ISBN filter fields
This commit is contained in:
@@ -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']
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user