mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -6,7 +6,6 @@ const settings = ref<KoboSyncSettings>({
|
||||
readingThreshold: 1,
|
||||
finishedThreshold: 99,
|
||||
convertToKepub: true,
|
||||
twoWayProgressSync: false,
|
||||
forceEnableHyphenation: false,
|
||||
kepubConversionLimitMb: 100,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -120,7 +120,6 @@ export type BookKoboReadingState = {
|
||||
createdAtKobo: string | null;
|
||||
lastModifiedKobo: string | null;
|
||||
priorityTimestamp: string | null;
|
||||
progressSyncedAt: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
Reference in New Issue
Block a user