mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(query): add up-next in series filter operator
Adds the isUpNext operator to seriesStatus allowing books to be filtered by whether they are the next unread book in a series where the previous book is completed. Closes #321
This commit is contained in:
@@ -32,6 +32,7 @@ const NO_VALUE_OPERATORS: RuleOperator[] = [
|
||||
'isFinished',
|
||||
'isLocked',
|
||||
'isUnlocked',
|
||||
'isUpNext',
|
||||
]
|
||||
const BETWEEN_OPERATORS: RuleOperator[] = ['between']
|
||||
const COLLECTION_OPERATORS: RuleOperator[] = ['includesAny', 'includesAll', 'excludesAll']
|
||||
|
||||
@@ -105,4 +105,33 @@ describe('BookFilterBuilder', () => {
|
||||
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('includes Series 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('Series Status')
|
||||
})
|
||||
|
||||
it('offers only is up next and no value input for Series 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('seriesStatus')
|
||||
|
||||
const operatorSelect = wrapper.findAll('select')[1]
|
||||
const operatorOptions = operatorSelect!.findAll('option').map((opt) => opt.text())
|
||||
expect(operatorOptions).toEqual(['is up next'])
|
||||
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,16 @@ describe('lock status labels', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('series status labels', () => {
|
||||
it('exposes a Series Status field label', () => {
|
||||
expect(FIELD_LABELS.seriesStatus).toBe('Series Status')
|
||||
})
|
||||
|
||||
it('exposes an is up next operator label', () => {
|
||||
expect(OPERATOR_LABELS.isUpNext).toBe('is up next')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ruleToParts', () => {
|
||||
it('renders a locked rule with no value', () => {
|
||||
const rule: Rule = { type: 'rule', field: 'lockStatus', operator: 'isLocked' }
|
||||
@@ -24,6 +34,11 @@ describe('ruleToParts', () => {
|
||||
expect(ruleToParts(rule)).toEqual({ field: 'Lock Status', operator: 'is unlocked', value: null })
|
||||
})
|
||||
|
||||
it('renders an up-next rule with no value', () => {
|
||||
const rule: Rule = { type: 'rule', field: 'seriesStatus', operator: 'isUpNext' }
|
||||
expect(ruleToParts(rule)).toEqual({ field: 'Series Status', operator: 'is up next', 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' })
|
||||
|
||||
@@ -49,6 +49,7 @@ export const FIELD_LABELS: Record<RuleField, string> = {
|
||||
metadataScore: 'Metadata Score',
|
||||
cover: 'Cover',
|
||||
lockStatus: 'Lock Status',
|
||||
seriesStatus: 'Series Status',
|
||||
}
|
||||
|
||||
export const OPERATOR_LABELS: Record<RuleOperator, string> = {
|
||||
@@ -78,6 +79,7 @@ export const OPERATOR_LABELS: Record<RuleOperator, string> = {
|
||||
isFinished: 'is finished',
|
||||
isLocked: 'is locked',
|
||||
isUnlocked: 'is unlocked',
|
||||
isUpNext: 'is up next',
|
||||
}
|
||||
|
||||
const NO_VALUE_OPS: RuleOperator[] = [
|
||||
@@ -90,6 +92,7 @@ const NO_VALUE_OPS: RuleOperator[] = [
|
||||
'isFinished',
|
||||
'isLocked',
|
||||
'isUnlocked',
|
||||
'isUpNext',
|
||||
]
|
||||
|
||||
export function ruleToParts(rule: Rule): { field: string; operator: string; value: string | null } {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* - `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)
|
||||
* - `seriesStatus` - computed per-user: "up next in series" (next unstarted book whose earlier series entries are all finished)
|
||||
*/
|
||||
export type RuleField =
|
||||
| "title"
|
||||
@@ -41,7 +42,8 @@ export type RuleField =
|
||||
| "isbn"
|
||||
| "metadataScore"
|
||||
| "cover"
|
||||
| "lockStatus";
|
||||
| "lockStatus"
|
||||
| "seriesStatus";
|
||||
|
||||
export type RuleOperator =
|
||||
| "contains"
|
||||
@@ -69,7 +71,8 @@ export type RuleOperator =
|
||||
| "isInProgress"
|
||||
| "isFinished"
|
||||
| "isLocked"
|
||||
| "isUnlocked";
|
||||
| "isUnlocked"
|
||||
| "isUpNext";
|
||||
|
||||
export const FIELD_OPERATORS: Record<RuleField, RuleOperator[]> = {
|
||||
title: ["contains", "notContains", "startsWith", "endsWith", "eq", "notEq", "isEmpty", "isNotEmpty"],
|
||||
@@ -97,6 +100,7 @@ export const FIELD_OPERATORS: Record<RuleField, RuleOperator[]> = {
|
||||
metadataScore: ["gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
|
||||
cover: ["isMissing", "isPresent"],
|
||||
lockStatus: ["isLocked", "isUnlocked"],
|
||||
seriesStatus: ["isUpNext"],
|
||||
};
|
||||
|
||||
export const RULE_FIELDS = Object.keys(FIELD_OPERATORS) as RuleField[];
|
||||
@@ -128,6 +132,7 @@ export const RULE_OPERATORS: RuleOperator[] = [
|
||||
"isFinished",
|
||||
"isLocked",
|
||||
"isUnlocked",
|
||||
"isUpNext",
|
||||
];
|
||||
|
||||
export type Rule = {
|
||||
|
||||
@@ -63,6 +63,24 @@ function getRuleSql(where: Record<string, unknown>): Record<string, unknown> {
|
||||
return outer.clauses[1].clauses[0] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively gathers the static SQL text fragments from a (possibly deeply nested)
|
||||
* mocked clause tree so tests can assert on keywords composed across nested sql`` templates.
|
||||
*/
|
||||
function collectSqlText(node: unknown, acc: string[] = []): string[] {
|
||||
if (!node || typeof node !== 'object') return acc;
|
||||
const n = node as Record<string, unknown>;
|
||||
if (n.type === 'sql' && typeof n.text === 'string') acc.push(n.text);
|
||||
for (const key of ['values', 'clauses'] as const) {
|
||||
const arr = n[key];
|
||||
if (Array.isArray(arr)) arr.forEach((child) => collectSqlText(child, acc));
|
||||
}
|
||||
for (const key of ['value', 'left', 'right'] as const) {
|
||||
if (n[key]) collectSqlText(n[key], acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
const BASE_CTX = { accessibleLibraryIds: [1] as number[] };
|
||||
const USER_CTX = { accessibleLibraryIds: [1] as number[], userId: 10 };
|
||||
|
||||
@@ -88,6 +106,7 @@ function buildValueFor(operator: RuleOperator, field: RuleField): { value?: unkn
|
||||
case 'isFinished':
|
||||
case 'isLocked':
|
||||
case 'isUnlocked':
|
||||
case 'isUpNext':
|
||||
return {};
|
||||
case 'contains':
|
||||
case 'notContains':
|
||||
@@ -1169,6 +1188,38 @@ describe('lockStatusRuleToSql', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('seriesStatusRuleToSql', () => {
|
||||
it('isUpNext builds set membership against the shelf window-function pipeline', () => {
|
||||
const { builder } = makeBuilder();
|
||||
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'seriesStatus', operator: 'isUpNext' }) as never, USER_CTX) as any;
|
||||
const clause = getRuleSql(where);
|
||||
expect(clause).toMatchObject({ type: 'sql' });
|
||||
|
||||
const text = collectSqlText(clause).join(' ').toLowerCase();
|
||||
expect(text).toContain(' in (');
|
||||
expect(text).toContain('distinct on');
|
||||
expect(text).toContain('lag(');
|
||||
expect(text).toContain('partition by');
|
||||
expect(text).toContain('previous_is_completed');
|
||||
expect(text).toContain("'read'");
|
||||
expect(text).toContain("'skimmed'");
|
||||
});
|
||||
|
||||
it('requires an authenticated user', () => {
|
||||
const { builder } = makeBuilder();
|
||||
expect(() => builder.buildWhere(wrapRule({ type: 'rule', field: 'seriesStatus', operator: 'isUpNext' }) as never, BASE_CTX)).toThrow(
|
||||
'Series status filter requires an authenticated user',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an invalid operator for seriesStatus', () => {
|
||||
const { builder } = makeBuilder();
|
||||
expect(() => builder.buildWhere(wrapRule({ type: 'rule', field: 'seriesStatus', operator: 'isFinished' }) as never, USER_CTX)).toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BookQueryBuilder.hasSeriesFilter', () => {
|
||||
it('returns false for undefined', () => {
|
||||
expect(BookQueryBuilder.hasSeriesFilter(undefined)).toBe(false);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { buildContentFilterClauses } from '../../common/utils/content-filter-sql
|
||||
import * as schema from '../../db/schema';
|
||||
import { BookSortBuilder } from './book-sort-builder.service';
|
||||
import {
|
||||
audiobookProgress,
|
||||
authors,
|
||||
bookAuthors,
|
||||
bookFiles,
|
||||
@@ -169,6 +170,8 @@ export class BookQueryBuilder {
|
||||
return this.coverRuleToSql(operator);
|
||||
case 'lockStatus':
|
||||
return this.lockStatusRuleToSql(operator);
|
||||
case 'seriesStatus':
|
||||
return this.seriesStatusRuleToSql(operator, userId);
|
||||
default:
|
||||
throw new BadRequestException(`Unknown filter field: ${String(field)}`);
|
||||
}
|
||||
@@ -502,6 +505,77 @@ export class BookQueryBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
private seriesStatusRuleToSql(operator: string, userId?: number): SQL {
|
||||
if (userId === undefined) throw new BadRequestException('Series status filter requires an authenticated user');
|
||||
switch (operator) {
|
||||
case 'isUpNext':
|
||||
return this.upNextInSeriesSql(userId);
|
||||
default:
|
||||
throw new BadRequestException(`Invalid operator '${operator}' for seriesStatus field`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set membership against the same window-function pipeline as the "Up Next in Series" shelf
|
||||
* (DashboardRepository.findUpNextInSeriesBookIds): per (library, series), the next unstarted book
|
||||
* whose immediately preceding entry is finished. Implemented as a subquery (not a per-row predicate)
|
||||
* so results match the shelf exactly. Keep the merged-progress / completion definitions in sync with
|
||||
* that repository. Library scoping is omitted here because the window partitions by library already;
|
||||
* the surrounding query restricts to the user's accessible libraries.
|
||||
*/
|
||||
private upNextInSeriesSql(userId: number): SQL {
|
||||
const mergedProgress = sql`coalesce(
|
||||
case
|
||||
when rp.updated_at is null then ab.percentage
|
||||
when ab.updated_at is null then rp.percentage
|
||||
when rp.updated_at >= ab.updated_at then rp.percentage
|
||||
else ab.percentage
|
||||
end,
|
||||
rp.percentage,
|
||||
ab.percentage,
|
||||
0
|
||||
)`;
|
||||
const isCompleted = sql`(ubs.status in ('read', 'skimmed') or ${mergedProgress} >= 100)`;
|
||||
return sql`${books.id} in (
|
||||
with scoped as (
|
||||
select
|
||||
b.id as id,
|
||||
b.library_id as library_id,
|
||||
m.series_id as series_id,
|
||||
m.series_index as series_index,
|
||||
b.added_at as added_at,
|
||||
${mergedProgress} as current_progress,
|
||||
case when ${isCompleted} then true else false end as is_completed
|
||||
from ${books} b
|
||||
inner join ${bookMetadata} m on m.book_id = b.id
|
||||
left join ${bookFiles} bf on bf.id = b.primary_file_id
|
||||
left join ${readingProgress} rp on rp.book_file_id = bf.id and rp.user_id = ${userId}
|
||||
left join ${audiobookProgress} ab on ab.book_id = b.id and ab.user_id = ${userId}
|
||||
left join ${userBookStatus} ubs on ubs.book_id = b.id and ubs.user_id = ${userId}
|
||||
where b.status = 'present' and m.series_id is not null and m.series_index is not null
|
||||
),
|
||||
ordered as (
|
||||
select
|
||||
s.id,
|
||||
s.library_id,
|
||||
s.series_id,
|
||||
s.series_index,
|
||||
s.added_at,
|
||||
s.is_completed,
|
||||
s.current_progress,
|
||||
lag(s.is_completed) over (
|
||||
partition by s.library_id, s.series_id
|
||||
order by s.series_index asc, s.added_at asc, s.id asc
|
||||
) as previous_is_completed
|
||||
from scoped s
|
||||
)
|
||||
select distinct on (o.library_id, o.series_id) o.id
|
||||
from ordered o
|
||||
where o.previous_is_completed = true and o.is_completed = false and o.current_progress = 0
|
||||
order by o.library_id, o.series_id, o.series_index asc, o.added_at asc, o.id asc
|
||||
)`;
|
||||
}
|
||||
|
||||
private readProgressRuleToSql(operator: string, userId?: number): SQL {
|
||||
if (userId === undefined) throw new BadRequestException('Reading progress filter requires an authenticated user');
|
||||
const existsReadingProgress = (whereClause: SQL) => {
|
||||
|
||||
Reference in New Issue
Block a user