Skip to content

feat(kobo/koreader): two way page level sync between kobo/koreader and bookorbit#250

Merged
neonsolstice merged 3 commits into
mainfrom
BO-249-two-way-page-level-progress-sync
Jun 5, 2026
Merged

feat(kobo/koreader): two way page level sync between kobo/koreader and bookorbit#250
neonsolstice merged 3 commits into
mainfrom
BO-249-two-way-page-level-progress-sync

Conversation

@neonsolstice

@neonsolstice neonsolstice commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

Closes #249

Summary by CodeRabbit

  • New Features

    • Two-way progress sync between BookOrbit and Kobo devices
    • Server-side KEPUB conversion with cached converted files
  • Improvements

    • Richer progress/location tracking (Kobo/KOReader-compatible metadata)
    • Kobo settings: new “Two-way progress sync” toggle and adjusted KEPUB behavior
    • UI: KO-R progress label and warning-icon tooltips for Kobo anomaly statuses

Implement two-way page-level reading progress synchronization between Kobo devices, KOReader, and BookOrbit. This ensures readers can switch between physical devices and the web app without losing their exact page-level reading position.

Closes #249
Restore the main branch thumbnail-click setting after the Kobo branch accidentally dropped it during the rebase. Keep the Kobo architecture allowlist aligned with the new identity service.
@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR extends BookOrbit's reader ecosystem with two-way progress synchronization, KOReader XPointer support, Kobo identity mapping, KEPUB conversion caching, enriched progress metadata, and server-side sync/orchestration across controllers, services, repository, schema, and client UI/components.

Changes

Two-Way Progress Sync & Koreader Progress

Layer / File(s) Summary
Database schema & migration
server/src/db/migrations/0014_kobo-two-way-progress-sync.sql, server/src/db/schema/kobo.ts, server/src/db/schema/reader.ts, server/src/db/migrations/meta/_journal.json
Adds kobo_book_entitlements, delivery_hash, new reading_progress Kobo/Koreader columns, default/constraint changes, and migration journal entry.
Client Foliate view: XPointer helpers & relocate enrichment
client/public/assets/foliate/view.js, client/src/features/reader/epub/__tests__/foliate-navigation.spec.ts
Exports getKoboSpanValue, getKoreaderDocFragmentIndex, getKoreaderProgress; onRelocate adds source, Kobo metadata, and koreaderProgress.
Client progress state & UI
client/src/features/reader/shared/composables/useReaderProgress.ts, client/src/features/reader/shared/composables/__tests__/useReaderProgress.spec.ts, client/src/features/book/components/detail/tabs/DetailsTab.vue, client/src/features/settings/KoboSettings.vue
Tracks and persists new Kobo/Koreader refs, updates anomaly tooltip UI, adds twoWayProgressSync toggle and local state wiring.
DTOs & API shapes
server/src/modules/book/dto/save-progress.dto.ts, packages/types/src/kobo.ts, client/src/features/reader/epub/composables/useFoliate.ts
Adds Kobo/Koreader fields to SaveProgressDto and settings types; RelocateDetail expanded to include new metadata.
Repository: progress persistence & sync-from-progress
server/src/modules/book/book.repository.ts, server/src/modules/book/book.repository.test.ts
findFileById/findProgressByBook/selects extended; upsertProgress accepts and persists Kobo/Koreader fields; syncKoboReadingStateFromProgress normalizes, compares with epsilon, upserts koboReadingStates, marks snapshots unsynced.
Book service & controller
server/src/modules/book/book.service.ts, server/src/modules/book/book.controller.ts, server/src/modules/book/book.service.test.ts
getBookProgress/saveProgress propagate Kobo/Koreader fields; saveProgress forwards fields to repo and conditional two-way sync; controller fallback response shape expanded.
Kobo identity service
server/src/modules/kobo/services/kobo-book-identity.service.ts, server/src/modules/kobo/services/kobo-book-identity.service.test.ts
New KoboBookIdentityService ensures/returns entitlement and cover mappings, resolves ids from UUID or legacy formats, and marks legacy-removal completion.
Kobo reading-state & two-way sync orchestration
server/src/modules/kobo/services/kobo-reading-state.service.ts, tests
upsertState gains twoWayProgressSync param, resolves entitlement, conditionally mirrors Kobo percent to internal readingProgress (timestamp/epsilon checks), and marks snapshots unsynced.
Kobo sync service: identity & delivery reconciliation
server/src/modules/kobo/services/kobo-sync.service.ts, tests
Uses identity-derived entitlement/cover ids, computes deliveryHash/format (KEPUB/EPUB3/PDF), updates reconciliation SQL to track delivery_hash, and adjusts tag/metadata generation.
KEPUB conversion & download delegation
server/src/modules/kobo/services/kepub-conversion.service.ts, server/src/modules/kobo/services/kobo-download.service.ts, tests
New KepubConversionService computes cached .kepub paths and runs kepubify; KoboDownloadService delegates path resolution, logs sanitized errors, and falls back to EPUB streaming.
Controllers & module wiring
server/src/modules/kobo/kobo-device.controller.ts, server/src/modules/kobo/kobo-sync.controller.ts, server/src/modules/kobo/kobo-auth.controller.ts, server/src/modules/kobo/kobo.module.ts
Controllers resolve entitlement/cover ids via KoboBookIdentityService (proxying when unresolved), add tag-delete endpoint, forward twoWayProgressSync into upsertState, and accept untyped auth bodies. Module registers new services.
EPUB reader conversion integration
server/src/modules/reader/epub/epub.service.ts, tests
EpubService resolves per-user converted KEPUB path using KoboSettings + KepubConversionService, enforces conversion size limits, hyphenation flag, and falls back on errors.
KOReader handling & repository clearing
server/src/modules/koreader/koreader.service.ts, server/src/modules/koreader/koreader.repository.ts, tests
Koreader service prefers stored koreaderProgress; repository clears koreaderProgress on KOReader percentage sync.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🐰 I nibble code and rhyme,
Kobo crumbs on my paws,
XPointer hops, progress binds,
Caches hum, identities align,
Readers leap between their pages—hooray!

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

@github-actions github-actions Bot added server Changes affecting server-side code, APIs, or backend behavior. client Changes affecting the client application or frontend behavior. packages labels Jun 5, 2026
@neonsolstice neonsolstice changed the title BO-249-two-way-page-level-progress-sync feat(kobo): two way page level sync Jun 5, 2026
Comment thread server/src/modules/book/book.repository.ts Fixed
Comment thread server/src/modules/book/book.repository.ts Fixed
@neonsolstice neonsolstice reopened this Jun 5, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (3)
server/src/modules/kobo/services/kepub-conversion.service.ts (1)

28-28: 💤 Low value

Consider adding a fallback or explicit validation for the config value.

The non-null assertion assumes storage.appDataPath is always present. If this config key is ever missing, the service will fail at runtime when constructing the cache path.

♻️ Suggested improvement
-    this.kepubCachePath = join(this.config.get<string>('storage.appDataPath')!, '.kepub-cache');
+    const appDataPath = this.config.getOrThrow<string>('storage.appDataPath');
+    this.kepubCachePath = join(appDataPath, '.kepub-cache');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/modules/kobo/services/kepub-conversion.service.ts` at line 28, The
assignment to this.kepubCachePath uses a non-null assertion on
this.config.get('storage.appDataPath') which can throw at runtime; update the
constructor (or the KepubConversionService initialization) to validate and
provide a safe fallback: retrieve the value from config into a local variable
(e.g. const appDataPath = this.config.get<string>('storage.appDataPath')), if
it's missing either throw a clear Error mentioning 'storage.appDataPath' or set
a sensible default (e.g. process.cwd() or configured storage dir) before calling
join to build this.kepubCachePath, ensuring the code uses the validated
appDataPath rather than the non-null assertion.
server/src/modules/reader/epub/epub.service.test.ts (1)

361-382: ⚡ Quick win

Add regression tests for fallback branches introduced by KEPUB routing.

Please add coverage for: (1) koboSettingsService.getSettings rejection falling back to original EPUB, and (2) kepubConversionService.getKepubPath rejection fallback. These are key resilience paths in the new flow.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/modules/reader/epub/epub.service.test.ts` around lines 361 - 382,
Add two regression tests around service.getBookInfo to exercise the failure
fallbacks: (1) simulate koboSettingsService.getSettings rejecting and assert
that getBookInfo falls back to the original EPUB path (verify
bookReadService.findFileById returned path is used and that mockStat and
mockOpenFile were called with '/books/alt.epub' and that
kepubConversionService.getKepubPath was not relied upon), and (2) simulate
kepubConversionService.getKepubPath rejecting (while getSettings resolves with
convertToKepub true) and assert getBookInfo falls back to the original EPUB path
(verify kepubConversionService.getKepubPath was called with the expected args,
then mockStat/mockOpenFile were called with '/books/alt.epub'). Ensure both
tests mock bookReadService.findFileById like the existing test and use the same
user and makeEpubArchive setup to mirror the success case.
server/src/modules/kobo/services/kobo-book-identity.service.ts (1)

25-28: ⚡ Quick win

Replace non-null assertion with explicit error.

The ! assertion on line 27 assumes bookId will always be present in the returned map. While this should be true in normal operation, if the identity is missing (due to a race condition or database issue), the runtime error would be unclear. Throw an explicit error instead.

🛡️ Proposed fix
 async ensureForBook(userId: number, bookId: number, needsLegacyNumericRemoval: boolean): Promise<KoboBookIdentity> {
   const identities = await this.ensureForBooks(userId, [bookId], needsLegacyNumericRemoval);
-  return identities.get(bookId)!;
+  const identity = identities.get(bookId);
+  if (!identity) {
+    throw new Error(`Failed to ensure identity for book ${bookId}`);
+  }
+  return identity;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/modules/kobo/services/kobo-book-identity.service.ts` around lines
25 - 28, The ensureForBook method currently uses a non-null assertion on
identities.get(bookId) which can hide missing-identity cases; update
ensureForBook to check whether the map returned by ensureForBooks contains
bookId (e.g., using identities.has(bookId) or checking the value !== undefined)
and if missing throw a clear, explicit error (include identifiers like bookId
and userId in the message) instead of using the `!` operator so callers get a
descriptive failure; keep the method signature async ensureForBook(userId:
number, bookId: number, needsLegacyNumericRemoval: boolean):
Promise<KoboBookIdentity>.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@client/src/features/reader/epub/composables/useFoliate.ts`:
- Around line 187-190: The current logic sets didNavigate = true even when
view.goToFraction is absent (because the optional call is a no-op), preventing
the subsequent fallback goTo(0); change the guard so you only call
view.goToFraction and set didNavigate when the method actually exists and was
invoked: check that view.goToFraction is truthy before calling it (and only then
set didNavigate = true) while keeping the existing fallbackFraction checks;
update the block that references didNavigate, fallbackFraction and
view.goToFraction accordingly so the final goTo(0) can still run when
goToFraction is not available.

In `@server/src/modules/book/book.repository.ts`:
- Around line 1140-1153: The current code replaces the entire currentBookmark
object, which drops any existing device-authored keys (e.g., ChapterProgress);
instead, merge updates into the existing bookmark object rather than rebuilding
it from scratch: locate where currentBookmark is created and change the logic to
start from the existing bookmark (or a shallow clone of it) and then
set/overwrite only LastModified, ProgressPercent, Location and
ContentSourceProgressPercent (using the normalizedKobo... variables) so other
keys are preserved (e.g., via Object.assign or object spread into
currentBookmark before assigning Location and ContentSourceProgressPercent).
- Around line 1279-1281: The current logic treats a null
koboContentSourceProgressPercent as "no change", which prevents clearing a
stored source-level percent; update the condition in the comparison block (where
koboContentSourceProgressPercent,
extractKoboContentSourceProgressPercent(bookmark), and PROGRESS_EPSILON are
used) so that when koboContentSourceProgressPercent === null it only returns
true if the existingSourcePercent is also null (otherwise return false to allow
clearing); keep the existing epsilon-based comparison for the non-null incoming
percent path.

In `@server/src/modules/kobo/kobo-auth.controller.ts`:
- Around line 19-25: authDevice and authRefresh currently assume `@Body`() is
non-null and will throw if the request JSON is null; fix both by normalizing the
incoming body to an empty object before reading fields (e.g., at the top of
authDevice and authRefresh assign a local payload = body ?? {} and use
payload.UserKey / payload.RefreshToken instead of body.*), ensuring you still
call optionalString(payload.UserKey) and optionalString(payload.RefreshToken) so
null bodies don't cause a 500; update types if needed to accept KoboAuthBody |
null.

In `@server/src/modules/kobo/services/kepub-conversion.service.ts`:
- Around line 44-47: The execFileAsync call currently runs without a timeout and
can hang; update the execFileAsync(binaryPath, args) invocation to pass an
options object with a sensible timeout (e.g., { timeout: 30_000 }) so the child
process is killed if it exceeds that duration, and catch the timeout error to
throw or log a clear error; change the call in kepub-conversion.service.ts where
binaryPath/args are used and ensure any thrown timeout error from execFileAsync
is handled (e.g., wrap the await execFileAsync(...) in try/catch and check for
the timeout condition to return a controlled failure).

In `@server/src/modules/reader/epub/epub.service.ts`:
- Around line 353-355: Wrap the call to koboSettingsService.getSettings(userId)
in a try/catch inside the EPUB handling flow so that any exception during
settings lookup is caught and treated as "no settings" — i.e., if an error
occurs, fall back to returning file.absolutePath; otherwise preserve the
existing logic that checks settings.twoWayProgressSync and
settings.convertToKepub to decide whether to continue to KEPUB conversion.
Ensure you reference the existing symbols koboSettingsService.getSettings,
settings, twoWayProgressSync, convertToKepub, and return file.absolutePath so
the change is limited to adding error handling around the settings lookup.

---

Nitpick comments:
In `@server/src/modules/kobo/services/kepub-conversion.service.ts`:
- Line 28: The assignment to this.kepubCachePath uses a non-null assertion on
this.config.get('storage.appDataPath') which can throw at runtime; update the
constructor (or the KepubConversionService initialization) to validate and
provide a safe fallback: retrieve the value from config into a local variable
(e.g. const appDataPath = this.config.get<string>('storage.appDataPath')), if
it's missing either throw a clear Error mentioning 'storage.appDataPath' or set
a sensible default (e.g. process.cwd() or configured storage dir) before calling
join to build this.kepubCachePath, ensuring the code uses the validated
appDataPath rather than the non-null assertion.

In `@server/src/modules/kobo/services/kobo-book-identity.service.ts`:
- Around line 25-28: The ensureForBook method currently uses a non-null
assertion on identities.get(bookId) which can hide missing-identity cases;
update ensureForBook to check whether the map returned by ensureForBooks
contains bookId (e.g., using identities.has(bookId) or checking the value !==
undefined) and if missing throw a clear, explicit error (include identifiers
like bookId and userId in the message) instead of using the `!` operator so
callers get a descriptive failure; keep the method signature async
ensureForBook(userId: number, bookId: number, needsLegacyNumericRemoval:
boolean): Promise<KoboBookIdentity>.

In `@server/src/modules/reader/epub/epub.service.test.ts`:
- Around line 361-382: Add two regression tests around service.getBookInfo to
exercise the failure fallbacks: (1) simulate koboSettingsService.getSettings
rejecting and assert that getBookInfo falls back to the original EPUB path
(verify bookReadService.findFileById returned path is used and that mockStat and
mockOpenFile were called with '/books/alt.epub' and that
kepubConversionService.getKepubPath was not relied upon), and (2) simulate
kepubConversionService.getKepubPath rejecting (while getSettings resolves with
convertToKepub true) and assert getBookInfo falls back to the original EPUB path
(verify kepubConversionService.getKepubPath was called with the expected args,
then mockStat/mockOpenFile were called with '/books/alt.epub'). Ensure both
tests mock bookReadService.findFileById like the existing test and use the same
user and makeEpubArchive setup to mirror the success case.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 808bdc9c-4405-4fe2-b5ea-db7930f9b8f2

📥 Commits

Reviewing files that changed from the base of the PR and between 283999e and c6ecfcb.

📒 Files selected for processing (58)
  • client/public/assets/foliate/view.js
  • client/src/features/book/components/detail/tabs/DetailsTab.vue
  • client/src/features/kobo/composables/useKoboSettings.ts
  • client/src/features/reader/epub/__tests__/foliate-navigation.spec.ts
  • client/src/features/reader/epub/composables/__tests__/useFoliate.spec.ts
  • client/src/features/reader/epub/composables/useFoliate.ts
  • client/src/features/reader/shared/composables/__tests__/useReaderProgress.spec.ts
  • client/src/features/reader/shared/composables/useReaderProgress.ts
  • client/src/features/settings/KoboSettings.vue
  • client/src/features/settings/KoreaderSettings.vue
  • packages/types/src/kobo.ts
  • server/src/db/migrations/0014_kobo-two-way-progress-sync.sql
  • server/src/db/migrations/meta/0014_snapshot.json
  • server/src/db/migrations/meta/_journal.json
  • server/src/db/schema/kobo.ts
  • server/src/db/schema/reader.ts
  • server/src/modules/architecture/architecture-boundaries.test.ts
  • server/src/modules/book/book.controller.test.ts
  • server/src/modules/book/book.controller.ts
  • server/src/modules/book/book.repository.test.ts
  • server/src/modules/book/book.repository.ts
  • server/src/modules/book/book.service.test.ts
  • server/src/modules/book/book.service.ts
  • server/src/modules/book/dto/book-dto-validation.test.ts
  • server/src/modules/book/dto/save-progress.dto.ts
  • server/src/modules/kobo/dto/kobo-auth-device-body.dto.ts
  • server/src/modules/kobo/dto/kobo-auth-refresh-body.dto.ts
  • server/src/modules/kobo/dto/kobo-dto-validation.test.ts
  • server/src/modules/kobo/dto/update-settings.dto.ts
  • server/src/modules/kobo/kobo-auth.controller.test.ts
  • server/src/modules/kobo/kobo-auth.controller.ts
  • server/src/modules/kobo/kobo-device.controller.test.ts
  • server/src/modules/kobo/kobo-device.controller.ts
  • server/src/modules/kobo/kobo-sync.controller.test.ts
  • server/src/modules/kobo/kobo-sync.controller.ts
  • server/src/modules/kobo/kobo-user.controller.test.ts
  • server/src/modules/kobo/kobo.module.test.ts
  • server/src/modules/kobo/kobo.module.ts
  • server/src/modules/kobo/services/kepub-conversion.service.test.ts
  • server/src/modules/kobo/services/kepub-conversion.service.ts
  • server/src/modules/kobo/services/kobo-book-identity.service.test.ts
  • server/src/modules/kobo/services/kobo-book-identity.service.ts
  • server/src/modules/kobo/services/kobo-download.service.test.ts
  • server/src/modules/kobo/services/kobo-download.service.ts
  • server/src/modules/kobo/services/kobo-reading-state.service.test.ts
  • server/src/modules/kobo/services/kobo-reading-state.service.ts
  • server/src/modules/kobo/services/kobo-settings.service.test.ts
  • server/src/modules/kobo/services/kobo-settings.service.ts
  • server/src/modules/kobo/services/kobo-sync.service.test.ts
  • server/src/modules/kobo/services/kobo-sync.service.ts
  • server/src/modules/koreader/koreader.repository.test.ts
  • server/src/modules/koreader/koreader.repository.ts
  • server/src/modules/koreader/koreader.service.test.ts
  • server/src/modules/koreader/koreader.service.ts
  • server/src/modules/reader/epub/epub.module.test.ts
  • server/src/modules/reader/epub/epub.module.ts
  • server/src/modules/reader/epub/epub.service.test.ts
  • server/src/modules/reader/epub/epub.service.ts
💤 Files with no reviewable changes (2)
  • server/src/modules/kobo/dto/kobo-auth-refresh-body.dto.ts
  • server/src/modules/kobo/dto/kobo-auth-device-body.dto.ts

Comment thread client/src/features/reader/epub/composables/useFoliate.ts
Comment thread server/src/modules/book/book.repository.ts
Comment thread server/src/modules/book/book.repository.ts Outdated
Comment thread server/src/modules/kobo/kobo-auth.controller.ts Outdated
Comment thread server/src/modules/kobo/services/kepub-conversion.service.ts Outdated
Comment thread server/src/modules/reader/epub/epub.service.ts Outdated
Preserve Kobo bookmark metadata and tolerate null auth payloads/settings failures.

Cover review-reported regressions with focused tests.
@neonsolstice neonsolstice changed the title feat(kobo): two way page level sync feat(kobo): two way page level sync between kobo/koreader and bookorbit Jun 5, 2026
@neonsolstice neonsolstice changed the title feat(kobo): two way page level sync between kobo/koreader and bookorbit feat(kobo/koreader): two way page level sync between kobo/koreader and bookorbit Jun 5, 2026
@neonsolstice
neonsolstice merged commit 11b6d98 into main Jun 5, 2026
30 of 31 checks passed
@neonsolstice
neonsolstice deleted the BO-249-two-way-page-level-progress-sync branch June 5, 2026 15:30
@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 1.9.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

client Changes affecting the client application or frontend behavior. packages released Issue or PR is included in a released version. server Changes affecting server-side code, APIs, or backend behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Two-way page-level progress sync for Kobo and KOReader

1 participant