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:
Bernard Potocki
2026-07-07 23:55:08 +02:00
committed by GitHub
parent 037a6714e6
commit d82a018d1e
16 changed files with 13309 additions and 44 deletions
@@ -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',
+2
View File
@@ -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
}
]
}
+1
View File
@@ -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;
}