feat(kobo): restore read-status thresholds, remove two-way progress sync, fix LAN dev sync, and add fallback fraction positioning for epub/cbz/pdf readers

This commit is contained in:
neon
2026-05-11 21:23:11 +00:00
parent 6b650981da
commit 31b3460658
30 changed files with 9445 additions and 418 deletions
@@ -6,7 +6,6 @@ const settings = ref<KoboSyncSettings>({
readingThreshold: 1,
finishedThreshold: 99,
convertToKepub: true,
twoWayProgressSync: false,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
})
+1 -1
View File
@@ -164,7 +164,7 @@ onMounted(async () => {
shouldApplyStyles.value = false
}
await open(bookId, fileId, fileFormat, progress.cfi.value)
await open(bookId, fileId, fileFormat, progress.cfi.value, progress.percentage.value > 0 ? progress.percentage.value / 100 : undefined)
setChapters(getChapters())
sectionFractions.value = getSectionFractions()
await bookmarks.load(bookId)
@@ -539,7 +539,12 @@ onMounted(async () => {
await load()
const saved = progress.pageNumber.value
if (saved && saved > 1) currentPage.value = Math.min(saved - 1, pageCount.value - 1)
if (saved && saved > 1) {
currentPage.value = Math.min(saved - 1, pageCount.value - 1)
} else if (progress.percentage.value > 0 && pageCount.value > 1) {
const estimated = Math.round((progress.percentage.value / 100) * pageCount.value)
currentPage.value = Math.max(0, Math.min(estimated - 1, pageCount.value - 1))
}
if (scrollMode.value === 'paginated' && isTwoPageEffective.value) {
currentPage.value = spreadLayout.value.anchorForPage(currentPage.value)
@@ -0,0 +1,143 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/api', () => ({
api: vi.fn<() => Promise<Response>>(),
getAccessToken: vi.fn<() => string | null>(() => 'test-token'),
}))
vi.mock('../useFoliateAnnotations', () => ({
useFoliateAnnotations: () => ({
annotationStyleMap: new Map(),
addAnnotation: vi.fn<() => void>(),
addAnnotations: vi.fn<() => void>(),
deleteAnnotation: vi.fn<() => void>(),
reAddAll: vi.fn<() => void>(),
handleDrawAnnotationEvent: vi.fn<() => void>(),
}),
}))
vi.mock('../useFoliateSelection', () => ({
useFoliateSelection: () => ({
setHandler: vi.fn<() => void>(),
handleSelectionEnd: vi.fn<() => void>(),
handleSelectionChange: vi.fn<() => void>(),
}),
}))
vi.mock('../useFoliateInput', () => ({
useFoliateInput: () => ({
cleanup: vi.fn<() => void>(),
attachIframeClicks: vi.fn<() => void>(),
}),
}))
import { api } from '@/lib/api'
import { useFoliate } from '../useFoliate'
describe('useFoliate.open', () => {
let container: HTMLDivElement
let mockGoTo: ReturnType<typeof vi.fn>
let mockGoToFraction: ReturnType<typeof vi.fn>
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
mockGoTo = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
mockGoToFraction = vi.fn<() => void>()
const originalCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
if (tag === 'foliate-view') {
const el = originalCreateElement('div')
Object.assign(el, {
open: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
goTo: mockGoTo,
goToFraction: mockGoToFraction,
renderer: {
setAttribute: vi.fn<(name: string, value: string) => void>(),
removeAttribute: vi.fn<(name: string) => void>(),
},
destroy: vi.fn<() => void>(),
})
return el
}
return originalCreateElement(tag)
})
vi.spyOn(customElements, 'get').mockReturnValue(class {} as CustomElementConstructor)
vi.mocked(api).mockResolvedValue({
ok: true,
json: vi.fn<() => Promise<unknown>>().mockResolvedValue({}),
} as unknown as Response)
;(window as { makeStreamingBook?: unknown }).makeStreamingBook = vi.fn<() => Promise<unknown>>().mockResolvedValue({ type: 'book' })
})
afterEach(() => {
vi.restoreAllMocks()
container.remove()
delete (window as { makeStreamingBook?: unknown }).makeStreamingBook
})
it('navigates to CFI when cfi is provided', async () => {
const foliate = useFoliate(() => container)
await foliate.open(1, 1, 'epub', 'epubcfi(/6/2!)', undefined)
expect(mockGoTo).toHaveBeenCalledWith('epubcfi(/6/2!)')
expect(mockGoToFraction).not.toHaveBeenCalled()
})
it('navigates to fallback fraction when cfi is null and fraction > 0', async () => {
const foliate = useFoliate(() => container)
await foliate.open(1, 1, 'epub', null, 0.42)
expect(mockGoTo).not.toHaveBeenCalled()
expect(mockGoToFraction).toHaveBeenCalledWith(0.42)
})
it('does not navigate when cfi is null and fraction is 0', async () => {
const foliate = useFoliate(() => container)
await foliate.open(1, 1, 'epub', null, 0)
expect(mockGoTo).not.toHaveBeenCalled()
expect(mockGoToFraction).not.toHaveBeenCalled()
})
it('does not navigate when cfi is null and fraction is undefined', async () => {
const foliate = useFoliate(() => container)
await foliate.open(1, 1, 'epub', null, undefined)
expect(mockGoTo).not.toHaveBeenCalled()
expect(mockGoToFraction).not.toHaveBeenCalled()
})
it('gracefully handles goTo rejection without propagating', async () => {
mockGoTo.mockRejectedValue(new Error('invalid CFI'))
const foliate = useFoliate(() => container)
await expect(foliate.open(1, 1, 'epub', 'epubcfi(/bad)', undefined)).resolves.toBeUndefined()
})
it('does not call goTo or goToFraction when container is null', async () => {
const foliate = useFoliate(() => null)
await foliate.open(1, 1, 'epub', 'epubcfi(/6/2!)', 0.5)
expect(mockGoTo).not.toHaveBeenCalled()
expect(mockGoToFraction).not.toHaveBeenCalled()
})
it('prefers cfi over fallback fraction when both are provided', async () => {
const foliate = useFoliate(() => container)
await foliate.open(1, 1, 'epub', 'epubcfi(/6/4!)', 0.75)
expect(mockGoTo).toHaveBeenCalledWith('epubcfi(/6/4!)')
expect(mockGoToFraction).not.toHaveBeenCalled()
})
})
@@ -50,7 +50,7 @@ export function useFoliate(
await customElements.whenDefined('foliate-view')
}
async function open(bookId: number, fileId: number, format: string, cfi?: string | null) {
async function open(bookId: number, fileId: number, format: string, cfi?: string | null, fallbackFraction?: number) {
const el = container()
if (!el) return
@@ -64,6 +64,7 @@ export function useFoliate(
renderer: FoliateRenderer
open: (file: File) => Promise<void>
goTo: (target: string | number) => Promise<void>
goToFraction?: (f: number) => void
book?: { toc?: unknown[] }
getSectionFractions?: () => number[]
prev?: () => void
@@ -131,7 +132,11 @@ export function useFoliate(
await view.open(file)
}
if (onApplyStyles) onApplyStyles(view.renderer)
await view.goTo(cfi ?? 0).catch(() => {})
if (cfi) {
await view.goTo(cfi).catch(() => {})
} else if (fallbackFraction !== undefined && fallbackFraction > 0) {
view.goToFraction?.(fallbackFraction)
}
} catch (e) {
console.error('[useFoliate]', e)
error.value = e instanceof Error ? e.message : 'Failed to open book'
@@ -165,7 +170,8 @@ export function useFoliate(
error,
fraction,
view: viewRef,
open: (bookId: number, fileId: number, format: string, cfi?: string | null) => open(bookId, fileId, format, cfi),
open: (bookId: number, fileId: number, format: string, cfi?: string | null, fallbackFraction?: number) =>
open(bookId, fileId, format, cfi, fallbackFraction),
prev: () => getViewEl()?.prev?.(),
next: () => getViewEl()?.next?.(),
goTo: (t: string | number) => getViewEl()?.goTo?.(t),
@@ -255,7 +255,12 @@ onMounted(async () => {
await nextTick()
measure()
const initialPage = progress.pageNumber.value && progress.pageNumber.value > 1 ? progress.pageNumber.value : 1
const initialPage =
progress.pageNumber.value && progress.pageNumber.value > 1
? progress.pageNumber.value
: progress.percentage.value > 0
? Math.max(1, Math.round((progress.percentage.value / 100) * totalPages.value))
: 1
goToPage(initialPage, 'instant')
await renderPage(initialPage)
+3 -16
View File
@@ -33,7 +33,6 @@ const renaming = ref(false)
const readingThreshold = ref(1)
const finishedThreshold = ref(99)
const convertToKepub = ref(true)
const twoWayProgressSync = ref(false)
const forceEnableHyphenation = ref(false)
const kepubConversionLimitMb = ref(100)
const savingSettings = ref(false)
@@ -59,7 +58,6 @@ onMounted(async () => {
readingThreshold.value = settings.value.readingThreshold
finishedThreshold.value = settings.value.finishedThreshold
convertToKepub.value = settings.value.convertToKepub
twoWayProgressSync.value = settings.value.twoWayProgressSync
forceEnableHyphenation.value = settings.value.forceEnableHyphenation
kepubConversionLimitMb.value = settings.value.kepubConversionLimitMb
} catch (e) {
@@ -152,7 +150,6 @@ async function saveSettings() {
readingThreshold: readingThreshold.value,
finishedThreshold: finishedThreshold.value,
convertToKepub: convertToKepub.value,
twoWayProgressSync: twoWayProgressSync.value,
forceEnableHyphenation: forceEnableHyphenation.value,
kepubConversionLimitMb: kepubConversionLimitMb.value,
})
@@ -167,7 +164,7 @@ async function saveSettings() {
</script>
<template>
<SettingsPageHeader title="Kobo Sync" subtitle="Pair your Kobo device to sync your library and reading progress." />
<SettingsPageHeader title="Kobo Sync" subtitle="Pair your Kobo device to sync your library." />
<div v-if="loading" class="text-sm text-muted-foreground">Loading...</div>
<div v-else-if="error" class="text-sm text-destructive">{{ error }}</div>
@@ -243,9 +240,7 @@ async function saveSettings() {
<Tablet :size="18" class="text-muted-foreground/70" />
</div>
<p class="text-sm font-medium text-foreground">No devices yet</p>
<p class="text-xs text-muted-foreground mt-1 max-w-[240px] mx-auto">
Add a device to start syncing your books and reading progress to your Kobo.
</p>
<p class="text-xs text-muted-foreground mt-1 max-w-[240px] mx-auto">Add a device to start syncing your books to your Kobo.</p>
</div>
<div v-else-if="devices.length > 0" class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
@@ -306,14 +301,6 @@ async function saveSettings() {
<ToggleSwitch v-model="forceEnableHyphenation" />
</div>
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div class="pr-8">
<p class="settings-label">Two-way progress sync</p>
<p class="settings-hint">Pushes progress from the web reader to your device during sync.</p>
</div>
<ToggleSwitch v-model="twoWayProgressSync" />
</div>
<div class="px-5 py-5 bg-card space-y-5">
<div>
<p class="settings-label mb-1">Progress Thresholds</p>
@@ -339,7 +326,7 @@ async function saveSettings() {
</div>
</div>
<div class="pt-2">
<div>
<div class="flex items-center justify-between mb-2">
<label class="text-[12px] font-bold text-muted-foreground uppercase tracking-widest">KEPUB conversion limit</label>
<span class="text-xs font-mono text-primary font-bold">{{ kepubConversionLimitMb }} MB</span>
+12 -1
View File
@@ -19,8 +19,19 @@ export default defineConfig({
include: ['@tanstack/vue-table', '@tanstack/vue-virtual'],
},
server: {
host: true,
proxy: {
'/api': 'http://localhost:3000',
'/api': {
target: 'http://localhost:3000',
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
if (req.headers.host) proxyReq.setHeader('x-forwarded-host', req.headers.host)
const localPort = (req.socket as { localPort?: number })?.localPort
if (localPort) proxyReq.setHeader('x-forwarded-port', String(localPort))
proxyReq.setHeader('x-forwarded-proto', 'http')
})
},
},
'/socket.io': {
target: 'http://localhost:3000',
ws: true,
-1
View File
@@ -120,7 +120,6 @@ export type BookKoboReadingState = {
createdAtKobo: string | null;
lastModifiedKobo: string | null;
priorityTimestamp: string | null;
progressSyncedAt: string | null;
updatedAt: string;
};
-2
View File
@@ -13,7 +13,6 @@ export interface KoboSyncSettings {
readingThreshold: number;
finishedThreshold: number;
convertToKepub: boolean;
twoWayProgressSync: boolean;
forceEnableHyphenation: boolean;
kepubConversionLimitMb: number;
}
@@ -30,7 +29,6 @@ export interface UpdateKoboSyncSettingsRequest {
readingThreshold?: number;
finishedThreshold?: number;
convertToKepub?: boolean;
twoWayProgressSync?: boolean;
forceEnableHyphenation?: boolean;
kepubConversionLimitMb?: number;
}
@@ -0,0 +1,2 @@
ALTER TABLE "kobo_reading_states" DROP COLUMN "progress_synced_at";--> statement-breakpoint
ALTER TABLE "kobo_sync_settings" DROP COLUMN "two_way_progress_sync";
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,13 @@
"when": 1778450195675,
"tag": "0000_baseline",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1778533843441,
"tag": "0001_remove_two_way_progress_sync",
"breakpoints": true
}
]
}
-2
View File
@@ -30,7 +30,6 @@ export const koboSyncSettings = pgTable(
readingThreshold: real('reading_threshold').notNull().default(1),
finishedThreshold: real('finished_threshold').notNull().default(99),
convertToKepub: boolean('convert_to_kepub').notNull().default(false),
twoWayProgressSync: boolean('two_way_progress_sync').notNull().default(false),
forceEnableHyphenation: boolean('force_enable_hyphenation').notNull().default(false),
kepubConversionLimitMb: integer('kepub_conversion_limit_mb').notNull().default(100),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
@@ -98,7 +97,6 @@ export const koboReadingStates = pgTable(
currentBookmark: jsonb('current_bookmark'),
statistics: jsonb('statistics'),
statusInfo: jsonb('status_info'),
progressSyncedAt: timestamp('progress_synced_at', { withTimezone: true }),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
@@ -511,7 +511,6 @@ export class BookRepository {
currentBookmark: koboReadingStates.currentBookmark,
statistics: koboReadingStates.statistics,
statusInfo: koboReadingStates.statusInfo,
progressSyncedAt: koboReadingStates.progressSyncedAt,
updatedAt: koboReadingStates.updatedAt,
})
.from(koboReadingStates)
@@ -1251,7 +1251,6 @@ describe('BookService', () => {
createdAtKobo: 'created',
lastModifiedKobo: 'updated',
priorityTimestamp: 'priority',
progressSyncedAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
});
bookRepo.findKoboSnapshotState.mockResolvedValue({
-1
View File
@@ -1452,7 +1452,6 @@ export class BookService {
createdAtKobo: readingStateRow.createdAtKobo ?? null,
lastModifiedKobo: readingStateRow.lastModifiedKobo ?? null,
priorityTimestamp: readingStateRow.priorityTimestamp ?? null,
progressSyncedAt: readingStateRow.progressSyncedAt?.toISOString() ?? null,
updatedAt: readingStateRow.updatedAt.toISOString(),
}
: null,
@@ -21,27 +21,35 @@ describe('Kobo DTO validation', () => {
expect((await getErrors(CreateDeviceDto, { name: 'x'.repeat(256) })).length).toBeGreaterThan(0);
});
it('validates threshold ranges and integer limits in update settings DTO', async () => {
it('validates boolean and integer limits in update settings DTO', async () => {
expect(
(
await getErrors(UpdateSettingsDto, {
readingThreshold: 2.5,
finishedThreshold: 95,
convertToKepub: true,
twoWayProgressSync: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 120,
})
).length,
).toBe(0);
expect((await getErrors(UpdateSettingsDto, { readingThreshold: 0.49 })).length).toBeGreaterThan(0);
expect((await getErrors(UpdateSettingsDto, { readingThreshold: 10.01 })).length).toBeGreaterThan(0);
expect((await getErrors(UpdateSettingsDto, { finishedThreshold: 74 })).length).toBeGreaterThan(0);
expect((await getErrors(UpdateSettingsDto, { finishedThreshold: 101 })).length).toBeGreaterThan(0);
expect((await getErrors(UpdateSettingsDto, { convertToKepub: 'true' })).length).toBeGreaterThan(0);
expect((await getErrors(UpdateSettingsDto, { forceEnableHyphenation: 'false' })).length).toBeGreaterThan(0);
expect((await getErrors(UpdateSettingsDto, { kepubConversionLimitMb: 0 })).length).toBeGreaterThan(0);
expect((await getErrors(UpdateSettingsDto, { kepubConversionLimitMb: 501 })).length).toBeGreaterThan(0);
expect((await getErrors(UpdateSettingsDto, { kepubConversionLimitMb: 10.5 })).length).toBeGreaterThan(0);
});
it('validates readingThreshold and finishedThreshold bounds', async () => {
expect((await getErrors(UpdateSettingsDto, { readingThreshold: 1 })).length).toBe(0);
expect((await getErrors(UpdateSettingsDto, { readingThreshold: 0.5 })).length).toBe(0);
expect((await getErrors(UpdateSettingsDto, { readingThreshold: 10 })).length).toBe(0);
expect((await getErrors(UpdateSettingsDto, { finishedThreshold: 99 })).length).toBe(0);
expect((await getErrors(UpdateSettingsDto, { finishedThreshold: 75 })).length).toBe(0);
expect((await getErrors(UpdateSettingsDto, { finishedThreshold: 100 })).length).toBe(0);
expect((await getErrors(UpdateSettingsDto, { readingThreshold: 0.4 })).length).toBeGreaterThan(0);
expect((await getErrors(UpdateSettingsDto, { readingThreshold: 10.1 })).length).toBeGreaterThan(0);
expect((await getErrors(UpdateSettingsDto, { finishedThreshold: 74 })).length).toBeGreaterThan(0);
expect((await getErrors(UpdateSettingsDto, { finishedThreshold: 101 })).length).toBeGreaterThan(0);
});
});
@@ -1,4 +1,4 @@
import { IsBoolean, IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator';
import { IsNumber, IsOptional, IsInt, IsBoolean, Max, Min } from 'class-validator';
export class UpdateSettingsDto {
@IsOptional()
@@ -17,10 +17,6 @@ export class UpdateSettingsDto {
@IsBoolean()
convertToKepub?: boolean;
@IsOptional()
@IsBoolean()
twoWayProgressSync?: boolean;
@IsOptional()
@IsBoolean()
forceEnableHyphenation?: boolean;
@@ -26,6 +26,33 @@ describe('KoboDeviceController', () => {
vi.clearAllMocks();
});
it('parses compound CoverImageId format (bookId-timestamp) correctly for thumbnails and downloads', async () => {
const req = { method: 'GET', url: '/api/v1/kobo/token/v1/books/42-1778450221186/thumbnail/355/530/80/False/image.jpg' };
const reply = makeReply();
await controller.thumbnailFull(
'42-1778450221186',
undefined,
{ id: 5 } as never,
{ deviceToken: 'token' } as never,
req as never,
reply as never,
);
await controller.thumbnailSimple(
'42-1778450221186',
undefined,
{ id: 5 } as never,
{ deviceToken: 'token' } as never,
req as never,
reply as never,
);
await controller.download('42-1778450221186', { id: 5 } as never, { deviceToken: 'token' } as never, req as never, reply as never);
expect(thumbnailService.serveThumbnail).toHaveBeenNthCalledWith(1, 5, 42, undefined, reply);
expect(thumbnailService.serveThumbnail).toHaveBeenNthCalledWith(2, 5, 42, undefined, reply);
expect(downloadService.streamBook).toHaveBeenCalledWith(5, 42, reply);
});
it('serves thumbnail for valid book ids across all thumbnail endpoints', async () => {
const req = { method: 'GET', url: '/api/v1/kobo/token/v1/books/12/thumbnail/300/300/false/image.jpg' };
const reply = makeReply();
@@ -53,6 +53,86 @@ describe('KoboSyncController', () => {
expect(resources.library_sync).toBe('https://reader.example.com/api/v1/kobo/device-token/v1/library/sync');
});
it('initialization appends x-forwarded-port when host has no port (Vite dev proxy scenario)', () => {
const req = {
headers: {
'x-forwarded-host': '192.168.8.134',
'x-forwarded-port': '5173',
'x-forwarded-proto': 'http',
host: '192.168.8.134',
},
protocol: 'http',
hostname: '192.168.8.134',
socket: { localPort: 3000 },
};
const payload = controller.initialization({ deviceToken: 'dev' } as never, req as never);
const resources = payload.Resources as Record<string, string>;
expect(resources.image_host).toBe('http://192.168.8.134:5173');
expect(resources.library_sync).toBe('http://192.168.8.134:5173/api/v1/kobo/dev/v1/library/sync');
});
it('initialization does not append x-forwarded-port when it is the default for the scheme', () => {
const httpDefault = {
headers: { 'x-forwarded-host': 'example.com', 'x-forwarded-port': '80', 'x-forwarded-proto': 'http' },
protocol: 'http',
hostname: 'example.com',
socket: { localPort: 80 },
};
const httpsDefault = {
headers: { 'x-forwarded-host': 'example.com', 'x-forwarded-port': '443', 'x-forwarded-proto': 'https' },
protocol: 'https',
hostname: 'example.com',
socket: { localPort: 443 },
};
const r1 = controller.initialization({ deviceToken: 'x' } as never, httpDefault as never);
const r2 = controller.initialization({ deviceToken: 'x' } as never, httpsDefault as never);
expect((r1.Resources as Record<string, string>).image_host).toBe('http://example.com');
expect((r2.Resources as Record<string, string>).image_host).toBe('https://example.com');
});
it('initialization uses custom x-forwarded-port for non-standard HTTPS', () => {
const req = {
headers: { 'x-forwarded-host': 'myapp.com', 'x-forwarded-port': '8443', 'x-forwarded-proto': 'https' },
protocol: 'https',
hostname: 'myapp.com',
socket: { localPort: 3000 },
};
const payload = controller.initialization({ deviceToken: 'x' } as never, req as never);
expect((payload.Resources as Record<string, string>).image_host).toBe('https://myapp.com:8443');
});
it('initialization ignores x-forwarded-port when host already contains a port', () => {
const req = {
headers: { 'x-forwarded-host': 'myapp.com:8080', 'x-forwarded-port': '9090', 'x-forwarded-proto': 'https' },
protocol: 'https',
hostname: 'myapp.com',
socket: { localPort: 3000 },
};
const payload = controller.initialization({ deviceToken: 'x' } as never, req as never);
expect((payload.Resources as Record<string, string>).image_host).toBe('https://myapp.com:8080');
});
it('initialization takes first value when x-forwarded-host is an array', () => {
const req = {
headers: { 'x-forwarded-host': ['primary.example.com', 'fallback.example.com'], 'x-forwarded-proto': 'https' },
protocol: 'http',
hostname: 'localhost',
socket: { localPort: 3000 },
};
const payload = controller.initialization({ deviceToken: 'x' } as never, req as never);
expect((payload.Resources as Record<string, string>).image_host).toBe('https://primary.example.com');
});
it('initialization appends local port when request host has no explicit port', () => {
const req = {
headers: { host: '127.0.0.1' },
@@ -66,12 +146,20 @@ describe('KoboSyncController', () => {
expect((payload.Resources as Record<string, string>).image_host).toBe('http://127.0.0.1:8080');
});
it('initialization does not append local port when it is the HTTP default', () => {
const req = {
headers: { host: 'example.com' },
protocol: 'http',
hostname: 'example.com',
socket: { localPort: 80 },
};
const payload = controller.initialization({ deviceToken: 'abc' } as never, req as never);
expect((payload.Resources as Record<string, string>).image_host).toBe('http://example.com');
});
it('librarySync sets response headers and sends entitlement payload', async () => {
settingsService.getSettings.mockResolvedValue({
readingThreshold: 2.5,
finishedThreshold: 95,
twoWayProgressSync: true,
});
syncService.getDelta.mockResolvedValue({
entitlements: [{ NewEntitlement: { BookEntitlement: { Id: '12' } } }],
hasMore: true,
@@ -87,12 +175,8 @@ describe('KoboSyncController', () => {
await controller.librarySync({ deviceToken: 'token-9' } as never, { id: 21 } as never, 'old-sync', req as never, reply as never);
expect(settingsService.getSettings).toHaveBeenCalledWith(21);
expect(syncService.getDelta).toHaveBeenCalledWith(21, 'token-9', 'http://kobo.local:3000', 'old-sync', {
readingThreshold: 2.5,
finishedThreshold: 95,
twoWayProgressSync: true,
});
expect(settingsService.getSettings).not.toHaveBeenCalled();
expect(syncService.getDelta).toHaveBeenCalledWith(21, 'token-9', 'http://kobo.local:3000');
expect(reply.header).toHaveBeenCalledWith('x-kobo-sync', 'continue');
expect(reply.header).toHaveBeenCalledWith('x-kobo-synctoken', 'SYNC-2');
expect(reply.send).toHaveBeenCalledWith([{ NewEntitlement: { BookEntitlement: { Id: '12' } } }]);
@@ -131,10 +215,13 @@ describe('KoboSyncController', () => {
expect(reply.send).toHaveBeenNthCalledWith(4, [{ EntitlementId: '5' }]);
});
it('updateReadingState reads thresholds and uses first ReadingStates element when provided', async () => {
it('updateReadingState uses first ReadingStates element when provided', async () => {
settingsService.getSettings.mockResolvedValue({
readingThreshold: 3,
finishedThreshold: 92,
readingThreshold: 1,
finishedThreshold: 99,
convertToKepub: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
});
readingStateService.upsertState.mockResolvedValue({ RequestResult: 'Success' });
const reply = makeReply();
@@ -151,8 +238,7 @@ describe('KoboSyncController', () => {
reply as never,
);
expect(settingsService.getSettings).toHaveBeenCalledWith(8);
expect(readingStateService.upsertState).toHaveBeenCalledWith(8, 77, { EntitlementId: '77', CurrentBookmark: { ProgressPercent: 56 } }, 3, 92);
expect(readingStateService.upsertState).toHaveBeenCalledWith(8, 77, { EntitlementId: '77', CurrentBookmark: { ProgressPercent: 56 } }, 1, 99);
expect(reply.send).toHaveBeenCalledWith({ RequestResult: 'Success' });
});
});
+11 -14
View File
@@ -39,11 +39,15 @@ function buildBaseUrl(req: FastifyRequest): string {
const headerHost = fwdHost ?? req.headers.host;
let host = headerHost ? (Array.isArray(headerHost) ? headerHost[0] : headerHost) : req.hostname;
if (!hasForwarded && !host.includes(':')) {
const localPort = req.socket?.localPort;
const isDefault = (proto === 'http' && localPort === 80) || (proto === 'https' && localPort === 443);
if (localPort && !isDefault) {
host = host + ':' + String(localPort);
if (!host.includes(':')) {
const port = fwdPort ? (Array.isArray(fwdPort) ? fwdPort[0] : fwdPort) : null;
if (port) {
const isDefault = (proto === 'http' && port === '80') || (proto === 'https' && port === '443');
if (!isDefault) host = host + ':' + port;
} else if (!hasForwarded) {
const localPort = req.socket?.localPort;
const isDefault = (proto === 'http' && localPort === 80) || (proto === 'https' && localPort === 443);
if (localPort && !isDefault) host = host + ':' + String(localPort);
}
}
@@ -89,14 +93,7 @@ export class KoboSyncController {
) {
this.logger.log(`librarySync: userId=${user.id} syncToken=${incomingToken ?? 'none'}`);
const baseUrl = buildBaseUrl(req);
const settings = await this.settingsService.getSettings(user.id);
const { entitlements, hasMore, syncToken } = await this.syncService.getDelta(
user.id,
device.deviceToken,
baseUrl,
incomingToken ?? null,
settings,
);
const { entitlements, hasMore, syncToken } = await this.syncService.getDelta(user.id, device.deviceToken, baseUrl);
reply.header('x-kobo-sync', hasMore ? 'continue' : '');
reply.header('x-kobo-synctoken', syncToken);
reply.send(entitlements);
@@ -158,9 +155,9 @@ export class KoboSyncController {
) {
const id = parseInt(bookId, 10);
if (isNaN(id)) return this.proxyService.forward(req, reply, device.deviceToken);
const settings = await this.settingsService.getSettings(user.id);
const states = body.ReadingStates as Record<string, unknown>[] | undefined;
const statePayload = states?.[0] ?? body;
const settings = await this.settingsService.getSettings(user.id);
const result = await this.readingStateService.upsertState(user.id, id, statePayload, settings.readingThreshold, settings.finishedThreshold);
reply.send(result);
}
@@ -40,16 +40,22 @@ describe('KoboUserController', () => {
});
it('delegates settings reads and writes with current user id', async () => {
settingsService.getSettings.mockResolvedValue({ readingThreshold: 1.2 });
settingsService.updateSettings.mockResolvedValue({ readingThreshold: 2 });
settingsService.getSettings.mockResolvedValue({ convertToKepub: true, forceEnableHyphenation: false, kepubConversionLimitMb: 100 });
settingsService.updateSettings.mockResolvedValue({ convertToKepub: false, forceEnableHyphenation: true, kepubConversionLimitMb: 150 });
await expect(controller.getSettings({ id: 5 } as never)).resolves.toEqual({ readingThreshold: 1.2 });
await expect(controller.updateSettings({ readingThreshold: 2 } as never, { id: 5 } as never)).resolves.toEqual({
readingThreshold: 2,
await expect(controller.getSettings({ id: 5 } as never)).resolves.toEqual({
convertToKepub: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
});
await expect(controller.updateSettings({ convertToKepub: false } as never, { id: 5 } as never)).resolves.toEqual({
convertToKepub: false,
forceEnableHyphenation: true,
kepubConversionLimitMb: 150,
});
expect(settingsService.getSettings).toHaveBeenCalledWith(5);
expect(settingsService.updateSettings).toHaveBeenCalledWith(5, { readingThreshold: 2 });
expect(settingsService.updateSettings).toHaveBeenCalledWith(5, { convertToKepub: false });
});
it('registers auditable metadata for create/rename/remove actions', () => {
+2 -1
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { CommonModule } from '../../common/common.module';
import { UserModule } from '../user/user.module';
import { UserBookStatusModule } from '../user-book-status/user-book-status.module';
import { KoboAuthController } from './kobo-auth.controller';
import { KoboDeviceController } from './kobo-device.controller';
import { KoboSyncController } from './kobo-sync.controller';
@@ -18,7 +19,7 @@ import { KoboSyncService } from './services/kobo-sync.service';
import { KoboThumbnailService } from './services/kobo-thumbnail.service';
@Module({
imports: [CommonModule, UserModule],
imports: [CommonModule, UserModule, UserBookStatusModule],
controllers: [KoboUserController, KoboAuthController, KoboSyncController, KoboDeviceController],
providers: [
KoboTokenGuard,
@@ -1,62 +1,40 @@
import { KoboReadingStateService } from './kobo-reading-state.service';
function makeSelectChain(limitResult: unknown[]) {
const chain = {
from: vi.fn(),
innerJoin: vi.fn(),
leftJoin: vi.fn(),
where: vi.fn(),
orderBy: vi.fn(),
limit: vi.fn(),
};
chain.from.mockReturnValue(chain);
chain.innerJoin.mockReturnValue(chain);
chain.leftJoin.mockReturnValue(chain);
chain.where.mockReturnValue(chain);
chain.orderBy.mockReturnValue(chain);
chain.limit.mockResolvedValue(limitResult);
return chain;
}
function makeInsertChain() {
const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
const values = vi.fn().mockReturnValue({ onConflictDoUpdate });
return {
values,
onConflictDoUpdate,
};
return { values, onConflictDoUpdate };
}
function makeDb() {
return {
query: {
books: { findFirst: vi.fn() },
bookFiles: { findFirst: vi.fn() },
koboReadingStates: { findFirst: vi.fn() },
},
select: vi.fn(),
insert: vi.fn(),
};
}
describe('KoboReadingStateService', () => {
const bookAccessService = {
assertBookAccessible: vi.fn(),
getAccessibleLibraryIds: vi.fn(),
};
const bookAccessService = { assertBookAccessible: vi.fn() };
const userBookStatusService = { autoUpdate: vi.fn() };
function makeService(db: ReturnType<typeof makeDb>) {
return new KoboReadingStateService(db as never, bookAccessService as never, userBookStatusService as never);
}
beforeEach(() => {
vi.clearAllMocks();
bookAccessService.assertBookAccessible.mockResolvedValue(undefined);
bookAccessService.getAccessibleLibraryIds.mockResolvedValue(null);
userBookStatusService.autoUpdate.mockResolvedValue(undefined);
});
it('returns ignored update results when the target book is missing', async () => {
const db = makeDb();
db.query.books.findFirst.mockResolvedValue(null);
const service = new KoboReadingStateService(db as never, bookAccessService as never);
await expect(service.upsertState(7, 99, {}, 2, 90)).resolves.toEqual({
await expect(makeService(db).upsertState(7, 99, {}, 1, 99)).resolves.toEqual({
RequestResult: 'Success',
UpdateResults: [
{
@@ -67,14 +45,14 @@ describe('KoboReadingStateService', () => {
},
],
});
expect(userBookStatusService.autoUpdate).not.toHaveBeenCalled();
});
it('merges reading state sub-objects by LastModified and syncs progress', async () => {
it('merges reading state sub-objects by LastModified before storing raw state', async () => {
const db = makeDb();
const stateInsert = makeInsertChain();
const progressInsert = makeInsertChain();
db.insert.mockReturnValueOnce(stateInsert).mockReturnValueOnce(progressInsert);
db.query.books.findFirst.mockResolvedValueOnce({ id: 12 }).mockResolvedValueOnce({ primaryFileId: 88 });
db.insert.mockReturnValue(stateInsert);
db.query.books.findFirst.mockResolvedValue({ id: 12 });
db.query.koboReadingStates.findFirst
.mockResolvedValueOnce({
currentBookmark: { LastModified: '2026-01-02T00:00:00.000Z', ProgressPercent: 34 },
@@ -90,10 +68,8 @@ describe('KoboReadingStateService', () => {
statistics: { Value: 1 },
statusInfo: { Status: 'Reading' },
});
db.query.bookFiles.findFirst.mockResolvedValue({ id: 88 });
const service = new KoboReadingStateService(db as never, bookAccessService as never);
const result = await service.upsertState(
const result = await makeService(db).upsertState(
3,
12,
{
@@ -101,8 +77,8 @@ describe('KoboReadingStateService', () => {
CurrentBookmark: { LastModified: '2026-01-01T00:00:00.000Z', ProgressPercent: 10 },
Statistics: { LastModified: '2026-01-05T00:00:00.000Z', Value: 2 },
},
2,
90,
1,
99,
);
expect(stateInsert.values).toHaveBeenCalledWith(
@@ -113,13 +89,6 @@ describe('KoboReadingStateService', () => {
statistics: { LastModified: '2026-01-05T00:00:00.000Z', Value: 2 },
}),
);
expect(progressInsert.values).toHaveBeenCalledWith(
expect.objectContaining({
userId: 3,
bookFileId: 88,
percentage: 34,
}),
);
expect(result).toEqual({
EntitlementId: '12',
Created: '2026-01-01T00:00:00.000Z',
@@ -131,20 +100,30 @@ describe('KoboReadingStateService', () => {
});
});
it('syncToReadingProgress normalizes finished progress to 100 and skips below-threshold updates', async () => {
it('calls autoUpdate with merged percent and thresholds when bookmark has ProgressPercent', async () => {
const db = makeDb();
const progressInsert = makeInsertChain();
db.insert.mockReturnValue(progressInsert);
db.query.books.findFirst.mockResolvedValue({ primaryFileId: 45 });
db.query.bookFiles.findFirst.mockResolvedValue({ id: 45 });
const service = new KoboReadingStateService(db as never, bookAccessService as never);
const stateInsert = makeInsertChain();
db.insert.mockReturnValue(stateInsert);
db.query.books.findFirst.mockResolvedValue({ id: 5 });
db.query.koboReadingStates.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ entitlementId: '5', currentBookmark: { ProgressPercent: 42.5 } });
await (service as any).syncToReadingProgress(6, 7, 99, 2, 90);
expect(progressInsert.values).toHaveBeenCalledWith(expect.objectContaining({ percentage: 100 }));
await makeService(db).upsertState(1, 5, { CurrentBookmark: { LastModified: '2026-01-01T00:00:00Z', ProgressPercent: 42.5 } }, 1, 99);
progressInsert.values.mockClear();
await (service as any).syncToReadingProgress(6, 7, 1, 2, 90);
expect(progressInsert.values).not.toHaveBeenCalled();
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(1, 5, 42.5, 1, 99);
});
it('does not call autoUpdate when bookmark has no percent', async () => {
const db = makeDb();
const stateInsert = makeInsertChain();
db.insert.mockReturnValue(stateInsert);
db.query.books.findFirst.mockResolvedValue({ id: 7 });
db.query.koboReadingStates.findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce({ entitlementId: '7' });
await makeService(db).upsertState(2, 7, { Statistics: { LastModified: '2026-01-01T00:00:00Z' } }, 1, 99);
expect(userBookStatusService.autoUpdate).not.toHaveBeenCalled();
});
it('getRawState returns null when absent and maps persisted fields when present', async () => {
@@ -158,10 +137,9 @@ describe('KoboReadingStateService', () => {
statistics: { Value: 1 },
statusInfo: { Status: 'ReadyToRead' },
});
const service = new KoboReadingStateService(db as never, bookAccessService as never);
await expect(service.getRawState(1, 44)).resolves.toBeNull();
await expect(service.getRawState(1, 44)).resolves.toEqual({
await expect(makeService(db).getRawState(1, 44)).resolves.toBeNull();
await expect(makeService(db).getRawState(1, 44)).resolves.toEqual({
EntitlementId: '44',
Created: '2026-01-01T00:00:00.000Z',
LastModified: '2026-01-02T00:00:00.000Z',
@@ -171,39 +149,4 @@ describe('KoboReadingStateService', () => {
StatusInfo: { Status: 'ReadyToRead' },
});
});
it('builds outgoing changed-reading-state payloads for progress push pages', async () => {
const db = makeDb();
const rows = Array.from({ length: 251 }, (_, i) => ({ bookId: i + 1, percentage: i % 3 === 0 ? 95 : 40 }));
const selectChain = makeSelectChain(rows);
db.select.mockReturnValue(selectChain);
const stateInsert = makeInsertChain();
db.insert.mockReturnValue(stateInsert);
const service = new KoboReadingStateService(db as never, bookAccessService as never);
const result = await service.getAndMarkStatesNeedingPush(4, 30, 90);
expect(result.hasMore).toBe(true);
expect(result.items).toHaveLength(250);
expect(result.items[0]).toEqual(
expect.objectContaining({
ChangedReadingState: expect.objectContaining({
ReadingState: expect.objectContaining({
EntitlementId: '1',
CurrentBookmark: expect.objectContaining({ ProgressPercent: 95 }),
}),
}),
}),
);
expect(stateInsert.values).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ userId: 4, bookId: 1 })]));
});
it('returns empty push payload when no updated reading progress rows exist', async () => {
const db = makeDb();
db.select.mockReturnValue(makeSelectChain([]));
const service = new KoboReadingStateService(db as never, bookAccessService as never);
await expect(service.getAndMarkStatesNeedingPush(4, 30, 90)).resolves.toEqual({ items: [], hasMore: false });
expect(db.insert).not.toHaveBeenCalled();
});
});
@@ -1,14 +1,14 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, asc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
import { and, eq, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../../db';
import * as schema from '../../../db/schema';
import { UserBookStatusService } from '../../user-book-status/user-book-status.service';
import { KoboBookAccessService } from './kobo-book-access.service';
type Db = NodePgDatabase<typeof schema>;
type JsonObj = Record<string, unknown>;
const PROGRESS_PUSH_PAGE_SIZE = 250;
function mergeSubObject(incoming: JsonObj | null | undefined, existing: JsonObj | null | undefined): JsonObj | null {
if (!incoming) return existing ?? null;
@@ -24,6 +24,7 @@ export class KoboReadingStateService {
constructor(
@Inject(DB) private readonly db: Db,
private readonly bookAccessService: KoboBookAccessService,
private readonly userBookStatusService: UserBookStatusService,
) {}
async upsertState(userId: number, bookId: number, payload: Record<string, unknown>, readingThreshold: number, finishedThreshold: number) {
@@ -78,7 +79,6 @@ export class KoboReadingStateService {
currentBookmark: mergedBookmark,
statistics: mergedStats,
statusInfo: mergedStatus,
progressSyncedAt: new Date(),
updatedAt: new Date(),
})
.onConflictDoUpdate({
@@ -89,14 +89,13 @@ export class KoboReadingStateService {
currentBookmark: sql`excluded.current_bookmark`,
statistics: sql`excluded.statistics`,
statusInfo: sql`excluded.status_info`,
progressSyncedAt: sql`now()`,
updatedAt: sql`now()`,
},
});
const percent = this.extractPercent(mergedBookmark);
if (percent !== null) {
await this.syncToReadingProgress(userId, bookId, percent, readingThreshold, finishedThreshold);
void this.userBookStatusService.autoUpdate(userId, bookId, percent, readingThreshold, finishedThreshold);
}
return this.getRawState(userId, bookId);
@@ -122,128 +121,10 @@ export class KoboReadingStateService {
};
}
async getAndMarkStatesNeedingPush(
userId: number,
readingThreshold: number,
finishedThreshold: number,
): Promise<{ items: unknown[]; hasMore: boolean }> {
const accessibleLibraryIds = await this.bookAccessService.getAccessibleLibraryIds(userId);
const libraryAccessFilter =
accessibleLibraryIds === null
? undefined
: accessibleLibraryIds.length === 0
? sql`false`
: inArray(schema.books.libraryId, accessibleLibraryIds);
const rows = await this.db
.select({
bookId: schema.books.id,
percentage: schema.readingProgress.percentage,
})
.from(schema.readingProgress)
.innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.readingProgress.bookFileId))
.innerJoin(schema.books, eq(schema.books.primaryFileId, schema.bookFiles.id))
.leftJoin(schema.koboReadingStates, and(eq(schema.koboReadingStates.bookId, schema.books.id), eq(schema.koboReadingStates.userId, userId)))
.where(
and(
eq(schema.readingProgress.userId, userId),
libraryAccessFilter,
or(
isNull(schema.koboReadingStates.progressSyncedAt),
sql`${schema.readingProgress.updatedAt} > ${schema.koboReadingStates.progressSyncedAt}`,
),
),
)
.orderBy(asc(schema.readingProgress.updatedAt), asc(schema.books.id))
.limit(PROGRESS_PUSH_PAGE_SIZE + 1);
if (rows.length === 0) return { items: [], hasMore: false };
const hasMore = rows.length > PROGRESS_PUSH_PAGE_SIZE;
const pageRows = rows.slice(0, PROGRESS_PUSH_PAGE_SIZE);
const now = new Date();
const nowIso = now.toISOString();
const result: unknown[] = [];
const upsertRows: (typeof schema.koboReadingStates.$inferInsert)[] = [];
for (const row of pageRows) {
const id = String(row.bookId);
const pct = row.percentage ?? 0;
const status = pct >= finishedThreshold ? 'Finished' : pct >= readingThreshold ? 'Reading' : 'ReadyToRead';
result.push({
ChangedReadingState: {
ReadingState: {
EntitlementId: id,
Created: nowIso,
LastModified: nowIso,
PriorityTimestamp: nowIso,
StatusInfo: { LastModified: nowIso, Status: status, TimesStartedReading: pct >= readingThreshold ? 1 : 0 },
Statistics: { LastModified: nowIso },
CurrentBookmark: { LastModified: nowIso, ProgressPercent: pct, ContentSourceProgressPercent: pct },
},
},
});
upsertRows.push({
userId,
bookId: row.bookId,
entitlementId: id,
createdAtKobo: nowIso,
lastModifiedKobo: nowIso,
priorityTimestamp: nowIso,
progressSyncedAt: now,
updatedAt: now,
});
}
await this.db
.insert(schema.koboReadingStates)
.values(upsertRows)
.onConflictDoUpdate({
target: [schema.koboReadingStates.userId, schema.koboReadingStates.bookId],
set: {
lastModifiedKobo: nowIso,
priorityTimestamp: nowIso,
progressSyncedAt: sql`now()`,
updatedAt: sql`now()`,
},
});
return { items: result, hasMore };
}
private extractPercent(bookmark: JsonObj | null): number | null {
if (!bookmark) return null;
const pct = bookmark.ProgressPercent ?? bookmark.ContentSourceProgressPercent;
if (typeof pct === 'number') return Math.max(0, Math.min(100, pct));
return null;
}
private async syncToReadingProgress(userId: number, bookId: number, percent: number, readingThreshold: number, finishedThreshold: number) {
if (percent < readingThreshold) return;
const book = await this.db.query.books.findFirst({
where: eq(schema.books.id, bookId),
columns: { primaryFileId: true },
});
if (!book?.primaryFileId) return;
const file = await this.db.query.bookFiles.findFirst({
where: and(eq(schema.bookFiles.bookId, bookId), eq(schema.bookFiles.id, book.primaryFileId)),
});
if (!file) return;
const normalizedPercent = percent >= finishedThreshold ? 100 : percent;
await this.db
.insert(schema.readingProgress)
.values({ bookFileId: file.id, userId, percentage: normalizedPercent, updatedAt: new Date() })
.onConflictDoUpdate({
target: [schema.readingProgress.bookFileId, schema.readingProgress.userId],
set: { percentage: normalizedPercent, updatedAt: sql`now()` },
});
}
}
@@ -1,14 +1,13 @@
import { BadRequestException, InternalServerErrorException } from '@nestjs/common';
import { InternalServerErrorException } from '@nestjs/common';
import { KoboSettingsService } from './kobo-settings.service';
function makeRow(overrides: Partial<Record<string, unknown>> = {}) {
return {
userId: 9,
readingThreshold: 2,
finishedThreshold: 90,
readingThreshold: 1,
finishedThreshold: 99,
convertToKepub: false,
twoWayProgressSync: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
...overrides,
@@ -52,14 +51,13 @@ describe('KoboSettingsService', () => {
it('returns existing settings row when present', async () => {
const db = makeDb();
db.query.koboSyncSettings.findFirst.mockResolvedValue(makeRow({ readingThreshold: 1.5 }));
db.query.koboSyncSettings.findFirst.mockResolvedValue(makeRow({ convertToKepub: true }));
const service = new KoboSettingsService(db as never);
await expect(service.getSettings(9)).resolves.toEqual({
readingThreshold: 1.5,
finishedThreshold: 90,
convertToKepub: false,
twoWayProgressSync: true,
readingThreshold: 1,
finishedThreshold: 99,
convertToKepub: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
});
@@ -68,17 +66,16 @@ describe('KoboSettingsService', () => {
it('creates missing settings row and falls back to re-read when insert returns nothing', async () => {
const db = makeDb();
db.query.koboSyncSettings.findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce(makeRow({ readingThreshold: 3 }));
db.query.koboSyncSettings.findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce(makeRow({ kepubConversionLimitMb: 150 }));
db.insertReturning.mockResolvedValueOnce([]);
const service = new KoboSettingsService(db as never);
await expect(service.getSettings(7)).resolves.toEqual({
readingThreshold: 3,
finishedThreshold: 90,
readingThreshold: 1,
finishedThreshold: 99,
convertToKepub: false,
twoWayProgressSync: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
kepubConversionLimitMb: 150,
});
expect(db.insertValues).toHaveBeenCalledWith({ userId: 7 });
});
@@ -92,49 +89,28 @@ describe('KoboSettingsService', () => {
await expect(service.getSettings(7)).rejects.toThrow(InternalServerErrorException);
});
it('rejects threshold updates where reading threshold is not below finished threshold', async () => {
const db = makeDb();
const service = new KoboSettingsService(db as never);
vi.spyOn(service, 'getSettings').mockResolvedValue({
readingThreshold: 2,
finishedThreshold: 90,
convertToKepub: false,
twoWayProgressSync: true,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
});
await expect(service.updateSettings(9, { readingThreshold: 95 })).rejects.toThrow(BadRequestException);
expect(db.update).not.toHaveBeenCalled();
});
it('updates and returns merged settings fields', async () => {
const db = makeDb();
const service = new KoboSettingsService(db as never);
vi.spyOn(service, 'getSettings').mockResolvedValue({
readingThreshold: 2,
finishedThreshold: 90,
readingThreshold: 1,
finishedThreshold: 99,
convertToKepub: false,
twoWayProgressSync: false,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
});
db.updateReturning.mockResolvedValue([
makeRow({
readingThreshold: 3,
finishedThreshold: 95,
convertToKepub: true,
twoWayProgressSync: true,
forceEnableHyphenation: true,
kepubConversionLimitMb: 200,
}),
]);
await expect(service.updateSettings(9, { readingThreshold: 3, finishedThreshold: 95, convertToKepub: true })).resolves.toEqual({
readingThreshold: 3,
finishedThreshold: 95,
await expect(service.updateSettings(9, { convertToKepub: true })).resolves.toEqual({
readingThreshold: 1,
finishedThreshold: 99,
convertToKepub: true,
twoWayProgressSync: true,
forceEnableHyphenation: true,
kepubConversionLimitMb: 200,
});
@@ -144,15 +120,33 @@ describe('KoboSettingsService', () => {
const db = makeDb();
const service = new KoboSettingsService(db as never);
vi.spyOn(service, 'getSettings').mockResolvedValue({
readingThreshold: 2,
finishedThreshold: 90,
readingThreshold: 1,
finishedThreshold: 99,
convertToKepub: false,
twoWayProgressSync: false,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
});
db.updateReturning.mockResolvedValue([]);
await expect(service.updateSettings(9, { finishedThreshold: 91 })).rejects.toThrow(InternalServerErrorException);
await expect(service.updateSettings(9, { kepubConversionLimitMb: 101 })).rejects.toThrow(InternalServerErrorException);
});
it('throws BadRequestException when readingThreshold >= finishedThreshold', async () => {
const db = makeDb();
const service = new KoboSettingsService(db as never);
vi.spyOn(service, 'getSettings').mockResolvedValue({
readingThreshold: 1,
finishedThreshold: 99,
convertToKepub: false,
forceEnableHyphenation: false,
kepubConversionLimitMb: 100,
});
await expect(service.updateSettings(9, { readingThreshold: 99, finishedThreshold: 99 })).rejects.toThrow(
'Reading threshold must be less than finished threshold',
);
await expect(service.updateSettings(9, { readingThreshold: 99, finishedThreshold: 80 })).rejects.toThrow(
'Reading threshold must be less than finished threshold',
);
});
});
@@ -11,7 +11,6 @@ export interface KoboSettings {
readingThreshold: number;
finishedThreshold: number;
convertToKepub: boolean;
twoWayProgressSync: boolean;
forceEnableHyphenation: boolean;
kepubConversionLimitMb: number;
}
@@ -35,7 +34,6 @@ export class KoboSettingsService {
readingThreshold: row.readingThreshold,
finishedThreshold: row.finishedThreshold,
convertToKepub: row.convertToKepub,
twoWayProgressSync: row.twoWayProgressSync,
forceEnableHyphenation: row.forceEnableHyphenation,
kepubConversionLimitMb: row.kepubConversionLimitMb,
};
@@ -57,7 +55,6 @@ export class KoboSettingsService {
readingThreshold: newReading,
finishedThreshold: newFinished,
convertToKepub: patch.convertToKepub ?? current.convertToKepub,
twoWayProgressSync: patch.twoWayProgressSync ?? current.twoWayProgressSync,
forceEnableHyphenation: patch.forceEnableHyphenation ?? current.forceEnableHyphenation,
kepubConversionLimitMb: patch.kepubConversionLimitMb ?? current.kepubConversionLimitMb,
updatedAt: sql`now()`,
@@ -73,7 +70,6 @@ export class KoboSettingsService {
readingThreshold: updated.readingThreshold,
finishedThreshold: updated.finishedThreshold,
convertToKepub: updated.convertToKepub,
twoWayProgressSync: updated.twoWayProgressSync,
forceEnableHyphenation: updated.forceEnableHyphenation,
kepubConversionLimitMb: updated.kepubConversionLimitMb,
};
@@ -106,14 +106,12 @@ describe('KoboSyncService', () => {
getAccessibleLibraryIds: vi.fn(),
};
const readingStateService = {
getAndMarkStatesNeedingPush: vi.fn(),
getRawState: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
bookAccessService.getAccessibleLibraryIds.mockResolvedValue(null);
readingStateService.getAndMarkStatesNeedingPush.mockResolvedValue({ items: [], hasMore: false });
readingStateService.getRawState.mockResolvedValue(null);
});
@@ -132,24 +130,14 @@ describe('KoboSyncService', () => {
db.query.koboLibrarySnapshots.findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 9, userId: 5 });
await expect(
service.getDelta(5, 'device-token', 'https://reader.example.com', null, {
readingThreshold: 2,
finishedThreshold: 90,
twoWayProgressSync: false,
}),
).resolves.toEqual({
await expect(service.getDelta(5, 'device-token', 'https://reader.example.com')).resolves.toEqual({
entitlements: [{ ChangedTag: {} }],
hasMore: false,
syncToken: 'PX.token',
});
expect(createSpy).toHaveBeenCalledWith(5, eligible);
await service.getDelta(5, 'device-token', 'https://reader.example.com', null, {
readingThreshold: 2,
finishedThreshold: 90,
twoWayProgressSync: false,
});
await service.getDelta(5, 'device-token', 'https://reader.example.com');
expect(reconcileSpy).toHaveBeenCalledWith(9, eligible);
expect(pageSpy).toHaveBeenCalledTimes(2);
});
@@ -230,43 +218,27 @@ describe('KoboSyncService', () => {
db.query.koboLibrarySnapshots.findFirst.mockResolvedValue(null);
const service = new KoboSyncService(db as never, bookAccessService as never, readingStateService as never);
await expect(
(service as any).getPageFromSnapshot(
7,
'tok',
'https://base',
{ readingThreshold: 2, finishedThreshold: 90, twoWayProgressSync: false },
new Set(),
),
).resolves.toEqual({
await expect((service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set())).resolves.toEqual({
entitlements: [],
hasMore: false,
syncToken: expect.stringMatching(/^PX\./),
});
});
it('getPageFromSnapshot returns progress + tags on final page when no pending rows', async () => {
it('getPageFromSnapshot returns tags on final page when no pending rows', async () => {
const db = makeDb({ select: [[]] });
db.query.koboLibrarySnapshots.findFirst.mockResolvedValue({ id: 1, userId: 7 });
readingStateService.getAndMarkStatesNeedingPush.mockResolvedValue({ items: [{ ChangedReadingState: {} }], hasMore: false });
const service = new KoboSyncService(db as never, bookAccessService as never, readingStateService as never);
vi.spyOn(service as any, 'buildTagItems').mockResolvedValue([{ ChangedTag: {} }]);
const result = await (service as any).getPageFromSnapshot(
7,
'tok',
'https://base',
{ readingThreshold: 2, finishedThreshold: 90, twoWayProgressSync: true },
new Set([1]),
);
const result = await (service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set([1]));
expect(result).toEqual({
entitlements: [{ ChangedReadingState: {} }, { ChangedTag: {} }],
entitlements: [{ ChangedTag: {} }],
hasMore: false,
syncToken: expect.stringMatching(/^PX\./),
});
});
it('getPageFromSnapshot returns page entitlements for removed/new/changed books', async () => {
const db = makeDb({
select: [
@@ -287,13 +259,7 @@ describe('KoboSyncService', () => {
);
readingStateService.getRawState.mockResolvedValue(null);
const result = await (service as any).getPageFromSnapshot(
7,
'tok',
'https://base',
{ readingThreshold: 2, finishedThreshold: 90, twoWayProgressSync: false },
new Set([1, 2, 3]),
);
const result = await (service as any).getPageFromSnapshot(7, 'tok', 'https://base', new Set([1, 2, 3]));
expect(result.hasMore).toBe(false);
expect(result.entitlements).toHaveLength(3);
@@ -40,12 +40,6 @@ export interface KoboBookEntry {
updatedAt: Date;
}
export interface SyncSettings {
readingThreshold: number;
finishedThreshold: number;
twoWayProgressSync: boolean;
}
function encodeSyncToken(snapshotId: number): string {
return TOKEN_PREFIX + Buffer.from(JSON.stringify({ snapshotId })).toString('base64');
}
@@ -58,13 +52,7 @@ export class KoboSyncService {
private readonly readingStateService: KoboReadingStateService,
) {}
async getDelta(
userId: number,
deviceToken: string,
baseUrl: string,
syncToken: string | null,
settings: SyncSettings,
): Promise<{ entitlements: unknown[]; hasMore: boolean; syncToken: string }> {
async getDelta(userId: number, deviceToken: string, baseUrl: string): Promise<{ entitlements: unknown[]; hasMore: boolean; syncToken: string }> {
const snapshot = await this.db.query.koboLibrarySnapshots.findFirst({
where: eq(schema.koboLibrarySnapshots.userId, userId),
});
@@ -77,7 +65,7 @@ export class KoboSyncService {
await this.reconcileSnapshot(snapshot.id, eligibleSnapshotRows);
}
return this.getPageFromSnapshot(userId, deviceToken, baseUrl, settings, new Set(eligibleSnapshotRows.map((row) => row.bookId)));
return this.getPageFromSnapshot(userId, deviceToken, baseUrl, new Set(eligibleSnapshotRows.map((row) => row.bookId)));
}
async getBookMetadata(userId: number, bookId: number, deviceToken: string, baseUrl: string): Promise<unknown[]> {
@@ -230,7 +218,6 @@ export class KoboSyncService {
userId: number,
deviceToken: string,
baseUrl: string,
settings: SyncSettings,
eligibleIds: Set<number>,
): Promise<{ entitlements: unknown[]; hasMore: boolean; syncToken: string }> {
const snapshot = await this.db.query.koboLibrarySnapshots.findFirst({
@@ -252,20 +239,8 @@ export class KoboSyncService {
const page = pending.slice(0, SYNC_PAGE_SIZE);
if (page.length === 0) {
const endItems: unknown[] = [];
if (settings.twoWayProgressSync) {
const pushed = await this.readingStateService.getAndMarkStatesNeedingPush(userId, settings.readingThreshold, settings.finishedThreshold);
endItems.push(...pushed.items);
if (pushed.hasMore) {
return { entitlements: endItems, hasMore: true, syncToken };
}
}
const tagItems = await this.buildTagItems(userId, eligibleIds);
endItems.push(...tagItems);
return { entitlements: endItems, hasMore: false, syncToken };
return { entitlements: tagItems, hasMore: false, syncToken };
}
const pageIds = page.map((r) => r.bookId);