mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(kobo): sync smart scopes to kobo (#570)
Allow smart scopes to opt into Kobo sync while preserving collection sync behavior. Generate the current Drizzle migration and keep smart-scope matching aligned with the normal book query path. Fixes #202 Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com> Co-authored-by: Jane Doe <janedoe@users.noreply.github.com>
This commit is contained in:
@@ -14,6 +14,7 @@ const { createSmartScope } = useSmartScopes()
|
||||
const name = ref('')
|
||||
const icon = ref('')
|
||||
const isPublic = ref(false)
|
||||
const syncToKobo = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const trimmedName = computed(() => name.value.trim())
|
||||
@@ -36,10 +37,12 @@ async function submit() {
|
||||
icon: trimmedIcon.value,
|
||||
defaultSort: [],
|
||||
isPublic: isPublic.value,
|
||||
syncToKobo: syncToKobo.value,
|
||||
})
|
||||
name.value = ''
|
||||
icon.value = ''
|
||||
isPublic.value = false
|
||||
syncToKobo.value = false
|
||||
emit('close')
|
||||
router.push({ name: 'smartScope', params: { id: smartScope.id } })
|
||||
} catch {
|
||||
@@ -84,6 +87,11 @@ async function submit() {
|
||||
<span class="text-sm text-foreground">Visible to all users</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2.5 cursor-pointer select-none">
|
||||
<input type="checkbox" v-model="syncToKobo" class="h-4 w-4 rounded border border-input accent-primary" />
|
||||
<span class="text-sm text-foreground">Sync to Kobo</span>
|
||||
</label>
|
||||
|
||||
<p v-if="error" class="text-sm text-destructive">{{ error }}</p>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-1">
|
||||
|
||||
@@ -47,6 +47,7 @@ const draftName = ref('')
|
||||
const draftIcon = ref('')
|
||||
const draftFilter = ref<GroupRule | undefined>(undefined)
|
||||
const draftSort = ref<SortSpec[]>([])
|
||||
const draftSyncToKobo = ref(false)
|
||||
const saving = ref(false)
|
||||
const saveError = ref<string | null>(null)
|
||||
const trimmedDraftName = computed(() => draftName.value.trim())
|
||||
@@ -64,6 +65,7 @@ watch(
|
||||
draftIcon.value = props.smartScope.icon ?? ''
|
||||
draftFilter.value = props.smartScope.filter ?? undefined
|
||||
draftSort.value = props.smartScope.defaultSort ? [...props.smartScope.defaultSort] : []
|
||||
draftSyncToKobo.value = props.smartScope.syncToKobo
|
||||
schedulePreview()
|
||||
}
|
||||
},
|
||||
@@ -127,6 +129,7 @@ async function save() {
|
||||
icon: trimmedDraftIcon.value,
|
||||
filter: hasCompleteRules(draftFilter.value) ? draftFilter.value : undefined,
|
||||
defaultSort: draftSort.value,
|
||||
syncToKobo: draftSyncToKobo.value,
|
||||
})
|
||||
emit('saved')
|
||||
emit('close')
|
||||
@@ -192,6 +195,26 @@ async function save() {
|
||||
<IconPicker v-model="draftIcon" placeholder="Choose an icon..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Sync to Kobo</p>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">Books matching this scope will appear on your Kobo device</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="draftSyncToKobo"
|
||||
class="w-11 h-6 rounded-full transition-colors relative shrink-0"
|
||||
:class="draftSyncToKobo ? 'bg-primary' : 'bg-muted'"
|
||||
@click="draftSyncToKobo = !draftSyncToKobo"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1 w-4 h-4 rounded-full bg-white transition-transform shadow-sm"
|
||||
:class="draftSyncToKobo ? 'translate-x-6' : 'translate-x-1'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters card -->
|
||||
|
||||
@@ -16,6 +16,7 @@ function makeSmartScope(overrides: Partial<SmartScope> = {}): SmartScope {
|
||||
filter: null,
|
||||
defaultSort: [],
|
||||
isPublic: false,
|
||||
syncToKobo: false,
|
||||
displayOrder: 0,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface SmartScope {
|
||||
filter: GroupRule | null;
|
||||
defaultSort: SortSpec[];
|
||||
isPublic: boolean;
|
||||
syncToKobo: boolean;
|
||||
displayOrder: number;
|
||||
bookCount?: number;
|
||||
createdAt: string;
|
||||
@@ -20,4 +21,5 @@ export interface CreateSmartScopePayload {
|
||||
filter?: GroupRule;
|
||||
defaultSort: SortSpec[];
|
||||
isPublic?: boolean;
|
||||
syncToKobo?: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "smart_scopes" ADD COLUMN "sync_to_kobo" boolean DEFAULT false NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -274,6 +274,13 @@
|
||||
"when": 1783397661979,
|
||||
"tag": "0038_user_book_notes_and_rating_tombstones",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 39,
|
||||
"version": "7",
|
||||
"when": 1783458659132,
|
||||
"tag": "0039_add_sync_to_kobo_smart_scopes",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export const smartScopes = pgTable(
|
||||
filter: jsonb('filter').$type<GroupRule | null>(),
|
||||
defaultSort: jsonb('default_sort').$type<SortSpec[]>().notNull().default([]),
|
||||
isPublic: boolean('is_public').notNull().default(false),
|
||||
syncToKobo: boolean('sync_to_kobo').notNull().default(false),
|
||||
displayOrder: integer('display_order').notNull().default(0),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
|
||||
@@ -37,6 +37,10 @@ function readHeaderValue(value: string | string[] | undefined): string | undefin
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function isBookOrbitTag(tagId: string): boolean {
|
||||
return tagId.startsWith('col-') || tagId.startsWith('ss-');
|
||||
}
|
||||
|
||||
function buildBaseUrl(req: FastifyRequest): string {
|
||||
const fwdHost = readHeaderValue(req.headers['x-forwarded-host']);
|
||||
const fwdPort = readHeaderValue(req.headers['x-forwarded-port']);
|
||||
@@ -154,21 +158,21 @@ export class KoboSyncController {
|
||||
@Req() req: FastifyRequest,
|
||||
@Res() reply: FastifyReply,
|
||||
) {
|
||||
if (!tagId.startsWith('col-')) return this.proxyService.forward(req, reply, device.deviceToken);
|
||||
if (!isBookOrbitTag(tagId)) return this.proxyService.forward(req, reply, device.deviceToken);
|
||||
reply.status(HttpStatus.OK).send({ RequestResult: 'Success' });
|
||||
}
|
||||
|
||||
@Post('v1/library/tags/:tagId/items')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async addTagItems(@Param('tagId') tagId: string, @KoboDevice() device: KoboDeviceContext, @Req() req: FastifyRequest, @Res() reply: FastifyReply) {
|
||||
if (!tagId.startsWith('col-')) return this.proxyService.forward(req, reply, device.deviceToken);
|
||||
if (!isBookOrbitTag(tagId)) return this.proxyService.forward(req, reply, device.deviceToken);
|
||||
reply.status(HttpStatus.OK).send({ RequestResult: 'Success' });
|
||||
}
|
||||
|
||||
@Delete('v1/library/tags/:tagId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async deleteTag(@Param('tagId') tagId: string, @KoboDevice() device: KoboDeviceContext, @Req() req: FastifyRequest, @Res() reply: FastifyReply) {
|
||||
if (!tagId.startsWith('col-')) return this.proxyService.forward(req, reply, device.deviceToken);
|
||||
if (!isBookOrbitTag(tagId)) return this.proxyService.forward(req, reply, device.deviceToken);
|
||||
reply.status(HttpStatus.OK).send({ RequestResult: 'Success' });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import * as schema from '../../../db/schema';
|
||||
import { KoboSyncService } from './kobo-sync.service';
|
||||
|
||||
// Recursively counts bound query params (drizzle-orm's `Param` chunks) in a built SQL tree,
|
||||
// so tests can assert a clause isn't binding one parameter per matched book id.
|
||||
function countSqlParams(node: unknown, seen = new Set<unknown>()): number {
|
||||
if (!node || typeof node !== 'object' || seen.has(node)) return 0;
|
||||
seen.add(node);
|
||||
if ((node as { constructor?: { name?: string } }).constructor?.name === 'Param') return 1;
|
||||
const children = Array.isArray(node) ? node : ((node as { queryChunks?: unknown[] }).queryChunks ?? []);
|
||||
return children.reduce((sum: number, child) => sum + countSqlParams(child, seen), 0);
|
||||
}
|
||||
|
||||
type QueueState = {
|
||||
select: unknown[];
|
||||
insert: unknown[];
|
||||
@@ -59,15 +71,23 @@ function makeDb(state?: Partial<QueueState>) {
|
||||
delete: [...(state?.delete ?? [])],
|
||||
execute: [...(state?.execute ?? [])],
|
||||
};
|
||||
const chains: ReturnType<typeof makeChain>[] = [];
|
||||
|
||||
return {
|
||||
__chains: chains,
|
||||
query: {
|
||||
users: { findFirst: vi.fn().mockResolvedValue({ settings: {} }) },
|
||||
koboLibrarySnapshots: { findFirst: vi.fn() },
|
||||
koboSnapshotBooks: { findFirst: vi.fn() },
|
||||
koboSyncSettings: { findFirst: vi.fn() },
|
||||
collections: { findMany: vi.fn() },
|
||||
smartScopes: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
select: vi.fn(() => makeChain(queue.select.shift() ?? [])),
|
||||
select: vi.fn(() => {
|
||||
const chain = makeChain(queue.select.shift() ?? []);
|
||||
chains.push(chain);
|
||||
return chain;
|
||||
}),
|
||||
insert: vi.fn(() => makeChain(queue.insert.shift() ?? [])),
|
||||
update: vi.fn(() => makeChain(queue.update.shift() ?? [])),
|
||||
delete: vi.fn(() => makeChain(queue.delete.shift() ?? [])),
|
||||
@@ -122,6 +142,9 @@ describe('KoboSyncService', () => {
|
||||
markLegacyNumericRemovalComplete: vi.fn(),
|
||||
buildVersionedCoverImageId: vi.fn(),
|
||||
};
|
||||
const queryBuilder = {
|
||||
buildWhere: vi.fn(),
|
||||
};
|
||||
|
||||
function makeIdentity(bookId: number, needsLegacyNumericRemoval = false) {
|
||||
return {
|
||||
@@ -143,6 +166,7 @@ describe('KoboSyncService', () => {
|
||||
readingStateService as never,
|
||||
contentFilterRepository as never,
|
||||
bookIdentityService as never,
|
||||
queryBuilder as never,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -203,7 +227,7 @@ describe('KoboSyncService', () => {
|
||||
|
||||
const [metadata] = (await service.getBookMetadata(3, 12, 'tok', 'https://base')) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(3, [12], true);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(3, [12], true, expect.any(Map));
|
||||
expect(metadata.Title).toBe('Book 12');
|
||||
expect(metadata.DownloadUrls).toEqual([
|
||||
{
|
||||
@@ -267,7 +291,7 @@ describe('KoboSyncService', () => {
|
||||
db.query.koboLibrarySnapshots.findFirst.mockResolvedValue(null);
|
||||
const service = makeService(db);
|
||||
|
||||
await expect((service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set())).resolves.toEqual({
|
||||
await expect((service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set(), new Map())).resolves.toEqual({
|
||||
entitlements: [],
|
||||
hasMore: false,
|
||||
syncToken: expect.stringMatching(/^PX\./),
|
||||
@@ -280,7 +304,7 @@ describe('KoboSyncService', () => {
|
||||
const service = makeService(db);
|
||||
vi.spyOn(service as any, 'buildTagItems').mockResolvedValue([{ ChangedTag: {} }]);
|
||||
|
||||
const result = await (service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set([1]));
|
||||
const result = await (service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set([1]), new Map());
|
||||
|
||||
expect(result).toEqual({
|
||||
entitlements: [{ ChangedTag: {} }],
|
||||
@@ -308,7 +332,7 @@ describe('KoboSyncService', () => {
|
||||
);
|
||||
readingStateService.getRawState.mockResolvedValue(null);
|
||||
|
||||
const result = await (service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set([1, 2, 3]));
|
||||
const result = await (service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set([1, 2, 3]), new Map());
|
||||
|
||||
expect(result.hasMore).toBe(false);
|
||||
expect(result.entitlements).toHaveLength(3);
|
||||
@@ -328,7 +352,7 @@ describe('KoboSyncService', () => {
|
||||
vi.spyOn(service as any, 'fetchEligibleBooksByIds').mockResolvedValue(new Map([[3, makeBook(3)]]));
|
||||
readingStateService.getRawState.mockResolvedValue({ EntitlementId: 'entitlement-3', CurrentBookmark: { ProgressPercent: 61 } });
|
||||
|
||||
const result = await (service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set([3]));
|
||||
const result = await (service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set([3]), new Map());
|
||||
|
||||
expect(result.entitlements).toHaveLength(2);
|
||||
expect(result.entitlements[0]).toHaveProperty('ChangedProductMetadata');
|
||||
@@ -349,7 +373,7 @@ describe('KoboSyncService', () => {
|
||||
bookIdentityService.findByBookIds.mockResolvedValue(new Map([[3, makeIdentity(3, true)]]));
|
||||
readingStateService.getRawState.mockResolvedValue({ EntitlementId: 'entitlement-3', CurrentBookmark: { ProgressPercent: 61 } });
|
||||
|
||||
const result = await (service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set([3]));
|
||||
const result = await (service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set([3]), new Map());
|
||||
|
||||
expect(result.entitlements).toHaveLength(2);
|
||||
expect(result.entitlements[0]).toHaveProperty('ChangedEntitlement');
|
||||
@@ -377,7 +401,7 @@ describe('KoboSyncService', () => {
|
||||
]);
|
||||
const service = makeService(db);
|
||||
|
||||
const tags = await (service as any).buildTagItems(3, new Set([10, 22]));
|
||||
const tags = await (service as any).buildTagItems(3, new Set([10, 22]), new Map());
|
||||
|
||||
expect(tags).toHaveLength(2);
|
||||
expect(tags[0]).toEqual(
|
||||
@@ -392,6 +416,127 @@ describe('KoboSyncService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('buildTagItems includes a tag per synced smart scope with matching, eligible books', async () => {
|
||||
const db = makeDb({
|
||||
select: [[{ id: 10 }, { id: 30 }]],
|
||||
});
|
||||
db.query.collections.findMany.mockResolvedValue([]);
|
||||
const filter = { type: 'group', join: 'AND', rules: [] };
|
||||
db.query.smartScopes.findMany.mockResolvedValue([{ id: 5, name: 'To Read', filter, syncToKobo: true }]);
|
||||
bookAccessService.getAccessibleLibraryIds.mockResolvedValue([1, 2]);
|
||||
queryBuilder.buildWhere.mockReturnValue('WHERE_CLAUSE');
|
||||
const service = makeService(db);
|
||||
|
||||
const tags = await (service as any).buildTagItems(9, new Set([10, 22]), new Map());
|
||||
|
||||
expect(queryBuilder.buildWhere).toHaveBeenCalledWith(filter, { accessibleLibraryIds: [1, 2], userId: 9, timeZone: 'UTC' });
|
||||
expect(tags).toHaveLength(1);
|
||||
expect(tags[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
ChangedTag: expect.objectContaining({
|
||||
Tag: expect.objectContaining({
|
||||
Id: 'ss-5',
|
||||
Name: 'To Read',
|
||||
// book 30 matched the scope but isn't in the eligible set, and 22 is eligible but didn't match the scope
|
||||
Items: [{ RevisionId: 'entitlement-10', Type: 'ProductRevisionTagItem' }],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('buildTagItems excludes synced smart scopes without a filter instead of matching everything', async () => {
|
||||
const db = makeDb();
|
||||
db.query.collections.findMany.mockResolvedValue([]);
|
||||
db.query.smartScopes.findMany.mockResolvedValue([{ id: 6, name: 'Empty Scope', filter: null, syncToKobo: true }]);
|
||||
bookAccessService.getAccessibleLibraryIds.mockResolvedValue([1]);
|
||||
const service = makeService(db);
|
||||
|
||||
const tags = await (service as any).buildTagItems(9, new Set([10]), new Map());
|
||||
|
||||
expect(queryBuilder.buildWhere).not.toHaveBeenCalled();
|
||||
expect(tags).toEqual([
|
||||
expect.objectContaining({
|
||||
ChangedTag: expect.objectContaining({
|
||||
Tag: expect.objectContaining({ Id: 'ss-6', Items: [] }),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('getSyncedSmartScopeMatches resolves multiple scopes independently, keyed by scope id', async () => {
|
||||
const db = makeDb({ select: [[{ id: 10 }], [{ id: 20 }, { id: 21 }]] });
|
||||
const filterA = { type: 'group', join: 'AND', rules: [] };
|
||||
const filterB = { type: 'group', join: 'OR', rules: [] };
|
||||
db.query.smartScopes.findMany.mockResolvedValue([
|
||||
{ id: 1, name: 'Scope A', filter: filterA, syncToKobo: true },
|
||||
{ id: 2, name: 'Scope B', filter: filterB, syncToKobo: true },
|
||||
]);
|
||||
queryBuilder.buildWhere.mockReturnValue('WHERE_CLAUSE');
|
||||
const service = makeService(db);
|
||||
|
||||
const matches = await (service as any).getSyncedSmartScopeMatches(9, [1], 'America/New_York');
|
||||
|
||||
expect(queryBuilder.buildWhere).toHaveBeenCalledWith(filterA, { accessibleLibraryIds: [1], userId: 9, timeZone: 'America/New_York' });
|
||||
expect(queryBuilder.buildWhere).toHaveBeenCalledWith(filterB, { accessibleLibraryIds: [1], userId: 9, timeZone: 'America/New_York' });
|
||||
expect(matches.get(1)).toEqual({ name: 'Scope A', bookIds: [10], where: 'WHERE_CLAUSE' });
|
||||
expect(matches.get(2)).toEqual({ name: 'Scope B', bookIds: [20, 21], where: 'WHERE_CLAUSE' });
|
||||
});
|
||||
|
||||
it('fetches synced smart scope book ids with the metadata join required by metadata-backed filters', async () => {
|
||||
const db = makeDb({ select: [[{ id: 10 }]] });
|
||||
const filter = { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'Dune' }] };
|
||||
db.query.smartScopes.findMany.mockResolvedValue([{ id: 1, name: 'Dune Scope', filter, syncToKobo: true }]);
|
||||
queryBuilder.buildWhere.mockReturnValue(sql`${schema.bookMetadata.title} ilike ${'%Dune%'}`);
|
||||
const service = makeService(db);
|
||||
|
||||
const matches = await (service as any).getSyncedSmartScopeMatches(9, [1], 'UTC');
|
||||
|
||||
expect(matches.get(1)?.bookIds).toEqual([10]);
|
||||
expect(db.__chains[0].leftJoin).toHaveBeenCalledWith(schema.bookMetadata, expect.anything());
|
||||
});
|
||||
|
||||
it('reuses a shared smartScopeMatchCache instead of re-querying smart scopes for each caller', async () => {
|
||||
const db = makeDb({
|
||||
select: [[{ id: 10 }]], // getSyncedSmartScopeMatches' single per-scope book lookup
|
||||
});
|
||||
db.query.collections.findMany.mockResolvedValue([]);
|
||||
db.query.smartScopes.findMany.mockResolvedValue([
|
||||
{ id: 5, name: 'To Read', filter: { type: 'group', join: 'AND', rules: [] }, syncToKobo: true },
|
||||
]);
|
||||
bookAccessService.getAccessibleLibraryIds.mockResolvedValue([1]);
|
||||
contentFilterRepository.findByUserId.mockResolvedValue({ includeTagIds: [], includeGenreIds: [], excludeTagIds: [], excludeGenreIds: [] });
|
||||
queryBuilder.buildWhere.mockReturnValue('WHERE_CLAUSE');
|
||||
const service = makeService(db);
|
||||
const cache = new Map();
|
||||
|
||||
await (service as any).buildEligibleBooksWhereClause(9, cache);
|
||||
await (service as any).buildTagItems(9, new Set([10]), cache);
|
||||
|
||||
expect(db.query.smartScopes.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("buildEligibleBooksWhereClause ORs in each scope's own SQL predicate instead of an inArray of matched book ids", async () => {
|
||||
const manyBookIds = Array.from({ length: 500 }, (_, i) => i + 1);
|
||||
const db = makeDb({ select: [manyBookIds.map((id) => ({ id }))] });
|
||||
db.query.collections.findMany.mockResolvedValue([]);
|
||||
db.query.smartScopes.findMany.mockResolvedValue([
|
||||
{ id: 5, name: 'To Read', filter: { type: 'group', join: 'AND', rules: [] }, syncToKobo: true },
|
||||
]);
|
||||
bookAccessService.getAccessibleLibraryIds.mockResolvedValue([1]);
|
||||
contentFilterRepository.findByUserId.mockResolvedValue({ includeTagIds: [], includeGenreIds: [], excludeTagIds: [], excludeGenreIds: [] });
|
||||
// A real (param-free) SQL fragment standing in for the scope's compiled filter, so we can
|
||||
// inspect the final where clause's structure instead of a mocked opaque string.
|
||||
queryBuilder.buildWhere.mockReturnValue(sql`EXISTS (FAKE_SCOPE_CONDITION)`);
|
||||
const service = makeService(db);
|
||||
|
||||
const where = await (service as any).buildEligibleBooksWhereClause(9, new Map());
|
||||
|
||||
// Regardless of how many books the scope matched, the clause should only bind params for
|
||||
// the fixed set of eligibility conditions (status/format/library/userId), not one per book.
|
||||
expect(countSqlParams(where)).toBeLessThan(10);
|
||||
});
|
||||
|
||||
describe('reconcileSnapshot', () => {
|
||||
function makeTxExecute() {
|
||||
const captured: unknown[] = [];
|
||||
@@ -745,7 +890,7 @@ describe('KoboSyncService', () => {
|
||||
const service = makeService(db);
|
||||
vi.spyOn(service as any, 'buildEligibleBooksWhereClause').mockResolvedValue({ where: true });
|
||||
|
||||
const snapshotRows = await (service as any).fetchEligibleSnapshotRows(8, true);
|
||||
const snapshotRows = await (service as any).fetchEligibleSnapshotRows(8, true, new Map());
|
||||
expect(snapshotRows).toEqual([
|
||||
{
|
||||
bookId: 5,
|
||||
@@ -755,7 +900,7 @@ describe('KoboSyncService', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const books = await (service as any).fetchEligibleBooksByIds(8, [5, 5], true);
|
||||
const books = await (service as any).fetchEligibleBooksByIds(8, [5, 5], true, new Map());
|
||||
expect(books.get(5)).toEqual(
|
||||
expect.objectContaining({
|
||||
title: 'Dune',
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { SQL, and, asc, eq, inArray, sql } from 'drizzle-orm';
|
||||
import { SQL, and, asc, eq, inArray, or, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../../db/db.module';
|
||||
import * as schema from '../../../db/schema';
|
||||
import { buildContentFilterClauses } from '../../../common/utils/content-filter-sql.utils';
|
||||
import { resolveTimeZone } from '../../../common/utils/timezone.utils';
|
||||
import { ContentFilterRepository } from '../../user/content-filter.repository';
|
||||
import { BookQueryBuilder } from '../../book/book-query-builder.service';
|
||||
import { KoboBookAccessService } from './kobo-book-access.service';
|
||||
import { KoboBookIdentityService } from './kobo-book-identity.service';
|
||||
import { KoboReadingStateService } from './kobo-reading-state.service';
|
||||
@@ -37,6 +39,13 @@ type KoboDeliveryInfo = {
|
||||
hash: string;
|
||||
};
|
||||
|
||||
type UserSettingsWithTimeZone = { timezone?: unknown };
|
||||
type SmartScopeMatch = { name: string; bookIds: number[]; where: SQL | undefined };
|
||||
// Scoped to a single getDelta/getBookMetadata call so results are shared across the
|
||||
// fetchEligibleSnapshotRows/fetchEligibleBooksByIds/buildTagItems calls it makes, without
|
||||
// caching across requests (this service is a singleton).
|
||||
type SmartScopeMatchCache = Map<number, Promise<Map<number, SmartScopeMatch>>>;
|
||||
|
||||
export interface KoboBookEntry {
|
||||
bookId: number;
|
||||
koboEntitlementId: string;
|
||||
@@ -75,6 +84,7 @@ export class KoboSyncService {
|
||||
private readonly readingStateService: KoboReadingStateService,
|
||||
private readonly contentFilterRepository: ContentFilterRepository,
|
||||
private readonly bookIdentityService: KoboBookIdentityService,
|
||||
private readonly queryBuilder: BookQueryBuilder,
|
||||
) {}
|
||||
|
||||
async getDelta(userId: number, deviceToken: string, baseUrl: string): Promise<{ entitlements: unknown[]; hasMore: boolean; syncToken: string }> {
|
||||
@@ -83,7 +93,8 @@ export class KoboSyncService {
|
||||
});
|
||||
|
||||
const hadSnapshot = Boolean(snapshot);
|
||||
const eligibleSnapshotRows = await this.fetchEligibleSnapshotRows(userId, hadSnapshot);
|
||||
const smartScopeMatchCache: SmartScopeMatchCache = new Map();
|
||||
const eligibleSnapshotRows = await this.fetchEligibleSnapshotRows(userId, hadSnapshot, smartScopeMatchCache);
|
||||
|
||||
if (!snapshot) {
|
||||
await this.createSnapshot(userId, eligibleSnapshotRows);
|
||||
@@ -91,7 +102,7 @@ export class KoboSyncService {
|
||||
await this.reconcileSnapshot(snapshot.id, eligibleSnapshotRows);
|
||||
}
|
||||
|
||||
return this.getPageFromSnapshot(userId, deviceToken, baseUrl, new Set(eligibleSnapshotRows.map((row) => row.bookId)));
|
||||
return this.getPageFromSnapshot(userId, deviceToken, baseUrl, new Set(eligibleSnapshotRows.map((row) => row.bookId)), smartScopeMatchCache);
|
||||
}
|
||||
|
||||
async getBookMetadata(userId: number, bookId: number, deviceToken: string, baseUrl: string): Promise<unknown[]> {
|
||||
@@ -99,7 +110,7 @@ export class KoboSyncService {
|
||||
where: eq(schema.koboLibrarySnapshots.userId, userId),
|
||||
columns: { id: true },
|
||||
});
|
||||
const booksById = await this.fetchEligibleBooksByIds(userId, [bookId], Boolean(snapshot));
|
||||
const booksById = await this.fetchEligibleBooksByIds(userId, [bookId], Boolean(snapshot), new Map());
|
||||
const book = booksById.get(bookId) ?? null;
|
||||
if (!book) return [];
|
||||
return [this.buildBookMetadata(book, deviceToken, baseUrl)];
|
||||
@@ -311,6 +322,7 @@ export class KoboSyncService {
|
||||
deviceToken: string,
|
||||
baseUrl: string,
|
||||
eligibleIds: Set<number>,
|
||||
smartScopeMatchCache: SmartScopeMatchCache,
|
||||
): Promise<{ entitlements: unknown[]; hasMore: boolean; syncToken: string }> {
|
||||
const snapshot = await this.db.query.koboLibrarySnapshots.findFirst({
|
||||
where: eq(schema.koboLibrarySnapshots.userId, userId),
|
||||
@@ -331,7 +343,7 @@ export class KoboSyncService {
|
||||
const page = pending.slice(0, SYNC_PAGE_SIZE);
|
||||
|
||||
if (page.length === 0) {
|
||||
const tagItems = await this.buildTagItems(userId, eligibleIds);
|
||||
const tagItems = await this.buildTagItems(userId, eligibleIds, smartScopeMatchCache);
|
||||
return { entitlements: tagItems, hasMore: false, syncToken };
|
||||
}
|
||||
|
||||
@@ -351,6 +363,7 @@ export class KoboSyncService {
|
||||
userId,
|
||||
page.filter((row) => !row.pendingDelete).map((row) => row.bookId),
|
||||
false,
|
||||
smartScopeMatchCache,
|
||||
);
|
||||
|
||||
for (const row of page) {
|
||||
@@ -411,18 +424,20 @@ export class KoboSyncService {
|
||||
return { entitlements, hasMore, syncToken };
|
||||
}
|
||||
|
||||
private async buildTagItems(userId: number, eligibleIds: Set<number>): Promise<unknown[]> {
|
||||
private async buildTagItems(userId: number, eligibleIds: Set<number>, smartScopeMatchCache: SmartScopeMatchCache): Promise<unknown[]> {
|
||||
const collections = await this.db.query.collections.findMany({
|
||||
where: and(eq(schema.collections.userId, userId), eq(schema.collections.syncToKobo, true)),
|
||||
});
|
||||
if (collections.length === 0) return [];
|
||||
|
||||
const collectionIds = collections.map((c) => c.id);
|
||||
|
||||
const collectionBooks = await this.db
|
||||
.select({ collectionId: schema.collectionBooks.collectionId, bookId: schema.collectionBooks.bookId })
|
||||
.from(schema.collectionBooks)
|
||||
.where(inArray(schema.collectionBooks.collectionId, collectionIds));
|
||||
const collectionBooks =
|
||||
collectionIds.length === 0
|
||||
? []
|
||||
: await this.db
|
||||
.select({ collectionId: schema.collectionBooks.collectionId, bookId: schema.collectionBooks.bookId })
|
||||
.from(schema.collectionBooks)
|
||||
.where(inArray(schema.collectionBooks.collectionId, collectionIds));
|
||||
|
||||
const booksByCollection = new Map<number, number[]>();
|
||||
for (const row of collectionBooks) {
|
||||
@@ -431,27 +446,96 @@ export class KoboSyncService {
|
||||
booksByCollection.set(row.collectionId, list);
|
||||
}
|
||||
|
||||
const [accessibleLibraryIds, timeZone] = await Promise.all([
|
||||
this.bookAccessService.getAccessibleLibraryIds(userId),
|
||||
this.getUserTimeZone(userId),
|
||||
]);
|
||||
const smartScopeMatches = await this.getSyncedSmartScopeMatchesCached(userId, accessibleLibraryIds, timeZone, smartScopeMatchCache);
|
||||
|
||||
if (collections.length === 0 && smartScopeMatches.size === 0) return [];
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const allEligibleBookIds = [...eligibleIds];
|
||||
const identitiesById = await this.bookIdentityService.ensureForBooks(userId, allEligibleBookIds, false);
|
||||
return collections.map((col) => {
|
||||
const bookIds = (booksByCollection.get(col.id) ?? []).filter((id) => eligibleIds.has(id));
|
||||
return {
|
||||
ChangedTag: {
|
||||
Tag: {
|
||||
Id: `col-${col.id}`,
|
||||
Name: col.name,
|
||||
Created: now,
|
||||
LastModified: now,
|
||||
Type: 'UserTag',
|
||||
Items: bookIds.flatMap((bookId) => {
|
||||
|
||||
const buildTag = (id: string, name: string, bookIds: number[]) => ({
|
||||
ChangedTag: {
|
||||
Tag: {
|
||||
Id: id,
|
||||
Name: name,
|
||||
Created: now,
|
||||
LastModified: now,
|
||||
Type: 'UserTag',
|
||||
Items: bookIds
|
||||
.filter((bookId) => eligibleIds.has(bookId))
|
||||
.flatMap((bookId) => {
|
||||
const identity = identitiesById.get(bookId);
|
||||
return identity ? [{ RevisionId: identity.entitlementId, Type: 'ProductRevisionTagItem' }] : [];
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const collectionTags = collections.map((col) => buildTag(`col-${col.id}`, col.name, booksByCollection.get(col.id) ?? []));
|
||||
const smartScopeTags = [...smartScopeMatches.entries()].map(([scopeId, match]) => buildTag(`ss-${scopeId}`, match.name, match.bookIds));
|
||||
|
||||
return [...collectionTags, ...smartScopeTags];
|
||||
}
|
||||
|
||||
// Memoizes getSyncedSmartScopeMatches for the lifetime of a single request (see SmartScopeMatchCache);
|
||||
// buildEligibleBooksWhereClause and buildTagItems both need it and would otherwise repeat the same queries.
|
||||
private getSyncedSmartScopeMatchesCached(
|
||||
userId: number,
|
||||
accessibleLibraryIds: number[] | null,
|
||||
timeZone: string,
|
||||
cache: SmartScopeMatchCache,
|
||||
): Promise<Map<number, SmartScopeMatch>> {
|
||||
let pending = cache.get(userId);
|
||||
if (!pending) {
|
||||
pending = this.getSyncedSmartScopeMatches(userId, accessibleLibraryIds, timeZone);
|
||||
cache.set(userId, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
private async getSyncedSmartScopeMatches(
|
||||
userId: number,
|
||||
accessibleLibraryIds: number[] | null,
|
||||
timeZone: string,
|
||||
): Promise<Map<number, SmartScopeMatch>> {
|
||||
const scopes = await this.db.query.smartScopes.findMany({
|
||||
where: and(eq(schema.smartScopes.userId, userId), eq(schema.smartScopes.syncToKobo, true)),
|
||||
});
|
||||
|
||||
if (scopes.length === 0) return new Map();
|
||||
|
||||
const libraryIds = accessibleLibraryIds ?? (await this.db.select({ id: schema.libraries.id }).from(schema.libraries)).map((row) => row.id);
|
||||
|
||||
const matches = await Promise.all(
|
||||
scopes.map(async (scope): Promise<[number, SmartScopeMatch]> => {
|
||||
// A scope without a filter matches zero books everywhere else in the app
|
||||
// (SmartScopeService.findAll/prepareBooksQuery), so mirror that here rather
|
||||
// than syncing the whole library for an unconfigured scope.
|
||||
if (!scope.filter) {
|
||||
return [scope.id, { name: scope.name, bookIds: [], where: undefined }];
|
||||
}
|
||||
const where = this.queryBuilder.buildWhere(scope.filter, { accessibleLibraryIds: libraryIds, userId, timeZone });
|
||||
const bookIds = await this.fetchSmartScopeBookIds(where);
|
||||
return [scope.id, { name: scope.name, bookIds, where }];
|
||||
}),
|
||||
);
|
||||
|
||||
return new Map<number, SmartScopeMatch>(matches);
|
||||
}
|
||||
|
||||
private async fetchSmartScopeBookIds(where: SQL | undefined): Promise<number[]> {
|
||||
if (!where) return [];
|
||||
const rows = await this.db
|
||||
.select({ id: schema.books.id })
|
||||
.from(schema.books)
|
||||
.leftJoin(schema.bookMetadata, eq(schema.bookMetadata.bookId, schema.books.id))
|
||||
.where(where);
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
private buildRemovedEntitlement(bookId: number | string) {
|
||||
@@ -627,10 +711,11 @@ export class KoboSyncService {
|
||||
};
|
||||
}
|
||||
|
||||
private async buildEligibleBooksWhereClause(userId: number): Promise<SQL | undefined> {
|
||||
const [accessibleLibraryIds, contentFilters] = await Promise.all([
|
||||
private async buildEligibleBooksWhereClause(userId: number, smartScopeMatchCache: SmartScopeMatchCache): Promise<SQL | undefined> {
|
||||
const [accessibleLibraryIds, contentFilters, timeZone] = await Promise.all([
|
||||
this.bookAccessService.getAccessibleLibraryIds(userId),
|
||||
this.contentFilterRepository.findByUserId(userId),
|
||||
this.getUserTimeZone(userId),
|
||||
]);
|
||||
|
||||
const libraryAccessFilter =
|
||||
@@ -650,19 +735,35 @@ export class KoboSyncService {
|
||||
AND ${schema.collections.syncToKobo} = true
|
||||
)`;
|
||||
|
||||
const smartScopeMatches = await this.getSyncedSmartScopeMatchesCached(userId, accessibleLibraryIds, timeZone, smartScopeMatchCache);
|
||||
const smartScopeFilters = [...smartScopeMatches.values()].map((match) => match.where).filter((where): where is SQL => where !== undefined);
|
||||
const membershipFilter = smartScopeFilters.length > 0 ? or(collectionMembershipFilter, ...smartScopeFilters) : collectionMembershipFilter;
|
||||
|
||||
const contentFilterClauses = accessibleLibraryIds !== null ? buildContentFilterClauses(contentFilters, this.db) : [];
|
||||
|
||||
return and(
|
||||
eq(schema.books.status, 'present'),
|
||||
inArray(schema.bookFiles.format, ['epub', 'kepub']),
|
||||
libraryAccessFilter,
|
||||
collectionMembershipFilter,
|
||||
membershipFilter,
|
||||
...contentFilterClauses,
|
||||
);
|
||||
}
|
||||
|
||||
private async fetchEligibleSnapshotRows(userId: number, needsLegacyNumericRemovalForNewMappings: boolean): Promise<EligibleSnapshotRow[]> {
|
||||
const whereClause = await this.buildEligibleBooksWhereClause(userId);
|
||||
private async getUserTimeZone(userId: number): Promise<string> {
|
||||
const user = await this.db.query.users.findFirst({
|
||||
where: eq(schema.users.id, userId),
|
||||
columns: { settings: true },
|
||||
});
|
||||
return resolveTimeZone((user?.settings as UserSettingsWithTimeZone | undefined)?.timezone, 'UTC');
|
||||
}
|
||||
|
||||
private async fetchEligibleSnapshotRows(
|
||||
userId: number,
|
||||
needsLegacyNumericRemovalForNewMappings: boolean,
|
||||
smartScopeMatchCache: SmartScopeMatchCache,
|
||||
): Promise<EligibleSnapshotRow[]> {
|
||||
const whereClause = await this.buildEligibleBooksWhereClause(userId, smartScopeMatchCache);
|
||||
if (!whereClause) return [];
|
||||
const deliverySettings = await this.getDeliverySettings(userId);
|
||||
|
||||
@@ -728,11 +829,12 @@ export class KoboSyncService {
|
||||
userId: number,
|
||||
bookIds: number[],
|
||||
needsLegacyNumericRemovalForNewMappings: boolean,
|
||||
smartScopeMatchCache: SmartScopeMatchCache,
|
||||
): Promise<Map<number, KoboBookEntry>> {
|
||||
const uniqueBookIds = [...new Set(bookIds)];
|
||||
if (uniqueBookIds.length === 0) return new Map();
|
||||
|
||||
const whereClause = await this.buildEligibleBooksWhereClause(userId);
|
||||
const whereClause = await this.buildEligibleBooksWhereClause(userId, smartScopeMatchCache);
|
||||
if (!whereClause) return new Map();
|
||||
const deliverySettings = await this.getDeliverySettings(userId);
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ describe('KOReader plugin update source wiring', () => {
|
||||
detail.indexOf('function CatalogDetail:updateDetailItems'),
|
||||
);
|
||||
|
||||
expect(detailActionsBlock).toContain('text = _("Download")');
|
||||
expect(detailActionsBlock).toContain('text = self:downloadButtonLabel(supported_files)');
|
||||
expect(detailActionsBlock).toContain('self:downloadDefaultFile(detail, supported_files[1])');
|
||||
expect(detailActionsBlock).toContain('self:showFileChoices(detail)');
|
||||
expect(detailActionsBlock).toContain('text = _("Download options")');
|
||||
|
||||
@@ -40,4 +40,8 @@ export class CreateSmartScopeDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPublic?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
syncToKobo?: boolean;
|
||||
}
|
||||
|
||||
@@ -33,4 +33,8 @@ export class UpdateSmartScopeDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPublic?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
syncToKobo?: boolean;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ function makeSmartScope(overrides: Partial<SmartScope> = {}): SmartScope {
|
||||
filter: null,
|
||||
defaultSort: [],
|
||||
isPublic: false,
|
||||
syncToKobo: false,
|
||||
displayOrder: 0,
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
@@ -145,6 +146,7 @@ describe('SmartScopeService', () => {
|
||||
filter: null,
|
||||
defaultSort: [{ field: 'title', dir: 'asc' }],
|
||||
isPublic: false,
|
||||
syncToKobo: false,
|
||||
});
|
||||
expect(result).toEqual(created);
|
||||
});
|
||||
|
||||
@@ -100,6 +100,7 @@ export class SmartScopeService {
|
||||
filter,
|
||||
defaultSort: dto.defaultSort ?? [],
|
||||
isPublic: dto.isPublic ?? false,
|
||||
syncToKobo: dto.syncToKobo ?? false,
|
||||
});
|
||||
return smartScope;
|
||||
}
|
||||
@@ -120,6 +121,7 @@ export class SmartScopeService {
|
||||
filter,
|
||||
defaultSort: dto.defaultSort,
|
||||
isPublic: dto.isPublic,
|
||||
syncToKobo: dto.syncToKobo,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user