From 31b34606585b6ea2be5b15e7ff633b5bdf3a3249 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Mon, 11 May 2026 21:23:11 +0000 Subject: [PATCH] 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 --- .../kobo/composables/useKoboSettings.ts | 1 - client/src/features/reader/ReaderView.vue | 2 +- .../src/features/reader/cbz/CbzReaderView.vue | 7 +- .../composables/__tests__/useFoliate.spec.ts | 143 + .../reader/epub/composables/useFoliate.ts | 12 +- .../src/features/reader/pdf/PdfReaderView.vue | 7 +- client/src/features/settings/KoboSettings.vue | 19 +- client/vite.config.ts | 13 +- packages/types/src/book.ts | 1 - packages/types/src/kobo.ts | 2 - .../0001_remove_two_way_progress_sync.sql | 2 + .../src/db/migrations/meta/0001_snapshot.json | 8994 +++++++++++++++++ server/src/db/migrations/meta/_journal.json | 7 + server/src/db/schema/kobo.ts | 2 - server/src/modules/book/book.repository.ts | 1 - server/src/modules/book/book.service.test.ts | 1 - server/src/modules/book/book.service.ts | 1 - .../kobo/dto/kobo-dto-validation.test.ts | 24 +- .../modules/kobo/dto/update-settings.dto.ts | 6 +- .../kobo/kobo-device.controller.test.ts | 27 + .../modules/kobo/kobo-sync.controller.test.ts | 118 +- .../src/modules/kobo/kobo-sync.controller.ts | 25 +- .../modules/kobo/kobo-user.controller.test.ts | 18 +- server/src/modules/kobo/kobo.module.ts | 3 +- .../kobo-reading-state.service.test.ts | 135 +- .../services/kobo-reading-state.service.ts | 127 +- .../services/kobo-settings.service.test.ts | 82 +- .../kobo/services/kobo-settings.service.ts | 4 - .../kobo/services/kobo-sync.service.test.ts | 48 +- .../kobo/services/kobo-sync.service.ts | 31 +- 30 files changed, 9445 insertions(+), 418 deletions(-) create mode 100644 client/src/features/reader/epub/composables/__tests__/useFoliate.spec.ts create mode 100644 server/src/db/migrations/0001_remove_two_way_progress_sync.sql create mode 100644 server/src/db/migrations/meta/0001_snapshot.json diff --git a/client/src/features/kobo/composables/useKoboSettings.ts b/client/src/features/kobo/composables/useKoboSettings.ts index d8fa416c5..8f1e963e6 100644 --- a/client/src/features/kobo/composables/useKoboSettings.ts +++ b/client/src/features/kobo/composables/useKoboSettings.ts @@ -6,7 +6,6 @@ const settings = ref({ readingThreshold: 1, finishedThreshold: 99, convertToKepub: true, - twoWayProgressSync: false, forceEnableHyphenation: false, kepubConversionLimitMb: 100, }) diff --git a/client/src/features/reader/ReaderView.vue b/client/src/features/reader/ReaderView.vue index 28f837f74..706774d8b 100644 --- a/client/src/features/reader/ReaderView.vue +++ b/client/src/features/reader/ReaderView.vue @@ -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) diff --git a/client/src/features/reader/cbz/CbzReaderView.vue b/client/src/features/reader/cbz/CbzReaderView.vue index 2e1b1bfae..57688c931 100644 --- a/client/src/features/reader/cbz/CbzReaderView.vue +++ b/client/src/features/reader/cbz/CbzReaderView.vue @@ -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) diff --git a/client/src/features/reader/epub/composables/__tests__/useFoliate.spec.ts b/client/src/features/reader/epub/composables/__tests__/useFoliate.spec.ts new file mode 100644 index 000000000..7a435dc10 --- /dev/null +++ b/client/src/features/reader/epub/composables/__tests__/useFoliate.spec.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/api', () => ({ + api: vi.fn<() => Promise>(), + 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 + let mockGoToFraction: ReturnType + + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + + mockGoTo = vi.fn<() => Promise>().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>().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>().mockResolvedValue({}), + } as unknown as Response) + ;(window as { makeStreamingBook?: unknown }).makeStreamingBook = vi.fn<() => Promise>().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() + }) +}) diff --git a/client/src/features/reader/epub/composables/useFoliate.ts b/client/src/features/reader/epub/composables/useFoliate.ts index a2866fa2a..2fd987c4c 100644 --- a/client/src/features/reader/epub/composables/useFoliate.ts +++ b/client/src/features/reader/epub/composables/useFoliate.ts @@ -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 goTo: (target: string | number) => Promise + 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), diff --git a/client/src/features/reader/pdf/PdfReaderView.vue b/client/src/features/reader/pdf/PdfReaderView.vue index fce96305a..c9f2524a0 100644 --- a/client/src/features/reader/pdf/PdfReaderView.vue +++ b/client/src/features/reader/pdf/PdfReaderView.vue @@ -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) diff --git a/client/src/features/settings/KoboSettings.vue b/client/src/features/settings/KoboSettings.vue index 0c333c875..3afbbaab0 100644 --- a/client/src/features/settings/KoboSettings.vue +++ b/client/src/features/settings/KoboSettings.vue @@ -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() {