chore(agent): update agent memories (#4802)

This commit is contained in:
Huang Xin
2026-06-26 13:55:36 +08:00
committed by GitHub
parent 4ba78490a7
commit 7544835fb8
20 changed files with 733 additions and 125 deletions
+128 -121
View File
@@ -1,34 +1,31 @@
# Readest Project Memory
## Key Reference Documents
- [Bug Fixing Patterns](bug-patterns.md) — bug categories, root causes, fix strategies
- [CSS & Style Fixes](css-style-fixes.md) — EPUB CSS overrides + style.ts pipeline
- [TTS Fixes](tts-fixes.md) — TTS architecture and bug patterns
- [Bug Fixing Patterns](bug-patterns.md) — categories, root causes, fixes
- [CSS & Style Fixes](css-style-fixes.md) — EPUB CSS + style.ts
- [TTS Fixes](tts-fixes.md) — TTS architecture + bugs
- [Layout & UI Fixes](layout-ui-fixes.md) — safe insets, z-index, platform UI
- [Platform Compat Fixes](platform-compat-fixes.md) — Android/iOS/Linux/macOS bugs
- [Platform Compat Fixes](platform-compat-fixes.md) — Android/iOS/Linux/macOS
- [Annotator & Reader Fixes](annotator-reader-fixes.md) — highlight, selection, a11y
## Safety & Security
- [In-place delete wiped originals](in-place-delete-wiped-originals.md) — never `fs.removeFile` an `external` source; only managed copy + sidecars
- [Backup zip Windows paths (#4703)](backup-windows-zip-paths-4703.md) — `\` entry names broke restore; normalize separators at cross-platform boundaries
- [download_file scope Android (#4639)](download-file-scope-android-regression.md) — strict `is_allowed` broke Android downloads; fix = `app.path()` base-dir membership
- [Security advisories 2026-06](security-advisories-web-2026-06.md) — 4 GHSA in #4638 (OPDS SSRF, storage key, Stripe userId) + #4639 transfer_file.rs
- [In-place delete wiped originals](in-place-delete-wiped-originals.md) — never `fs.removeFile` `external` source; managed copy + sidecars only
- [Backup zip Windows paths (#4703)](backup-windows-zip-paths-4703.md) — `\` entry names broke restore; normalize separators
- [download_file scope Android (#4639)](download-file-scope-android-regression.md) — strict `is_allowed` broke downloads; `app.path()` base-dir membership
- [Security advisories 2026-06](security-advisories-web-2026-06.md) — 4 GHSA #4638 (OPDS SSRF, storage key, Stripe userId) + #4639 transfer_file.rs
## Paginator & Scroll
- [Reading ruler line-aware](reading-ruler-line-aware.md) — snaps to real lines; drop tall block boxes; iframe frame-offset map
- [TOC expand + auto-scroll](toc-expand-and-autoscroll.md) — #4059 collapse-default; dynamic expansion breaks scroll-to-current
- [BooknoteView auto-scroll (#4352)](booknote-view-autoscroll-4352.md) — reload via `initialized` ref, tab via `initialTopMostItemIndex`
- [TOC current-position row](toc-current-position-row.md) — synthetic row under active item; insert AFTER so flatItems index stays valid
- [Swipe page-turn bg flash](paginator-swipe-bg-flash.md) — static `#background`; sliding per-view `computeBackgroundSegments` synced per-rAF
- [Paginated texture occlusion (#4399)](paginated-texture-occlusion-4399.md) — opaque `#background` occludes texture; `hasTexture ? '' : fallbackBg`
- [Background overflows column (#4394)](paginator-gutter-bleed-asymmetry-4394.md) — bg bled into gutter; clamp segments to `[containerStart,containerEnd]`
- [Inline-block column overflow](inline-block-column-overflow.md) — inline-block body can't fragment → clipped; `#demoteUnfragmentableBoxes`
- [FXL fit-width scroll reset (#4683)](fixed-layout-paginated-scroll-reset-4683.md) — WebKit scrollTop not reset on turn; `computePaginatedScroll` + `pageTurn`
- [PDF spread 1px seam (#4587)](pdf-spread-canvas-seam-4587.md) — fractional dpr truncates bitmap; pin `canvas.style.{w,h} = viewport.{w,h}`
- [PDF scrolled wheel double (#4727)](pdf-scroll-mode-wheel-double-4727.md) — manual `scrollBy` on top of native chaining; drop it
- [Scrolled header title center (#4436)](scrolled-header-title-center-4436.md) — return view covering `renderedStart + size/2`, not topmost sliver
- [Duokan fullscreen cover scroll](duokan-fullscreen-cover-scroll.md) — #4379 absolute cover collapses in scroll; gate on `this.#column`
- [Reading ruler line-aware](reading-ruler-line-aware.md) — snaps to real lines; iframe frame-offset map
- [TOC expand + auto-scroll](toc-expand-and-autoscroll.md) — #4059 collapse-default breaks scroll-to-current
- [BooknoteView auto-scroll (#4352)](booknote-view-autoscroll-4352.md) — reload via `initialized` ref; `initialTopMostItemIndex`
- [TOC current-position row](toc-current-position-row.md) — synthetic row; insert AFTER to keep flatItems index
- [Swipe page-turn bg flash](paginator-swipe-bg-flash.md) — static `#background`; `computeBackgroundSegments` per-rAF
- [Paginated texture occlusion (#4399)](paginated-texture-occlusion-4399.md) — opaque bg occludes texture; `hasTexture ? '' : fallbackBg`
- [Background overflows column (#4394)](paginator-gutter-bleed-asymmetry-4394.md) — bg bled into gutter; clamp to container bounds
- [Inline-block column overflow](inline-block-column-overflow.md) — body can't fragment; `#demoteUnfragmentableBoxes`
- [FXL fit-width scroll reset (#4683)](fixed-layout-paginated-scroll-reset-4683.md) — scrollTop not reset; `computePaginatedScroll` + `pageTurn`
- [PDF spread 1px seam (#4587)](pdf-spread-canvas-seam-4587.md) — fractional dpr truncates; pin `canvas.style=viewport`
- [PDF scrolled wheel double (#4727)](pdf-scroll-mode-wheel-double-4727.md) — drop manual `scrollBy` over native chaining
- [Scrolled header title center (#4436)](scrolled-header-title-center-4436.md) — view covering `renderedStart + size/2`
- [Duokan fullscreen cover scroll](duokan-fullscreen-cover-scroll.md) — #4379 cover collapses; gate on `this.#column`
## Critical Files (Most Bug-Prone)
- `src/utils/style.ts` — central EPUB CSS transformation hub
- `packages/foliate-js/paginator.js` — page layout, image sizing, backgrounds
@@ -36,114 +33,124 @@
- `src/hooks/useSafeAreaInsets.ts` — safe area inset management
- `src/app/reader/components/FoliateViewer.tsx` — reader view orchestration
- `src/app/reader/components/annotator/Annotator.tsx` — annotation lifecycle
## Sync Notes
- [Grimmory native sync](grimmory-native-sync.md) — Booklore-fork sync research; built then REVERTED ("not ready"); official koplugin maps id by ISBN/ASIN + progress via koreader-hash endpoint; OPDS link carries bookId+fileId
- [KOSync CFI spine resolution](kosync-cfi-spine-resolution.md) — convert via the CFI's own spine, never `new XCFI(primaryDoc, primaryIndex)`
- [Empty-start CFI sync](empty-start-cfi-sync.md) — skip-link malformed CFI; `isMalformedLocationCfi` → discard synced value
- [Custom fonts vanish on sync (#4410)](custom-fonts-reincarnation-4410.md) — CRDT remove-wins; re-import needs `reincarnation` token
- [koplugin note deletion sync](koplugin-note-deletion-sync.md) — `recordDeletion` tombstone; signal = `index_modified < 0`
- [koplugin stats sync (#4666)](koplugin-stats-sync.md) — statistics.sqlite3 delta; 3-bug chain (LuaSettings, required, optional_params)
- [Statusless books re-pin top (#4677)](sync-statusless-book-rebump-4677.md) — `undefined`≠server `null` rewrites updated_at; fix `(a??null)!==(b??null)`
- [Pull cursor via synced_at (#4678)](sync-synced-at-cursor-4678.md) — books `synced_at` + BEFORE trigger; superset ⇒ old clients OK
- [KOSync connect() false-positive (#4692)](kosync-connect-false-positive-4692.md) — any 2xx HTML accepted as login; validate koreader JSON
- [Grimmory native sync](grimmory-native-sync.md) — Booklore-fork REVERTED; id by ISBN/ASIN + koreader-hash
- [KOSync CFI spine resolution](kosync-cfi-spine-resolution.md) — convert via CFI's own spine, not `new XCFI(primaryDoc,...)`
- [Empty-start CFI sync](empty-start-cfi-sync.md) — `isMalformedLocationCfi` → discard synced value
- [Custom fonts vanish on sync (#4410)](custom-fonts-reincarnation-4410.md) — CRDT remove-wins; re-import `reincarnation` token
- [koplugin note deletion sync](koplugin-note-deletion-sync.md) — `recordDeletion` tombstone; `index_modified < 0`
- [koplugin stats sync (#4666)](koplugin-stats-sync.md) — statistics.sqlite3 delta; LuaSettings/required/optional_params
- [Statusless books re-pin top (#4677)](sync-statusless-book-rebump-4677.md) — `(a??null)!==(b??null)`
- [Pull cursor via synced_at (#4678)](sync-synced-at-cursor-4678.md) — books `synced_at` + BEFORE trigger
- [KOSync connect() false-positive (#4692)](kosync-connect-false-positive-4692.md) — validate koreader JSON, not any 2xx
- [koplugin bulk download (#4751)](koplugin-bulk-download-4751.md) — `downloadAll()`; `listCloudOnlyBooks()`; `coroutine.status`
- [WebDAV metadata sync (#4756)](webdav-metadata-sync-4756.md) — PR #4776; LWW on `book.updatedAt` + `updateBookMetadata`
- [File-sync refactor (#4784)](webdav-filesync-refactor-plan.md) — `FileSyncEngine`/`FileSyncProvider`/`merge.ts`; incremental + Full Sync
- [WebDAV connect nullified (#4780)](webdav-connect-nullified-4780.md) — catch+finally saved STALE closure; `getState().settings`
- [Google Drive provider research](gdrive-sync-provider-research.md) — Drive as `FileSyncProvider`; token persist + resumable upload
- [Google Drive provider multi-PR status](gdrive-provider-multipr-status.md) — PR1+PR2+PR3-so-far DONE on `feat/gdrive-sync-core` (5 commits thru `5efbe6b2f`, UNPUSHED, suite 6411 green); native KV + desktop OAuth runner + connect + ingress filter shipped; REMAINING (UI generalization + tauri.conf scheme + live verify) blocked on official Google client id (ops)
- [Hardcover progress edition_id (#4792)](hardcover-progress-edition-id-4792.md) — `edition_id` fell back to `book_id`; resolve real edition; old bug via #4614
## Build, Testing & CI
- [format:check separate gate](verify-format-check-gate.md) — `pnpm lint` ≠ formatter; `pnpm format:check` own gate, run before push
- [Android CDP e2e lane](android-cdp-e2e-lane.md) — `pnpm test:android` adb+CDP; MediaStore VIEW transient open; CI KVM emulator
- [CDP Android WebView profiling](cdp-android-webview-profiling.md) — adb+CDP JS probes in live app; locked-device freezes fetch
- [Tauri Rust↔JS parser parity](tauri-parser-parity-tests.md) — #4369 native parser cross-check; `dcterms:modified``published`
- [TTS browser e2e harness](tts-browser-e2e-harness.md) — real foliate-view; seed `settings.globalViewSettings` or getMergedRules crashes
- [TTS paragraph+RSVP sync (#3235)](tts-sync-paragraph-rsvp-3235.md) — TTS-is-clock; highlight on overlay CLONE via CSS Custom Highlight API
- [fastlane App Store](fastlane-apple-appstore-submission.md) — keep `APPLE_API_KEY_PATH` OUT of macOS build env or Tauri double-notarizes
- [Turbopack cache OOM (#4619)](turbopack-build-cache-oom-docker-standalone.md) — partial cache → freeze; standalone gated on `BUILD_STANDALONE`
- [Deps override workflow](deps-security-overrides-workflow.md) — overrides in `pnpm-workspace.yaml`; tauri-plugins separate submodule w/ age gate
- [pdfjs vendor wasm](pdfjs-vendor-wasm-decoders.md) — scanned PDFs blank in CI; pdfjs 5.7 moved JBIG2 to `jbig2.wasm`; copy `wasm/*`
- [CI/PR delivery + push keepalive](ci-pr-delivery-and-push.md) — temp-index plumbing; SSH `ServerAliveInterval`; `--no-verify` once hook passed
- [format:check separate gate](verify-format-check-gate.md) — `pnpm format:check` own gate before push
- [Worktree rebase submodule drift](worktree-rebase-submodule-drift.md) — rebase leaves foliate-js submodule old; `git fetch <url> <commit>`
- [Android CDP e2e lane](android-cdp-e2e-lane.md) — `pnpm test:android` adb+CDP; CI KVM emulator
- [CDP Android WebView profiling](cdp-android-webview-profiling.md) — adb+CDP JS probes; locked-device freezes fetch
- [Tauri Rust↔JS parser parity](tauri-parser-parity-tests.md) — #4369 cross-check; `dcterms:modified``published`
- [TTS browser e2e harness](tts-browser-e2e-harness.md) — seed `settings.globalViewSettings` or getMergedRules crashes
- [TTS paragraph+RSVP sync (#3235)](tts-sync-paragraph-rsvp-3235.md) — TTS-is-clock; overlay CLONE via CSS Custom Highlight API
- [fastlane App Store](fastlane-apple-appstore-submission.md) — keep `APPLE_API_KEY_PATH` out of macOS build env
- [Turbopack cache OOM (#4619)](turbopack-build-cache-oom-docker-standalone.md) — partial cache freeze; gate on `BUILD_STANDALONE`
- [Deps override workflow](deps-security-overrides-workflow.md) — overrides in `pnpm-workspace.yaml`; tauri-plugins age gate
- [pdfjs vendor wasm](pdfjs-vendor-wasm-decoders.md) — pdfjs 5.7 moved JBIG2 to `jbig2.wasm`; copy `wasm/*`
- [CI/PR delivery + push keepalive](ci-pr-delivery-and-push.md) — temp-index plumbing; SSH `ServerAliveInterval`
## Platform Compat
- [Android hyphen selection (#1553)](android-hyphen-selection-bounds-1553.md) — Blink paints start handle on last hyphen; repair anchor + custom handles
- [NativeFile vs RemoteFile I/O](android-nativefile-remotefile-io.md) — NativeFile slow; RemoteFile can't replace (asset Range broken); handle-reuse 2.3×
- [Window-state sanitizer (#4398)](window-state-sanitize-4398.md) — invalid `.window-state.json` crashes WebView2; sanitizer plugin before window-state
- [Android Open-with intent (#4521)](android-open-with-intent-flow.md) — VIEW/SEND pipeline; VIEW routing now gated by `autoImportBooksOnOpen` (#4747, mobile default ON → import); Telegram cold-start + foreign-file read
- [Dict lookup browser hijack (#4559)](dict-lookup-browser-hijack-4559.md) — missing `<queries>` ACTION_PROCESS_TEXT sdk36; filter browsers in `decideLookupDispatch`
- [Large-PDF OOM range flood (#3470)](pdf-oom-range-flood-3470.md) — makePDF fires all ranges un-awaited OOM; MAX_CONCURRENT_RANGES=6
- [Android themed icon (#4733)](android-themed-icon-4733.md) — `tauri icon` emits no monochrome → force-commit; tint=SRC_IN, negative-space gap
- [Android hyphen selection (#1553)](android-hyphen-selection-bounds-1553.md) — Blink start handle on last hyphen; repair anchor + custom handles
- [NativeFile vs RemoteFile I/O](android-nativefile-remotefile-io.md) — NativeFile slow; asset Range broken; handle-reuse 2.3×
- [Window-state sanitizer (#4398)](window-state-sanitize-4398.md) — invalid json crashes WebView2; sanitize before window-state
- [Android Open-with intent (#4521)](android-open-with-intent-flow.md) — VIEW gated by `autoImportBooksOnOpen` (#4747); Telegram cold-start
- [Dict lookup browser hijack (#4559)](dict-lookup-browser-hijack-4559.md) — missing `<queries>` sdk36; filter in `decideLookupDispatch`
- [Large-PDF OOM range flood (#3470)](pdf-oom-range-flood-3470.md) — un-awaited ranges OOM; MAX_CONCURRENT_RANGES=6
- [Android themed icon (#4733)](android-themed-icon-4733.md) — no monochrome → force-commit; tint=SRC_IN
## Reader Features & UI
- [Search modes #4560 + spoiler-bound bug](search-modes-4560-and-spoiler-bound-bug.md) — regex + nearby-words in foliate search.js (phased); deferred cache/searchBook; lookupPassage spoiler bound (page vs chunk ordinal) already wrong
- [OPDS groups carousel (#4750)](opds-groups-carousel-4750.md) — >=2 groups → horizontal react-virtuoso carousel; lazy covers; `handle.scrollBy` vertical-only so page by `scrollToIndex`; card covers rounded + badge dropped (kept in detail)
- [Image zoom trackpad flicker (#4742)](image-zoom-trackpad-flicker-4742.md) — macOS trackpad pinch = `ctrl+wheel` stream; `0.05s` transform transition restarts each event → flicker; `isWheelZooming` debounce gates transition off
- [Instant Highlight ate tap/swipe (Android)](instant-highlight-tap-paginate.md) — `handlePointerDown` preventDefault killed tap-paginate; touch still-hold gate `INSTANT_HOLD_MS=300`
- [Keyboard selection adjust (#4728)](keyboard-selection-adjust-4728.md) — Shift+←/→ char, Ctrl/Alt+Shift word; `onAdjustTextSelection` in useBookShortcuts
- [Annotator onLoad listener leak (#4735)](annotator-onload-listener-leak-paragraph-mode.md) — per-section onLoad leaked listeners; `useRendererInputListeners` once-per-view
- [Paragraph mode toggle/resume (#4717)](paragraph-mode-toggle-resume-4717.md) — dispatch iterates live Set → snapshot; resume from fresh `view.lastLocation.cfi`
- [Paragraph-mode accidental exit (#4474)](paragraph-mode-accidental-exit-4474.md) — backdrop taps exited; `paragraph-show-controls` event; bar `absolute``fixed`
- [#4584 tap-death](issue-4584-tap-death-investigation.md) — UNFIXED; `isPopuped` self-heal RED HERRING; likely WebView-148 (emulator 133 can't repro)
- [Dblclick-drag turns page (#4524)](dblclick-drag-pageturn-4524.md) — deferred single-click fires mid-drag; `isMouseDown` gates `postSingleClick`
- [Tap to open image/table (#4600)](tap-to-open-image-table-4600.md) — single-tap opens gallery in reflowable; `iframe-open-media` + `detectMediaTarget`
- [iOS instant-dict double popup](ios-instant-dict-double-popup.md) — multi selectionchange → once-per-gesture latch; `isLongPressHold` 300ms gate
- [Dict popup font size (#4443)](dict-popup-font-size-4443.md) — `fontScale``--dict-font-scale`; MDict shadow-DOM needs `::part(dict-content)`
- [Dictionary lemmatization (#4574)](dict-lemmatization-4574.md) — inflected→lemma; `lemmatize/` registry; `-ses→-sis` before `-es`
- [Word Lens inline gloss](wordlens-feature.md) — native hint above hard words; CFI-safe `<ruby cfi-skip>…<rt cfi-inert>`; TTS/search isolation
- [Word Lens en-en](wordlens-en-en.md) — gloss = simplest WordNet synonym; same-lang unblock manifest-driven; rt font/color settings
- [Stripe highest-active plan (#4694)](stripe-plan-highest-active-4694.md) — `plans.plan` = MAX over active subs; `getHighestActivePlan` create+cancel
- [Save image to gallery (#4680)](save-image-to-gallery-android.md) — Save → MediaStore; sharekit 0-byte self-copy bug (Temp==cacheDir)
- [Webtoon Mode (#3647)](webtoon-mode-3647.md) — no-gap scrolled image reading; FXL scroll = fit-width; `scroll-gap``--scroll-page-gap`
- [Biometric app-lock (#4645)](biometric-app-lock-4645.md) — read flag from `appLockStore` not settingsStore; plugin `cfg(mobile)`
- [Reference Pages (#4542)](reference-pages-672-4542.md) — 'reference' progressStyle from foliate pageList; per-book `referencePageCount`
- [Share intent + toolbar (#4014)](annotation-share-toolbar-4014.md) — Share tool gated mobile+macOS; drag-drop customizer; `annotationToolbarItems`
- [Customize Toolbar global (serializeConfig) #4760](customize-toolbar-global-serializeconfig.md) — was per-book; root cause `serializeConfig` `!==` ref-compares array viewSettings → always stored as stale per-book override shadowing global; fix = value compare (`isSameViewSettingValue`) in viewSettings reduce only; field stays `annotationToolbarItems` in AnnotatorConfig (no exception/rename/move); limitation: pre-existing v0.11.12 overrides not retro-cleared
- [Native iOS TTS (#4676)](native-ios-tts-4676.md) — AVSpeechSynthesizer plugin; pause==stop, never `end` on didCancel; rate `pow^(1/2.5)`
- [Native TTS offline halt (#4613)](native-tts-offline-autoadvance-4613.md) — `#speak` advances only on `end`; native SKIP-on-error via `forward()` + cap
- [Edge TTS word highlight (#4017)](edge-tts-word-highlighting-4017.md) — `audio.metadata` WordBoundary synced by rAF; gates on UA not Origin
- [Edge TTS word-highlight drift](tts-word-highlight-singletextnode-drift.md) — TEXT_NODE fast path ignored offsets → whole-para; slice `[start,end]`
- [TTS start-from-selection](tts-start-from-selection.md) — `from()` picked first mark after sel (use last at/before); cloneRange+deselect
- [Reuse TTS session on mode entry](tts-reuse-session-mode-entry.md) — `redispatchPosition()` + `tts-sync-request` replay + engage-on-entry
- [RSVP control bar overlap = REVERT](rsvp-control-bar-overlap-revert.md) — #4585 fixed; stale #4589 reverted it incl. guard test
- [RSVP font face/family (#4519)](rsvp-font-settings-4519.md) — was font-mono; `getBaseFontFamily`; overlay renders in top doc
- [RSVP RTL word display (#4630)](rsvp-rtl-word-display-4630.md) — ORP char-split breaks Arabic; `isRTLText` → render whole `dir=rtl`
- [Overlay z-index scale](zindex-overlay-scale.md) — RSVP 100 / Settings 110 / ModalPortal 120 / toast 130 / app-lock 200; invariant test
- [Global annotation page-turn lag (#4575)](global-annotation-pageturn-perf-4575.md) — `global` highlights re-fanned every turn; `WeakMap<Document>` memo
- [Overlayer splitRange text nodes](overlayer-splitrange-textnodes.md) — `'p,h1-h4'` selector dropped `li`; walk text nodes + img/svg
- [Android image callout freeze](android-image-callout-freeze.md) — long-press img callout → freeze; `.no-context-menu` on ANCESTOR
- [Table dark-mode tint (#4419)](table-dark-mode-tint-4419.md) — `blockquote, table *` tint must stay gated on `overrideColor`; paints TOC spacer
- [Footnote aside border line (#4438)](footnote-aside-namespace-order-4438.md) — @font-face before @namespace invalidated it; hoist @namespace
- [Proofread enhancements (#4700)](proofread-enhancements-4700.md) — only global rules sync; regex UI; Ctrl+P reuse; `wholeWord` no-op
- [OPDS Firefox strict-XML (#4479)](opds-firefox-strict-xml-4479.md) — junk after `</feed>` → parsererror; `parseOPDSXML` slices to last close tag
- [OPDS2 JSON search greyed (#4502)](opds2-json-search-4502.md) — `isSearchLink` ignored templated opds+json; expand `{?query}` BEFORE resolveURL
- [OPDS HTML description (#4503)](opds-html-description-4503.md) — double-escaped into unsanitized HTML; decode-once + sanitize
- [OPDS self-link metadata (#4749)](opds-self-link-metadata-4749.md) — summary pubs need `rel:self` deref (`opds-publication+json`); JSON `description` is HTML → `getOPDSDescriptionHtml(content ?? description)`
- [D-pad Navigation](dpad-navigation.md) — Android TV remote / arrow-key nav design + pitfalls
- [koplugin cover upload (#4374)](koplugin-cover-upload.md) — local covers uploaded blank; `extractLocalCover` via `getCoverImage`
- [Search modes #4560 + spoiler-bound bug](search-modes-4560-and-spoiler-bound-bug.md) — regex + nearby-words; lookupPassage spoiler bound (page vs chunk)
- [OPDS groups carousel (#4750)](opds-groups-carousel-4750.md) — >=2 groups → virtuoso carousel; `scrollToIndex`
- [WebDAV browser sort + search (#4724)](webdav-browse-sort-search-4724.md) — PR #4786; `sortWebDAVEntries`/`filterWebDAVEntries`
- [Image zoom trackpad flicker (#4742)](image-zoom-trackpad-flicker-4742.md) — macOS pinch=`ctrl+wheel`; `isWheelZooming` debounce
- [Instant Highlight ate tap/swipe](instant-highlight-tap-paginate.md) — preventDefault killed tap-paginate; `INSTANT_HOLD_MS=300`
- [Keyboard selection adjust (#4728)](keyboard-selection-adjust-4728.md) — Shift+←/→ char, Ctrl/Alt+Shift word; `onAdjustTextSelection`
- [Cross-page selection auto-turn (#4741)](cross-page-selection-autoturn-4741.md) — `useAutoPageTurn` dwell; `onAfterTurn` re-emit
- [Annotator onLoad listener leak (#4735)](annotator-onload-listener-leak-paragraph-mode.md) — `useRendererInputListeners` once-per-view
- [Paragraph mode toggle/resume (#4717)](paragraph-mode-toggle-resume-4717.md) — snapshot live Set; resume from fresh `lastLocation.cfi`
- [Paragraph-mode accidental exit (#4474)](paragraph-mode-accidental-exit-4474.md) — `paragraph-show-controls`; bar `absolute``fixed`
- [#4584 tap-death](issue-4584-tap-death-investigation.md) — UNFIXED; `isPopuped` RED HERRING; likely WebView-148
- [Dblclick-drag turns page (#4524)](dblclick-drag-pageturn-4524.md) — `isMouseDown` gates `postSingleClick`
- [Tap to open image/table (#4600)](tap-to-open-image-table-4600.md) — `iframe-open-media` + `detectMediaTarget`
- [PDF/CBZ Contrast view-menu](pdf-cbz-contrast-view-menu.md) — per-book `contrast`; ONE `filter:` (invert+contrast); `skipGlobal=true`
- [iOS instant-dict double popup](ios-instant-dict-double-popup.md) — once-per-gesture latch; `isLongPressHold` 300ms
- [Dict popup font size (#4443)](dict-popup-font-size-4443.md) — `--dict-font-scale`; MDict `::part(dict-content)`
- [Dictionary lemmatization (#4574)](dict-lemmatization-4574.md) — inflected→lemma; `-ses→-sis` before `-es`
- [Word Lens inline gloss](wordlens-feature.md) — CFI-safe `<ruby cfi-skip>…<rt cfi-inert>`; TTS/search isolation
- [Word Lens en-en](wordlens-en-en.md) — simplest WordNet synonym; same-lang manifest-driven
- [Stripe highest-active plan (#4694)](stripe-plan-highest-active-4694.md) — `plans.plan` = MAX over active subs
- [Save image to gallery (#4680)](save-image-to-gallery-android.md) — MediaStore; sharekit 0-byte self-copy (Temp==cacheDir)
- [Webtoon Mode (#3647)](webtoon-mode-3647.md) — no-gap scrolled images; FXL fit-width; `--scroll-page-gap`
- [Biometric app-lock (#4645)](biometric-app-lock-4645.md) — flag from `appLockStore`; plugin `cfg(mobile)`
- [Reference Pages (#4542)](reference-pages-672-4542.md) — 'reference' progressStyle; `referencePageCount`
- [Share intent + toolbar (#4014)](annotation-share-toolbar-4014.md) — Share gated mobile+macOS; `annotationToolbarItems`
- [Instant highlight delete orphan (#4773)](instant-highlight-delete-orphan-4773.md) — stale memoized index + in-place `deletedAt`; re-check at READ
- [Customize Toolbar global (#4760)](customize-toolbar-global-serializeconfig.md) — `serializeConfig` ref-compare → stale override; value compare
- [Native iOS TTS (#4676)](native-ios-tts-4676.md) — AVSpeechSynthesizer; pause==stop; rate `pow^(1/2.5)`
- [Native TTS offline halt (#4613)](native-tts-offline-autoadvance-4613.md) — advance only on `end`; SKIP-on-error via `forward()`
- [Edge TTS word highlight (#4017)](edge-tts-word-highlighting-4017.md) — `audio.metadata` WordBoundary by rAF; gate on UA
- [Edge TTS word-highlight drift](tts-word-highlight-singletextnode-drift.md) — TEXT_NODE fast path ignored offsets; slice `[start,end]`
- [TTS start-from-selection](tts-start-from-selection.md) — use last mark at/before sel; cloneRange+deselect
- [Reuse TTS session on mode entry](tts-reuse-session-mode-entry.md) — `redispatchPosition()` + `tts-sync-request` replay
- [RSVP control bar overlap = REVERT](rsvp-control-bar-overlap-revert.md) — #4585 fixed; stale #4589 reverted it
- [RSVP font face/family (#4519)](rsvp-font-settings-4519.md) — `getBaseFontFamily`; overlay in top doc
- [RSVP RTL word display (#4630)](rsvp-rtl-word-display-4630.md) — ORP breaks Arabic; `isRTLText` → whole `dir=rtl`
- [Overlay z-index scale](zindex-overlay-scale.md) — RSVP 100 / Settings 110 / ModalPortal 120 / toast 130 / app-lock 200
- [Global annotation page-turn lag (#4575)](global-annotation-pageturn-perf-4575.md) — `global` re-fanned every turn; `WeakMap<Document>` memo
- [Overlayer splitRange text nodes](overlayer-splitrange-textnodes.md) — `'p,h1-h4'` dropped `li`; walk text nodes + img/svg
- [Android image callout freeze](android-image-callout-freeze.md) — `.no-context-menu` on ANCESTOR
- [Table dark-mode tint (#4419)](table-dark-mode-tint-4419.md) — `blockquote, table *` tint gated on `overrideColor`
- [Footnote aside border line (#4438)](footnote-aside-namespace-order-4438.md) — @font-face before @namespace; hoist @namespace
- [Proofread enhancements (#4700)](proofread-enhancements-4700.md) — regex UI; Ctrl+P reuse; `wholeWord` no-op
- [Proofread per-book CRDT sync (#4781)](proofread-per-book-crdt-sync.md) — `mergeProofreadRules` by id; `ensureRuleId` backfill
- [Russian hanging-preposition NBSP (#4769)](russian-hanging-prepositions-nbsp-4769.md) — generic `nbsp` + `NBSP_LANGUAGES` (`ru`); after `whitespace`/`simplecc`
- [OPDS Firefox strict-XML (#4479)](opds-firefox-strict-xml-4479.md) — junk after `</feed>`; `parseOPDSXML` slices to last close tag
- [OPDS2 JSON search greyed (#4502)](opds2-json-search-4502.md) — expand `{?query}` BEFORE resolveURL
- [OPDS HTML description (#4503)](opds-html-description-4503.md) — double-escaped; decode-once + sanitize
- [OPDS self-link metadata (#4749)](opds-self-link-metadata-4749.md) — `rel:self` deref; `getOPDSDescriptionHtml`
- [OPDS popular catalog dedup (#4782)](opds-popular-catalog-dedup-4782.md) — `getUnaddedPopularCatalogs` normalized-URL
- [D-pad Navigation](dpad-navigation.md) — Android TV remote / arrow-key nav
- [koplugin cover upload (#4374)](koplugin-cover-upload.md) — `extractLocalCover` via `getCoverImage`
## Library Fixes
- [Book action platform surfaces](book-actions-platform-surfaces.md) — context menu Tauri-desktop-only; cross-platform actions in `BookDetailView`
- [Tauri menu append race (#4389)](tauri-menu-append-race-4389.md) — un-awaited `Menu.append()` shuffles order; single `await Menu.new({ items })`
- [TXT author recognition (#4390)](txt-author-recognition-4390.md) — 【】 web-novels garbage author; `parseLabeledAuthor` + `isPlausibleAuthorName`
- [TXT chapter measure-word FP (#4658)](txt-chapter-measure-word-4658.md) — strong `[章节回讲篇话]` vs weak `[卷本册部封]` needs separator
- [Cover stale (in-place mutation)](cover-stale-inplace-mutation-memo.md) — mutated book in place → memo skip; pure `getBookWithUpdatedMetadata`
- [Series/author back no-op (#4437)](series-folder-back-noop-4437.md) — Next 16.2 empty-search `router.replace` no-op; `handleBack` `group=''` workaround
- [Library/reader separate texture (#4743)](library-reader-separate-texture-4743.md) — shared `#background-texture` style; `libraryBackground*` device-local + inherit; `none` must unmount; store inits `{}`
- [Book action platform surfaces](book-actions-platform-surfaces.md) — context menu Tauri-desktop-only; cross-platform in `BookDetailView`
- [Tauri menu append race (#4389)](tauri-menu-append-race-4389.md) — single `await Menu.new({ items })`
- [TXT author recognition (#4390)](txt-author-recognition-4390.md) — `parseLabeledAuthor` + `isPlausibleAuthorName`
- [TXT chapter measure-word FP (#4658)](txt-chapter-measure-word-4658.md) — strong `[章节回讲篇话]` vs weak `[卷本册部封]`
- [Cover stale (in-place mutation)](cover-stale-inplace-mutation-memo.md) — pure `getBookWithUpdatedMetadata`
- [Series/author back no-op (#4437)](series-folder-back-noop-4437.md) — Next 16.2 empty-search no-op; `handleBack` `group=''`
- [Library/reader separate texture (#4743)](library-reader-separate-texture-4743.md) — `libraryBackground*` device-local; `none` unmounts
- [List view series overflow (#4796)](list-view-series-overflow-4796.md) — fixed `h-28` clipped series+description (Android font scale); `min-h-28`
## Architecture & Patterns
- foliate-js is a git submodule at `packages/foliate-js/`; multiview paginator preloads adjacent sections (multiple View/Overlayer per book)
- Style: `getLayoutStyles()` always, `getColorStyles()` when overriding color; `transformStylesheet()` regex-rewrites EPUB CSS at load
- TTS independent section tracking (`#ttsSectionIndex`); safe insets: native plugin → useSafeAreaInsets → styles; Dropdowns use `DropdownContext`
- [Foliate touch-listener capture phase](foliate-touch-listener-capture-phase.md) — suppress reader gestures via `{capture:true}`
- [iframe cross-realm instanceof](iframe-cross-realm-instanceof.md) — top realm: `iframeEl instanceof Element` always false; duck-type `'closest' in target`
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars hook for mobile webviews
- [Design system → DESIGN.md](feedback_design_system_doc.md) — codify UI rules; never `pl/pr/ml/mr/text-left/right` (RTL)
- foliate-js submodule at `packages/foliate-js/`; multiview paginator preloads adjacent sections
- Style: `getLayoutStyles()` always, `getColorStyles()` when overriding color; `transformStylesheet()` rewrites EPUB CSS at load
- TTS independent section tracking (`#ttsSectionIndex`); safe insets: native plugin → useSafeAreaInsets → styles; Dropdowns `DropdownContext`
- Stale settings closure: store-hook `settings` stale across `await`; persist `useSettingsStore.getState().settings` ([#4780](webdav-connect-nullified-4780.md))
- [Foliate touch-listener capture phase](foliate-touch-listener-capture-phase.md) — suppress gestures via `{capture:true}`
- [iframe cross-realm instanceof](iframe-cross-realm-instanceof.md) — `instanceof Element` false; duck-type `'closest' in target`
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars for mobile webviews
- [Design system → DESIGN.md](feedback_design_system_doc.md) — never `pl/pr/ml/mr/text-left/right` (RTL)
## Workflow & Feedback
- [Commit messages English-only](feedback-commit-message-english-only.md) — commit msgs + PR titles English only (no CJK, no em/en dashes). PR #4660
- [Test file filter](feedback_test_file_filter.md) — `pnpm test <path>` without `--` runs a single file
- [Commit messages English-only](feedback-commit-message-english-only.md) — English only (no CJK, no em/en dashes)
- [Test file filter](feedback_test_file_filter.md) — `pnpm test <path>` without `--` runs one file
- [Rebase before PR](feedback_pr_rebase.md) — rebase onto origin/main before PRs
- [New branch per PR](feedback_pr_new_branch.md) — fresh branch from main per PR/issue
- [Use worktree](feedback_use_worktree.md) — never `git worktree add`; always `pnpm worktree:new`
- [Never push every change](feedback_dont_push_every_change.md) — commit locally until user confirms or clean done-state
- [Never push every change](feedback_dont_push_every_change.md) — commit locally until user confirms
- [No test seams in prod](feedback_no_test_seams_in_prod.md) — prod never imports `__reset*ForTests`
- [No lookbehind regex](feedback_no_lookbehind_regex.md) — never `(?<=)`/`(?<!)`; build check rejects
- [en plurals manual](feedback_en_plurals_manual.md) — only plural variants + proper nouns; plurals need `_one`/`_other`
- [en plurals manual](feedback_en_plurals_manual.md) — only plural variants + proper nouns; `_one`/`_other`
- [Dependabot transitive fixes](dependabot-pnpm-overrides.md) — pin in `pnpm-workspace.yaml` `overrides:`; alert#≠issue#
- [Upgrade gstack locally](feedback_gstack_upgrade.md) — upgrade from project `.claude/skills/gstack`
@@ -0,0 +1,57 @@
---
name: cross-page-selection-autoturn-4741
description: Cross-page selection/highlight in paginated mode via extracted useAutoPageTurn; all four selection gestures drive the corner-dwell turn
metadata:
node_type: memory
type: project
originSessionId: 33b70e98-fb55-467a-b03f-e4065491bc7e
---
#4741: in paginated (non-scrolling) mode, extend a selection/highlight past the
page edge by turning the page mid-gesture. Branch `feat/cross-page-highlight-autoturn`.
**Extracted `src/app/reader/hooks/useAutoPageTurn.ts`** from `useTextSelector`
the corner-dwell auto page-turn (#1354), now **decoupled from the DOM selection**
so selection-less gestures can drive it. API: `notePoint`/`noteAutoTurnPoint`
(window-coord engagement point), `cancel`, `isAutoTurning`, `onAfterTurn(cb)`
(Set of subs), `cornerAtPoint`, `readingAreaRect`. Liveness at dwell fire-time is
an injected predicate, not `doc.getSelection()`: `noteCorner(corner, isInCorner)`.
`useTextSelector` keeps the dual-signal native liveness (`pointerCornerNow ||
caretCornerNow`); point-only callers use `noteAutoTurnPoint` (last-point liveness).
Pure exports `getReadingAreaRect`, `turnForFocusBeyondPage`, `keyboardTurnDirection`.
**Key trap:** the old `armDwell` required a valid DOM selection to turn. Instant
Highlight (`user-select:none` + CFI overlay) and AnnotationRangeEditor (CFI
overlay) have **no** DOM selection, so the machine refused to turn for them. The
decoupling is what makes them work at all.
**Four gestures, all feeding the one machine** (`useTextSelector` re-exposes
`noteAutoTurnPoint`/`cancelAutoTurn`/`onAutoTurn` to the editors via `Annotator`):
1. Instant Highlight drag — `handlePointerMove`/`handleNativeTouchMove` feed the
finger corner. `useInstantAnnotation` now **DOM-anchors the start** (`startPosRef`
= `{node,offset}` at pointer-down; `buildRangeFromAnchor` builds anchor->end each
move) so it survives the scroll; relaxed the pointer-up `distance<10` cancel with
`&& !previewAnnotationRef.current`. See [[instant-highlight-tap-paginate]].
2. `SelectionRangeEditor` handle drag — already DOM-anchored the fixed end; just
feed `noteAutoTurnPoint(point)` + cancel + re-emit.
3. `AnnotationRangeEditor` handle drag — `useAnnotationEditor` changed from
`handleAnnotationRangeChange(startPt,endPt)` (`buildRangeFromPoints` resolved BOTH
ends from window coords -> lost previous page) to `applyAnnotationRange(range,...)`;
component anchors the non-dragged end (`fixedAnchorRef`) + builds via
`rangeFromAnchorToPoint` like SelectionRangeEditor.
4. `Shift+Arrow` keyboard adjust (#4728) — `useBookShortcuts.adjustTextSelection`,
after `extendSelectionFromContents`, **immediate turn-on-cross** (no dwell):
`keyboardTurnDirection(contents, getReadingAreaRect(...))` -> `view.next()/prev()`
when the extended focus leaves the page. Desktop-only; gated `!scrolled`.
**After-turn re-emit:** active gesture subscribes `onAfterTurn` to rebuild its range
from the held point onto the new page immediately (instant: `reapplyInstantAnnotation`;
editors: `subscribeAutoTurnReemit` -> `updateFromDraggedPoint(lastPoint)`). Native
selection does NOT subscribe (browser extends its own). The Android #873 scroll-pin
(`selectionPosition`) is re-anchored after every turn via `onAfterTurn` in useTextSelector.
`focusCaretWindowPos` promoted `useTextSelector` -> `src/utils/sel.ts` (keyboard reuse).
Scope: within-section column turns only (a Range can't span two iframe docs).
Tests: `useAutoPageTurn.test.ts` (21), `useTextSelector-instantTurn.test.ts`,
`useInstantAnnotation.test.ts`, `useAnnotationEditor.test.ts`; existing autoTurn/
instantHold suites stay green (regression net for the extraction).
@@ -0,0 +1,48 @@
---
name: gdrive-provider-multipr-status
description: "Google Drive file-sync provider — phased multi-PR build status, what shipped in PR1 and what each later PR adds"
metadata:
node_type: memory
type: project
originSessionId: 50e2c2b8-ca61-4c33-acae-cd5d2c9aa93f
---
Adding **Google Drive as a second `FileSyncProvider`** for the merged file-sync engine (the WebDAV refactor, PR #4784). Approved plan: `/Users/chrox/.claude/plans/floating-chasing-feather.md`. Research + reuse map: [[gdrive-sync-provider-research]]. Author of the reference (`ratatabananana-bit/Readest-google-drive-mod-patcher`, AGPL-3.0) granted explicit reuse permission; adapted files carry attribution headers.
**Shipped across multiple PRs (decided at the autoplan gate; no BYO client, official iOS-type client only).**
**PR1 — DONE (built, all gates green, committed locally, NOT pushed).** Branch `feat/gdrive-sync-core` (worktree `/Users/chrox/dev/readest-feat-gdrive-sync-core`), commit `1a0065818`. 25 files / ~2.6k lines, ~81 new unit tests, full suite 6377 passing + lint + format clean. Contents under `src/services/sync/providers/gdrive/`:
- `GoogleDriveProvider.ts` — Drive v3 over `FileSyncProvider`; id-addressed resolution + per-instance id cache; create-then-name upload; real `ensureDir`; `files.list` pagination; Retry-After 429/5xx backoff; per-path folder-creation locks + deterministic dup-collapse (smallest id); stale-id eviction; `mapDriveError` (403 split rate-limit→NETWORK vs permission→AUTH_FAILED). Factory `createGoogleDriveProvider(auth, fetchFn, {sleep?})`; streaming omitted.
- `auth/``pkce`, `parseRedirect` (target + CSRF, takes `expectedRedirectUri`), `reverseDnsRedirect`, `tokenStore` (no client secret), `oauthFlow` (DI).
- `PersistedDriveAuth.ts` — single-flight refresh + re-check, carries old refresh_token, one save; `accountLabel` via `about.get`.
- `driveTokenStore.ts``TokenPersistence` + `KeychainTokenPersistence` over keyed secure-KV; `createDriveTokenPersistence()` returns null off-Tauri (NO ephemeral fallback for refresh token).
- `driveRest.ts` — pure builders + pagination + `aboutUrl`.
- `buildGoogleDriveProvider.ts` (env client id + keychain), `file/providerRegistry.ts` (`createFileSyncProvider`/`getEnabledFileSyncBackends`).
- Shared `file/providerSemanticContract.ts` test helper run for BOTH WebDAV + Drive.
- `utils/bridge.ts` — TS wrappers `set/get/clearSecureItem` (`plugin:native-bridge|*_secure_item`).
**DEVIATION from plan:** the native keyed secure-KV implementation (Rust desktop/mobile + Kotlin + Swift + permissions) was DEFERRED out of PR1 — nothing in PR1 calls it (no UI/sync wiring), and 4 languages of un-runnable native code don't belong in a "CI-testable, no-platform" PR. The TS contract exists + is mock-tested. Native impl lands with **PR3 (desktop OAuth)**, which first exercises it and can live-verify.
**PR2 — DONE (foundation only; committed `9ba097ea2`, UNPUSHED, on same `feat/gdrive-sync-core` branch).** Full suite 6403 passing + lint + format clean.
- `GoogleDriveSettings` type (mirrors WebDAVSettings minus URL/creds/rootPath, +`accountLabel`) in `types/settings.ts` + `SystemSettings.googleDrive`; `DEFAULT_GOOGLE_DRIVE_SETTINGS` in `constants.ts`.
- `googleDrive.deviceId`/`lastSyncedAt` added to `BACKUP_SETTINGS_BLACKLIST` (backupService.ts) + backup-settings test.
- `webdavSyncStore``store/fileSyncStore.ts`: per-backend progress keyed by kind + GLOBAL library-sync mutex (`beginSync(kind,label)` returns false if another holds lock). Migrated `WebDAVForm` + `IntegrationsPanel`; WebDAV behavior unchanged. `fileSyncStore.test.ts`.
- **DEFERRED to PR3 (deliberate):** `useWebDAVSync``useFileSync` hook generalization + `WebDAVForm``FileSyncForm` extraction + visible Drive Integrations row/connect UI. Rationale: until Drive connects (needs OAuth), the multi-provider hook paths can't run and `FileSyncForm` would be a single-use abstraction (violates YAGNI); also the autoplan gates these on a live WebDAV Sync-now check. Do them WITH PR3.
**PR3 — IN PROGRESS (3 commits, all gates green: full suite 6411 passing + rust fmt/clippy/test + lint/format). UNPUSHED on `feat/gdrive-sync-core`.**
- `ff1ffe717` native keyed secure-KV: `set/get/clear_secure_item` across Rust desktop (keyring keyed by item key) + mobile forward + models/commands/lib/build/default.toml + Kotlin (EncryptedSharedPreferences `readest_secure_items_v1`) + Swift (Keychain, service `com.bilingify.readest.secure-items`). Rust compiles+clippy+fmt clean; permission files regenerated (passphrase preserved).
- `602f41406` desktop OAuth machinery: `auth/oauthDesktop.ts` (`runDesktopDeepLinkOAuth`, DI, 3 tests) + `src-tauri/src/spawn_fresh_browser.rs` (registry default-browser cold-spawn on Windows / no-op macOS+Linux; winreg Windows-only dep; pure-helper tests; registered `#[cfg(desktop)]`) + `connectGoogleDrive.ts` (`connectGoogleDrive`/`disconnectGoogleDrive`, fail-loud token save, 4 tests). `DRIVE_FILE_SCOPE='https://www.googleapis.com/auth/drive.file'`.
- `5efbe6b2f` ingress filter: `isGoogleOAuthRedirectUrl` (scheme-prefix match) + filter in `useAppUrlIngress` dispatch so the reverse-DNS redirect never reaches book-import consumers (OAuth runner catches via own listeners). Tested.
**Official client id PROVISIONED:** `209390247301-ctpmep68ppfa56r1b8tr35e4qi4p60kq.apps.googleusercontent.com` (iOS type, no secret, `drive.file`). Baked as default in `getGoogleClientId` (env `NEXT_PUBLIC_GOOGLE_CLIENT_ID` overrides); reverse-DNS scheme `com.googleusercontent.apps.209390247301-ctpmep68ppfa56r1b8tr35e4qi4p60kq` registered in `tauri.conf.json` desktop+mobile deep-link. Commit `7a2ac3671`.
**Drive UI DONE (commit `c657c34f0`):** `FileSyncForm` (shared sync controls extracted from WebDAVForm, parameterized by kind, builds provider via registry; WebDAVForm refactored to use it, behavior unchanged) + `GoogleDriveForm` (OAuth Connect/account/Disconnect + FileSyncForm) + `googleDriveConnect.ts` (assembles env client id + keychain + desktop runner) + IntegrationsPanel "Google Drive" row gated on `appService.isDesktopApp`. Full suite 6412 green.
**PR3 REMAINING:**
- **LIVE VERIFICATION (needs the user — real Google sign-in):** `pnpm tauri dev` → add own Google account as a Test user in the consent screen (Testing mode caps + gates) → Settings → Integrations → Google Drive → Connect → browser → grant → "Connected as <email>" → add book / Sync now → confirm `Readest/books/<hash>/{config.json,cover.png}` in Drive. Windows cold-browser fallback.
- **Reader-hook auto-sync (deferred):** generalize `useWebDAVSync``useFileSync` (per-provider state maps, async Drive provider build in the hook) so Drive auto-syncs per-book while reading like WebDAV. Manual Sync-now already works without it; do after live-verifying the base.
- Consent screen → Production before GA (testing caps 100 users).
- PR4 Android OAuth (Custom Tab + manifest scheme), PR5 iOS OAuth (authWithSafari scheme param + Info-ios.plist). Later: Drive resumable upload for `syncBooks` on mobile.
- Ops/launch blocker: create Google Cloud project (iOS client, `drive.file`) + consent screen to production (testing caps 100 users).
- PR4 Android OAuth, PR5 iOS OAuth. Later: Drive resumable upload to unlock `syncBooks` on mobile.
- Ops/launch blocker: create the Google Cloud project (iOS client, `drive.file`) + set consent screen to production (testing caps 100 users).
@@ -0,0 +1,42 @@
---
name: gdrive-sync-provider-research
description: Research on the ratatabananana-bit Google Drive mod for building a Drive FileSyncProvider; OAuth approach + reuse map
metadata:
node_type: memory
type: reference
originSessionId: 50e2c2b8-ca61-4c33-acae-cd5d2c9aa93f
---
NEXT TASK (research done, not yet built): add **Google Drive as a `FileSyncProvider`** for the merged file-sync engine ([[webdav-filesync-refactor-plan]] / PR #4784). Researched reference: `github.com/ratatabananana-bit/Readest-google-drive-mod-patcher` (AGPL-3.0, same as Readest → can adapt WITH attribution). Reference patch saved at `~/.../scratchpad/gdrive-ref/` (extracted modules under `extracted/`).
**The repo is a PATCHER**, not a fork: the whole impl is one squashed diff `tooling/mod/mod.patch` (13k lines) against Readest v0.11.12. Design/plan docs live in a SIBLING repo `readest-gdrive-sync-mod` (referenced in MOD.md, likely private — not in the patcher).
**Their architecture = REPLACE Readest's native cloud sync with Drive** (library/progress/notes/stats). Two layers:
- `src/services/cloudprovider/` — REUSABLE: a backend-agnostic provider seam + OAuth. `CloudProvider.ts` (their interface), `GoogleDriveProvider.ts` (Drive v3 REST impl), `FakeCloudProvider.ts`, `buildDriveProvider.ts` (assembly), `googleAuth/*` (the OAuth layer).
- `src/services/drivesync/` — SKIP for us: their integration with the native-sync data model (driveMerge, statsMerge, DriveSyncClient, DriveBlobStore, jsonl, layout). We REPLACE this with our `FileSyncEngine`.
**KEY: their `CloudProvider` is ~1:1 with our `FileSyncProvider`.** Map: getText↔readText, getBinary↔readBinary, putText/putBinary↔writeText/writeBinary, list↔list, stat↔head, deleteFile↔deleteDir. Their `CloudEntry` even carries `md5` (Drive checksum) — stronger than our size-only HEAD short-circuit. Extra on theirs: `isAuthenticated()`/`accountLabel()` (auth state) + `putBinary` `onProgress`. Missing on theirs: `ensureDir` (Drive auto-creates folders on write).
**Recommended fit for US = Drive as a parallel `FileSyncProvider`** (like WebDAV), NOT replacing native sync. Reuses the whole engine (incremental/concurrency/merge). Build = (1) `createGoogleDriveProvider(settings): FileSyncProvider` adapting their `GoogleDriveProvider` (rename methods, map CloudEntry→FileEntry, head from stat, deleteDir from delete-folder-by-id, ensureDir = no-op since write auto-creates, rootPath='/'), (2) reuse `googleAuth/*` OAuth nearly as-is, (3) token persistence (the ONE big gap — see below), (4) settings UI + provider registry.
**Drive specifics (vs WebDAV path-addressing):**
- **Drive is ID-addressed, not path-addressed.** Resolve a logical path (`Readest/books/<hash>/config.json`) segment-by-segment via `files.list` (name+parent queries), cache folder/file ids in a `Map<path,id>`. `driveRest.ts` = pure query/URL builders; `GoogleDriveProvider` owns resolver+cache.
- **`drive.file` scope** = app sees only files it created → Drive root is a safe private namespace (no appdata hidden folder; a visible "Readest" folder). Non-sensitive scope = no Google verification needed (unverified-app warning shows once).
- **Upload = create-then-name:** `uploadType=media` carries no metadata, so POST bytes to root → PATCH name + reparent (addParents=folder, removeParents=root). Overwrite = media PATCH on the existing id (preserves id/links).
- Endpoints: metadata `drive/v3/files`, media `upload/drive/v3/files?uploadType=media`. Folder MIME `application/vnd.google-apps.folder`.
**OAuth (the hard part — every gotcha you flagged is CONFIRMED + implemented):**
- **One iOS-type Google client** (Bundle ID only, NO secret, NO SHA-1, App Check OFF) for BOTH Windows + Android. Redirect = reverse-DNS `com.googleusercontent.apps.<id>:/oauthredirect` (SINGLE slash) + PKCE. Client id derives the scheme (`reverseDnsRedirect.ts`). Client id is committed (not a secret). App Check must stay OFF (Android can't produce iOS attestation → would break everyone).
- Loopback dead for iOS clients (Google blocked 2022); embedded WebView blocked (`disallowed_useragent`). Reverse-DNS is the only no-SHA native redirect Google accepts.
- `oauthFlow.ts` — provider-agnostic orchestration, platform mechanics injected (DI, headless-testable). Arms `awaitRedirect` BEFORE `openUrl` (race fix). PKCE + `state` CSRF via `parseRedirect.ts`.
- **Android** (`oauthAndroid.ts`): Chrome Custom Tab via Readest's EXISTING native bridge `authWithCustomTab` (same as Supabase login) — NOT external browser (keeps Tauri Activity foregrounded so in-flight auth survives memory pressure; redirect resolves via a native Kotlin field that survives WebView reload). Register the client scheme as a BROWSABLE intent-filter (patcher injects into `tauri.conf deep-link.mobile`). MUST filter the OAuth redirect out of Readest's deep-link ingress (`useAppUrlIngress` via `matchesReverseDnsRedirect`) or it triggers a /library reload that kills the flow. `tauri android init` wipes the manifest → restore MANAGE_EXTERNAL_STORAGE etc.
- **Windows/desktop** (`oauthDesktopDeepLink.ts` + `spawn_fresh_browser.rs`): system browser + self-registered scheme (`deep_link().register_all()`, no installer/admin). Capture via `single-instance` (url=args[1]) + `onOpenUrl`. THE WINDOWS SUBTLETY: a browser process snapshots protocol associations at launch, so a browser already running before scheme-registration silently drops the redirect. Fix: open default browser first; if no redirect in `DEFAULT_FALLBACK_DELAY_MS=25_000`, re-open in a freshly-spawned COLD browser (`spawn_fresh_browser` Rust cmd: resolve default browser from registry UserChoice → if Chromium-family spawn with `--user-data-dir=<isolated>` → else fall back to Edge). Hard deadline `CONNECT_DEADLINE_MS=15min` rejects an abandoned sign-in. Whichever browser returns first wins.
- `tokenStore.ts` = PKCE token exchange + `refreshAccessToken` (Google omits refresh_token on refresh → keep the old one). `pkce.ts` = PKCE pair + `buildAuthUrl`.
**GAPS / NOT in the reference (we'd build):**
1. **Token persistence is a stubbed interface** (`TokenPersistence` load/save/clear) — they explicitly left the secret store (Tauri secure storage / Android Keystore) as a later task. WE implement it.
2. **No resumable/streaming upload** — simple `uploadType=media` buffers the whole file in JS heap (same OOM risk our WebDAV `uploadStream` avoids). For large book files we'd add Drive resumable upload (`uploadType=resumable`); configs/covers are fine buffered. Our engine's streaming is optional (falls back to buffered).
3. **accountLabel is a placeholder** ('Google Drive'); real email needs a userinfo call.
4. **iOS/macOS** not covered (Windows + Android only).
**License call:** AGPL→AGPL is compatible. **The author (ratatabananana-bit) granted EXPLICIT permission** (2026-06): "feel free to do whatever you want with the code (it's the AGPL fork - Drive sync + the recently-read shelf)." So we can copy-adapt freely; keep attribution/credit. The OAuth platform glue is the high-value, hard-to-reproduce part → adapt with credit. Note the author also mentions a "recently-read shelf" feature in the same fork (separate, potential bonus).
@@ -0,0 +1,21 @@
---
name: hardcover-progress-edition-id-4792
description: Hardcover progress sync parse-failed — edition_id falls back to book_id; invalid edition rejected by Hasura Action
metadata:
node_type: memory
type: project
originSessionId: 6273b46d-b22d-4d48-9295-7420b251a197
---
Issue #4792 (v0.11.12) — FIXED in PR #4794 (branch `fix/hardcover-progress-edition-id`). "Hardcover sync fails completely despite successful API key auth." Auth (`GetUserId`) works; progress push fails with:
`GraphQL Errors: [{"message":"parsing Hasura.GraphQL.Execute.Action.Types.ActionWebhookErrorResponse failed, key \"message\" not found","extensions":{"code":"parse-failed"}}]`
**Root cause (verified live in Chrome, account chrox, book "Crime and Punishment"):** `HardcoverClient.pushProgress``MUTATION_UPDATE_READ` (`update_user_book_read`) sent `edition_id: 713309`, which is the **book_id**, not a real edition id. `update_user_book_read`/`insert_user_book_read` are Hardcover **Hasura Actions**; an invalid edition makes the Action handler throw and return a non-conforming error body, which Hasura surfaces as the generic `parse-failed` (`ActionWebhookErrorResponse` missing `message`). HTTP status is 200 — the error is GraphQL-level only.
**Why edition_id == book_id:** title-search path in `fetchBookContext` (`HardcoverClient.ts`). `QUERY_SEARCH_BOOK` (`per_page:1`, returns raw `results`) does **not** select `featured_edition_id` — confirmed the hit `document` has no such key. So `searchBookByTitle` does `editionId = featured_edition_id ?? bookId` → always `bookId`. Then `QUERY_GET_BOOK_USER_DATA` only resolves a real edition via `selectedEdition` (the user_book's / read's `edition`); here both were `null` (user added the book with no specific edition), so `editionId` stays `bookId`. Broad impact: any no-ISBN (title-matched) book whose Hardcover library entry has no edition selected sends `edition_id = book_id`.
**Fix shipped (PR #4794):** `BookContext.editionId` is now `number | null`; `searchBookByTitle` drops the `?? bookId` fallback (null when no `featured_edition_id`); `$edition_id` made nullable (`Int`) in `MUTATION_INSERT_READ`/`MUTATION_UPDATE_READ`/`MUTATION_INSERT_JOURNAL`; `insert_user_book` omits `edition_id` when null. Verified live: book id → `parse-failed`; real edition id → `error:null`; `edition_id:null``error:null` and is a no-op (does NOT clear an existing edition).
**NOT a recent Readest regression:** the buggy `editionId = featured_edition_id ?? bookId` fallback + `edition_id: context.editionId` in the read mutations exist unchanged since the original feature #3724 (2026-04-03). It surfaces now because auto-sync (#4614, 2026-06-16, shipped v0.11.10/v0.11.12) made progress-push run automatically on every page turn (debounced) and via the BookMenu "Hardcover Sync → Push Progress". Possibly compounded by Hardcover tightening server-side edition validation. Secondary: title search also mis-matches (e.g. matched a Harold Bloom study guide, not Dostoevsky's novel) — separate match-quality concern.
Files: `src/services/hardcover/HardcoverClient.ts` (`fetchBookContext` ~306-426, `searchBookByTitle` ~286-289, `pushProgress` ~499-536), `src/services/hardcover/hardcover-graphql.ts` (`QUERY_SEARCH_BOOK`, `MUTATION_UPDATE_READ`/`MUTATION_INSERT_READ` ~131-155). Proxy: `src/app/api/hardcover/graphql/route.ts` forwards client `authorization` header.
@@ -0,0 +1,50 @@
---
name: instant-highlight-delete-orphan-4773
description: Deleting a just-made highlight leaves the overlay drawn (gone only after reopen); a stale memoized annotationIndex re-draws it
metadata:
node_type: memory
type: project
originSessionId: 3a58d242-3867-414c-869a-95a23714b361
---
#4773 (Android, instant highlight): highlight a word, delete it "within a very
short time" → the mark stays painted on the page, vanishing only after reopening
the book. Booknote IS soft-deleted (`deletedAt` set, gone on reopen) but the
**overlay was re-drawn after removal** → orphan.
**Root cause — stale memoized index re-draws a deleted annotation.**
`Annotator.tsx` re-applies per-location annotations on every relocate via the
memoized `annotationIndex` (`useMemo(buildAnnotationIndex(config.booknotes), [config.booknotes])`)
`selectLocationAnnotations(index, location)``view.addAnnotation(a)`.
`buildAnnotationIndex` filters `deletedAt` at BUILD time, but
`selectLocationAnnotations` trusted that and did NOT re-check. The delete
(`handleHighlight(false)`) stamps `existing.deletedAt = Date.now()` **in place**
on the same booknote object that's still sitting in the index bucket, and
removes the overlay (`addAnnotation(existing, true)`). If the re-apply effect
scheduled from the popup-open render flushes AFTER the delete (the "very short
time" window — passive effects deferred on Android WebView under rapid taps),
`selectLocationAnnotations` returns the now-deleted object from the pre-deletion
snapshot and `addAnnotation` re-draws it → overlay orphaned. Annotator does NOT
re-render on booknote changes (subscribes only to the stable `getConfig` fn), so
the memo stays stale until some other state change recomputes it.
NOT instant-specific in the data layer — instant highlight (`useInstantAnnotation`)
just makes it easy to hit (no popup friction, fast gesture). Delete + re-apply
(where the fix lives) is shared with normal highlights. `onCreateOverlay` reads
`getConfig` FRESH so it's safe; FoliateViewer onLoad re-draw only fires on
section load (not a quick delete).
**Fix:** re-check `deletedAt` at the READ site, not just at index build:
- `selectLocationAnnotations` (annotationIndex.ts): `if (item.deletedAt) continue;`
before classifying — covers both the annotations and notes lists.
- The sibling `annotationIndex.globals` loop in the Annotator re-apply effect:
`if (annotation.deletedAt) continue;` before `expandAllRenderedSections` (same
stale-snapshot hazard for global highlights).
Test: `src/__tests__/utils/annotation-index.test.ts` — build index with a styled
note, then `highlight.deletedAt = 123` in place, assert `selectLocationAnnotations`
returns `{ annotations: [], notes: [] }` (red before fix). Verified on Xiaomi 13
Pro (fuxi, WebView) via the CDP lane: real create→delete→immediate-relocate over
4 iterations left overlay count 6→7→6 each time (no orphan); overlay-count metric
proven non-blind by a stray-overlay sanity probe. See [[android-cdp-e2e-lane]].
Related: [[instant-highlight-tap-paginate]], [[global-annotation-pageturn-perf-4575]].
@@ -0,0 +1,19 @@
---
name: koplugin-bulk-download-4751
description: "koplugin Library \"Download all books\" bulk download — entry point, candidate query, and the sync/async coroutine bridge"
metadata:
node_type: memory
type: project
originSessionId: b474b24d-cfa5-4f32-b6f2-d6a35f27cadd
---
Issue #4751: bulk "download all" for the readest.koplugin Library view (parity with Readest web/desktop "download all"). Branch `feat/koplugin-bulk-download-4751`, PR #4765 (base main).
- Entry point: view-menu Actions section in `library/libraryviewmenu.lua` → calls `require("library.librarywidget").downloadAll()` (no args; reads `M._opts`/`M._store` like `M.refresh()`).
- Candidate set: new `LibraryStore:listCloudOnlyBooks()` = `cloud_present=1 AND local_present=0 AND deleted_at IS NULL AND uploaded_at IS NOT NULL` (phantom records with no uploaded file are excluded, same as `listBooks`). Whole library, ignores active search/group. Test-first in `librarystore_spec.lua`.
- Orchestration `M.downloadAll()`: sequential reuse of `syncbooks.downloadBook`, inside `Trapper:wrap`. Progress + cancel via `Trapper:info("Downloading %1 of %2…")` — it yields to UIManager, so a tap queued during the previous (blocking) download is processed at the book boundary and raises Trapper's Abort/Continue confirm (returns false → cancel). Skip per-book failures, count them, show a summary toast. Only `Trapper:clear()` when NOT cancelled (abort path already closed the widget).
- **Sync/async cb bridge** (the non-obvious bit): `downloadBook`'s callback fires exactly once but may be synchronous (token fresh) OR async (after token refresh). In the cb, resume the coroutine only `if coroutine.status(co) == "suspended"`; capture result + a `finished` flag, and only `coroutine.yield()` `if not finished`. This avoids "resume non-suspended coroutine" errors in the sync case and correctly awaits in the async case. Reusable for any callback-style KOReader API awaited inside a Trapper coroutine.
- i18n: 6 new `_()` strings, `T(_("… %1 …"), ...)` interpolation (`local T = require("ffi/util").template`). Ran `node scripts/extract-i18n.js`; translated all 33 locales via [[i18n-koplugin]] flow. Verify: placeholders `%1/%2/%3` preserved (no `%s/%d`), `…` U+2026 kept.
- Note: the per-book long-press sheet already had a "Download All" (cover+file for ONE book) — left as-is; distinct from the new bulk "Download all books".
Gates: `pnpm lint:lua` + `pnpm test:lua` (see [[verify-format-check-gate]] / verification.md). No JS/TS/Rust changes.
@@ -0,0 +1,18 @@
---
name: list-view-series-overflow-4796
description: "Library list view series + description text overlapped/clipped under fixed h-28, worsened by Android system font scaling"
metadata:
node_type: memory
type: project
originSessionId: 8645710b-673d-422a-ad8a-e3f385057f49
---
PR #4799 (branch `fix/list-series-overflow-4796`). Reported on Pixel 10 Pro / Android 16: in library **list view**, a book that belongs to a series shows its series line and description preview overlapping and cut off.
**Root cause:** `BookItem.tsx` list-mode container used a fixed `h-28` (112px) with `overflow-hidden`. The right column stacks title + authors + (optional) series + description + a progress/actions row (`useResponsiveSize(15)` → ~19px on phones). Without a series it fits 112px; the optional series line (added in #4593/#4612) pushes the total over 112px, so the lines collide and clip. **Android applies the system accessibility font-size scale to WebView CSS text**, inflating line heights — that's what made it bad enough to report (matched the issue screenshot at ~130% scale).
**Fix:** `h-28``min-h-28` (one class). Row grows to fit; non-series rows keep 112px. List is `Virtuoso` with measured (not fixed) heights, so variable row heights are fine.
**Verification:** jsdom can't measure layout, so reproduced the exact flex markup in a real browser at normal + 130% font scale (before = overlap, after = clean). Lint + full `pnpm test` (6324 pass) + `format:check` pass.
Lesson: fixed-height list/card rows are fragile against optional metadata lines AND user font scaling. Prefer `min-h-*` when the row can virtualize. Related: [[cover-stale-inplace-mutation-memo]].
@@ -0,0 +1,27 @@
---
name: opds-popular-catalog-dedup-4782
description: "Added popular OPDS catalog still showed in Popular section (looked like a duplicate); filter it out, not just hide its Add button"
metadata:
node_type: memory
type: project
originSessionId: fd07b2a4-290b-4f10-a01d-190281571221
---
Issue #4782: adding a generic "Popular Catalog" (e.g. Project Gutenberg) to My
Catalogs left it ALSO rendering in the Popular Catalogs section → looked like a
duplicate.
Root cause in `src/app/opds/components/CatalogManager.tsx`: on add, only the
**Add button** was hidden (`{!isAdded && ...}`) — the whole card kept rendering
with its Browse button, so the entry visibly appeared in both sections.
Fix: filter added/disabled entries out of the Popular list entirely. New pure
helper `getUnaddedPopularCatalogs(popular, added)` in
`src/app/opds/utils/opdsUtils.ts` dedups by **normalized URL** (trim +
lowercase), mirroring the store's `findByUrl`. Component computes
`popularCatalogs = isOnlineCatalogsAccessible ? getUnaddedPopularCatalogs(POPULAR_CATALOGS, catalogs) : []`;
the section already auto-hides on `popularCatalogs.length === 0`, so once all
popular entries are added the whole section disappears. Tested in
`src/__tests__/app/opds/opds-utils.test.ts`.
Related: [[opds-self-link-metadata-4749]], [[opds-groups-carousel-4750]].
@@ -0,0 +1,18 @@
---
name: pdf-cbz-contrast-view-menu
description: "Contrast option in View menu for fixed-layout (PDF/CBZ) docs; per-book, CSS filter"
metadata:
node_type: memory
type: project
originSessionId: 94f785c8-9015-4140-b64d-c6177e033189
---
Added a **Contrast** stepper to the reader **View menu** (`ViewMenu.tsx`) for fixed-layout / image docs (PDF/CBZ/FXL-EPUB). Models the existing `invertImgColorInDark` / `zoomLevel` pattern. Increase/decrease/reset (+ / / ◐%), gated inside the `rendition?.layout === 'pre-paginated'` block, placed right under the Zoom Level control.
**Key wiring (mirror this for any future fixed-layout image adjustment — brightness, saturation):**
- Type: `contrast: number` in `BookStyle` (`types/book.ts`, fixed-layout section). Default `contrast: 100` in `DEFAULT_BOOK_STYLE` (`constants.ts`); also `MIN_CONTRAST=50`/`MAX_CONTRAST=300`/`CONTRAST_STEP=10`.
- Filter applied in `applyFixedlayoutStyles()` (`utils/style.ts`) on the `img, canvas` rule. **GOTCHA:** CSS `filter` is a single property — a second `filter:` line overrides the first. Build ONE declaration: collect `invert(100%)` (dark+invert) and `contrast(${c}%)` (c!==100) into an array, join with spaces. invert/contrast commute so order is irrelevant. Contrast applies in light mode too (independent of dark/invert).
- **Local to current document:** `saveViewSettings(envConfig, bookKey, 'contrast', value, /*skipGlobal*/ true, /*applyStyles*/ true)`. `skipGlobal=true` forces the per-book branch (`applyViewSettings(bookKey)`) regardless of `isGlobal`, so it never touches `globalViewSettings`.
- **Re-apply on change:** add `viewSettings?.contrast` to the dependency array of the `FoliateViewer.tsx` effect (~L829) that calls `applyFixedlayoutStyles` on every rendered doc. New pages pick it up via the on-load `applyFixedlayoutStyles(detail.doc, viewSettings)` call (~L321). Re-render is driven by `setViewSettings` updating `bookDataStore` config → parent `BooksGrid` re-renders FoliateViewer.
Test: `src/__tests__/utils/fixed-layout-styles.test.ts` (new) asserts the combined `filter: invert(100%) contrast(150%)` and the no-filter-at-100% cases. The settings dialog `ColorPanel.tsx` was intentionally NOT touched — request was View menu only. Related: [[tap-to-open-image-table-4600]], css/style hub `src/utils/style.ts`.
@@ -10,7 +10,7 @@ metadata:
Issue #4700 (FR: Proofread enhancements) — SHIPPED, merged to main via PR #4708. The proofread (校对/替换规则) find-replace feature lives in: data model `ProofreadRule` in `src/types/book.ts`; store `src/store/proofreadStore.ts`; engine `src/services/transformers/proofread.ts`; selection popup `src/app/reader/components/annotator/ProofreadPopup.tsx`; manager dialog `src/app/reader/components/ProofreadRules.tsx` (mounted in `Reader.tsx`); sidebar entry `BookMenu.tsx`.
What shipped (all test-first, full suite green):
1. **Sync** — added `'globalViewSettings.proofreadRules'` to `SETTINGS_WHITELIST` in `src/services/sync/adapters/settings.ts` (whole-field LWW). KEY INSIGHT: book- and selection-scope rules ALREADY sync because `src/utils/transform.ts` JSON-serializes the entire `viewSettings` into the synced book config (`configs`/`progress` category). Only library/global rules were stranded — they live in `settings.globalViewSettings.proofreadRules`.
1. **Sync** — added `'globalViewSettings.proofreadRules'` to `SETTINGS_WHITELIST` in `src/services/sync/adapters/settings.ts` (whole-field LWW). ⚠️ CORRECTION (the original "KEY INSIGHT" here was WRONG): book/selection-scope rules were PUSHED (serializeConfig keeps the viewSettings delta) but **silently DROPPED on pull**`useProgressSync.applyRemoteProgress` only consumed `location`/`xpointer` and discarded the rest of the synced config (the "Currently, only reading progress is synced" comment). So per-book/selection rules did NOT actually propagate across devices until the fix below. Library/global rules sync independently via the settings replica. See [[proofread-per-book-crdt-sync]].
2. **Regex** — the transformer ALREADY fully supported `isRegex`; only UI was missing. Added a Regex toggle to the selection popup AND a full "Add Rule" form (pattern/replacement/scope Book|Library/Regex/Case-sensitive) to the manager dialog, validated via `validateReplacementRulePattern`. Popup skips the whole-word validation when regex is on.
3. **i18n** — the whole-word warning in ProofreadPopup was a hardcoded English string (root cause of issue's point #2: Chinese user couldn't read it, thought symbols couldn't be replaced). Wrapped in `_()` + 8 new keys translated across all 33 locales via `pnpm i18n:extract`.
4. **Shortcut** — reused the existing `onProofreadSelection` (`ctrl+p`/`cmd+p`). `handleProofread` in `Annotator.tsx` now opens the rules manager (`setProofreadRulesVisibility(true)`) when there's no active selection, and opens the create-from-selection popup when there is. No new shortcut entry, no first-level toolbar button (maintainer said skip). NOTE: first attempt used a dedicated `opt+p`/`alt+p` action — reverted because macOS Option+P is a dead-key (emits `'π'`, not `'p'`; `useShortcuts` matches on `event.key`). Ctrl+P avoids that entirely. The Annotator selection shortcuts have no unit-test harness (same as onTranslate/onDictionary), so this wiring isn't unit-tested; `setProofreadRulesVisibility` itself is covered by ProofreadRules.test.tsx.
@@ -0,0 +1,62 @@
---
name: proofread-per-book-crdt-sync
description: "Per-book/selection proofread rules now CRDT-merge by id on config pull (was dropped); tombstone-on-delete"
metadata:
node_type: memory
type: project
---
MERGED via PR #4781 (2026-06-25, squash commit 79ae8a48).
Per-book + selection-scope proofread rules now actually sync across devices via an
item-level CRDT merge (keyed by rule `id`), mirroring how booknotes merge. Before
this, `useProgressSync.applyRemoteProgress` pulled the full synced book config but
only applied `location`/`xpointer`, dropping `viewSettings.proofreadRules` (and
everything else). Library/global-scope rules sync separately via the settings
replica (`adapters/settings.ts` whitelist, whole-field LWW) — see [[proofread-enhancements-4700]].
**Design (per maintainer):** no new DB table — the rules keep riding the existing
book-config blob; the pull side just stops discarding them and merges by id instead.
What changed:
- `ProofreadRule` (`types/book.ts`) gained `updatedAt?: number` (LWW key) and
`deletedAt?: number | null` (tombstone). No `createdAt` (the existing `order` covers ordering).
- New pure `mergeProofreadRules(local, remote)` in `src/utils/proofread.ts` — by id,
LWW on updatedAt/deletedAt, identical semantics to `mergeNotes` in WebDAVSync.ts.
- `proofreadStore.ts`: stamps `updatedAt` on add/update/toggle/reorder; **`removeBookRule`
now TOMBSTONES (sets deletedAt) instead of splicing** so the per-id merge can't
resurrect a deleted rule from the peer's live copy. `removeGlobalRule` STAYS a
hard-splice — library deletion already propagates via the settings replica's
whole-field LWW (shrinking the array wins), so a tombstone there would just leave
dead entries. Getters (`getBookRules`/`getGlobalRules`/`getMergedRules`) and the
book-scope dedup filter out `deletedAt`.
- `transformers/proofread.ts`: render filter gained `!r.deletedAt`.
- `ProofreadRules.tsx` `useReplacementRules`: filters `deletedAt` so tombstoned rules
don't show in the manager list.
- `useProgressSync.applyRemoteProgress`: merges `syncedConfig.viewSettings?.proofreadRules`
(filtered to scope !== 'library') into the open book's rules, `setViewSettings` +
`saveConfig`, and `recreateViewer` ONLY when the merged array actually differs (guards
a reflow on no-op pulls).
Convergence gotcha (why the push re-uploads the union): `bookDataStore.saveConfig` only
merges `{updatedAt}` into the in-memory config — it does NOT write the passed viewSettings
into `booksData`. The thing that syncs merged viewSettings into `booksData.config` (so the
next `pushConfig``getConfig` serializes the union) is `readerStore.setViewSettings`, but
only when the viewState `isPrimary`. So call order must be setViewSettings → saveConfig
(same as `proofreadStore.updateBookViewSettings`).
**Stable id (`ensureRuleId` in utils/proofread.ts):** the merge keys on `id`, so id-less
rules (legacy / hand-edited / foreign peer) would ALL collide on the Map's `undefined`
slot — distinct rules clobber each other (silent loss, NOT duplication). `ensureRuleId`
backfills a missing id with a content hash `ph-${md5(scope|isRegex|pattern)}` (selection
scope also folds in sectionHref+cfi since it's per-instance), applied on both sides inside
`mergeProofreadRules`. `createProofreadRule` now seeds book/library ids the same way
(`id = scope==='selection' ? uniqueId() : ''` then `ensureRuleId`) so the SAME rule made
independently on two devices dedupes on sync instead of duplicating; selection rules keep
`uniqueId` (per-instance). Identity excludes replacement/case/wholeWord to match the
in-store dedup (pattern+isRegex). Ids are assigned ONCE and frozen — edits never re-key
(updates omit `id`). Limitation: rules already created with the old random `uniqueId` keep
those ids, so pre-existing identical rules across devices are NOT retroactively merged.
WebDAV does NOT carry proofread rules: its wire envelope strips viewSettings (`buildRemotePayload`),
so this only fixes the native cloud sync path. WebDAV would need un-stripping + the same merge.
@@ -0,0 +1,24 @@
---
name: russian-hanging-prepositions-nbsp-4769
description: "Russian hanging-preposition NBSP transformer; generic per-language, lang-gated, no toggle"
metadata:
node_type: memory
type: project
originSessionId: 423131fb-8192-4055-b617-3f79d412e258
---
Issue #4769: Russian typography forbids short function words (prepositions/conjunctions/particles) hanging at the end of a line ("hanging preposition"). Fix = a content transformer that inserts U+00A0 after such words so they stick to the next word. Source file is never modified.
**Where:** `src/services/transformers/nbsp.ts` (export `nbspTransformer`, name `'nbsp'`), registered in `transformers/index.ts`, added to the FoliateViewer pipeline AFTER `simplecc`, before `proofread` — must run after `whitespace` (which strips NBSP when `overrideLayout`) or the glue is undone. (Originally named `russianNbsp` / `russianNbspTransformer`; renamed generic so it's the home for NBSP across languages.)
**Generic by language:** internally a `NBSP_LANGUAGES: Record<langCode, {script, shortWords}>` registry; gate = `NBSP_LANGUAGES[normalizedLangCode(ctx.primaryLanguage)]` (so `ru-RU` -> `ru`; returns content unchanged if no entry). Only `ru` configured today; adding another language = one registry entry (its Unicode script name + a 3+ letter function-word list).
**Gating decision (user, via AskUserQuestion):** language gate ONLY, no settings toggle (deliberately skipped the issue's requested toggle to keep scope in `services/transformers`). Belarusian/Ukrainian/Bulgarian (also Cyrillic) are NOT included — `ru` only.
**Algorithm:** regex on the raw HTML string (NOT a DOM round-trip — avoids restructuring XML decl/doctype for every section, unlike `proofread`/`sanitizer` which parse+serialize). `TEXT_OR_SKIP = /<(style|script)\b[^>]*>[\s\S]*?<\/\1>|>([^<]+)</gi` skips style/script blocks and only rewrites text between tags, leaving tags/attrs/entities byte-for-byte intact.
- Glue regex (built per language from `config.script` + `config.shortWords`): `(^|[^\p{L}])(<3+ letter words>|\p{Script=<script>}{1,2}) (?=[\p{Script=<script>}\p{N}])` -> replace `$1$2` + NBSP. 1-2 letter words of the script glue generically; 3+ letter function words need the explicit list (content nouns excluded so we never glue after them).
- No look-behind ([[feedback_no_lookbehind_regex]]): capture+re-emit the boundary char instead. Because the boundary is consumed, consecutive short words ("и в доме") need a loop-until-stable (`do/while result!==prev`); NBSP is in `[^\p{L}]` so a just-inserted NBSP counts as the next boundary.
**Known limitation (accepted):** postfix particles же/бы/ли glue FORWARD (to next word) not backward (to preceding word) — still prevents end-of-line hang, which is the issue's actual concern. Prepositions before digits glue too ("в 2025", "около 5").
**Authoring gotcha:** typing literal NBSP (U+00A0) into tool inputs near Cyrillic silently produced many stray NBSP bytes in source. Always write NBSP as the ` ` escape in JS source; normalize files with a Python `chr(0xA0)->chr(0x20)` pass then restore the one intended escape. Verify with `python3 -c "...read().count(chr(0xA0))"`, not shell `grep $' '` (matches regular spaces). Same applies to test assertions: define `const NBSP = ' '` and build expectations via template literals.
@@ -10,7 +10,7 @@ metadata:
agreed the original "foundational Turso-cached engine + searchBook agent tool" was over-scoped).
Decision = **phase it**.
**PR-1 (done, branch `feat/search-modes-regex-nearby-4560`, worktree `/Users/chrox/dev/readest-feat-search-modes-regex-nearby-4560`):**
**PR-1 (MERGED: readest#4764 + foliate-js#38):**
adds `regex` + `nearby-words` modes INSIDE the foliate submodule `packages/foliate-js/search.js`
(`regexSearch`, `nearbyWordsSearch`, `mode` dispatch in `search()`/`searchMatcher`); per-word `cfis`
+ annotation dedupe in `view.js`; `BookSearchConfig.mode`/`nearbyWords` + `BookSearchMatch.cfis` +
@@ -13,8 +13,13 @@ EPUBs a single tap on an `<img>` / `<svg>`-with-`<image>` / `<table>` now opens
- **Fixed-layout** (PDF/comics/manga, `bookData.isFixedLayout`) keeps tap-to-turn —
there the tap IS the page-turn gesture.
- **Long-press** is unchanged everywhere; **linked images** (inside `<a>`) still
follow the link (the existing `sup, a, audio, video` skip).
- **Long-press** is unchanged everywhere.
- **UPDATE #4757:** **linked images** (inside a plain `<a>`) now ALSO zoom on single
tap instead of following the link (was the `sup, a, audio, video` skip). Impl:
`postSingleClick` computes `media = !isFixedLayout && !footnote ? detectMediaTarget(element) : null`
up front, and the `<a>` early-return guard gains `!media &&` so a media target bypasses it.
**Footnotes are excluded** (`!footnote`) so footnote anchors keep popup/navigation. The
later dispatch reuses that `media` (no second `detectMediaTarget` call).
Impl in `src/app/reader/utils/iframeEventHandlers.ts`:
- New shared `detectMediaTarget(el) -> {elementType:'image',src} | {elementType:'table',html} | null`,
@@ -0,0 +1,31 @@
---
name: webdav-browse-sort-search-4724
description: "WebDAV browser sort + search feature (#4724, PR"
metadata:
node_type: memory
type: project
originSessionId: 0b4c38bb-34ca-4446-a77a-cd967f88b40e
---
Sort + search for the WebDAV browse pane (Settings > Integrations > WebDAV), issue #4724. MERGED PR #4786 (merge commit `13e0fb814`).
NOTE: rebased onto the #4784 provider refactor late in the work, which MOVED the WebDAV client — paths below reflect the merged (post-#4784) layout, not the original branch.
**Where the pieces live:**
- `services/sync/providers/webdav/client.ts` (was `services/webdav/WebDAVClient.ts`, moved by #4784) — PROPFIND body now requests `<D:creationdate/>`; `WebDAVEntry` gained optional `created`. The whole folder is fetched in one `PROPFIND Depth:1` (no pagination) so sort/filter are pure client-side.
- `components/settings/integrations/webdavBrowseUtils.ts` — pure, unit-tested `sortWebDAVEntries(entries, sortBy, ascending, getName?)` and `filterWebDAVEntries(entries, query, getName?)`.
- `types/settings.ts``WebDAVBrowseSortByType = 'name'|'modified'|'created'|'size'`; persisted `WebDAVSettings.browseSortBy` + `browseSortAscending` (both optional; absent => name/ascending = legacy order, no migration).
- `WebDAVBrowsePane.tsx` — controls row (filter box + sort select + ↑/↓ toggle), normal-mode only (hidden in cleanup mode). Persists sort via new optional `onUpdateSettings` prop, wired in `WebDAVForm` to its existing `persistWebdav`.
**Decisions worth remembering:**
- Directories always group first; within a group, sort by field. Entries missing the field (undated / sizeless dirs) sink to the bottom in BOTH directions; stable tiebreak by display name.
- `getName` resolver maps a per-hash book dir to its local library title, so sort "by name" and the filter both operate on the visible title, not the hash.
- Search is transient (reset on folder navigation/refresh); only the sort preference persists.
- Row shows the active sort key's date: created vs modified, so the order is legible.
**Gotchas:**
- Two consecutive refactors landed under this feature: #4774 removed `SyncHistoryPanel`/`syncLog`; then #4784 (provider-agnostic FileSyncEngine) moved `WebDAVClient.ts``services/sync/providers/webdav/client.ts` and swapped the pane to `createWebDAVProvider` + `deleteRemoteBookDir(provider, hash)` + `FileSyncError` + `SYNC_BOOKS_DIR`. The rebase auto-carried my `creationdate` change into the moved client via git rename detection; only the pane import block + locale tails conflicted. Re-read post-refactor files before editing.
- Not every WebDAV server returns `<creationdate>`. The home test server at `192.168.2.3:6065` returns `getlastmodified` for all entries but NO `creationdate` (PROPFIND-confirmed 0/675), so "Date created" degrades gracefully to a dateless stable name order. Verified on a physical Xiaomi via `pnpm dev-android` + adb/CDP.
- i18n: 32 locales translated for the 6 new keys; `bo` (Tibetan) left to English fallback. The i18n scanner also surfaced ~20 unrelated pre-existing untranslated keys/locale from other features — deliberately reverted and NOT bundled into this PR (scope).
Related: [[koplugin-bulk-download-4751]], [[opds-groups-carousel-4750]].
@@ -0,0 +1,55 @@
---
name: webdav-connect-nullified-4780
description: "WebDAV connection lost after app restart (#4780) — useSync pull finally saved stale closure settings"
metadata:
node_type: memory
type: project
originSessionId: 5a1fa597-f371-4023-a342-2b04a4ad5cd5
---
# WebDAV "doesn't connect persistently" (#4780)
**Symptom:** Settings → Integration → WebDAV connects fine, but after closing and
reopening the app the connection reads back as "Not connected". Reported on
Android 16 (Motorola RAZR 50), "Latest" version.
**Root cause:** `src/hooks/useSync.ts` `pullChanges` persisted the **stale
hook-closure `settings`** (destructured at line ~63 per render), not the live
store state. The author had already fixed the in-`try` `setSettings` path by
re-reading `useSettingsStore.getState().settings` (block-scoped `const settings`
inside the try), but the `catch` (`Not authenticated``keepLogin=false`) and
the `finally` (`saveSettings(envConfig, settings)`) still wrote the stale outer
closure. When a settings change lands **during an in-flight pull** — most
visibly a WebDAV connect — the pull's `finally` overwrites `settings.json` on
disk with the pre-change snapshot, wiping the connect.
**Why WebDAV specifically / why Android / why "consistent":**
- WebDAV is the **only integration credential NOT in the replica
`SETTINGS_WHITELIST`** (`src/services/sync/adapters/settings.ts`). kosync /
readwise / hardcover get re-hydrated from the server replica on next launch
(`applyRemoteSettings`), so a local clobber is invisible for them. WebDAV has
no server copy → the clobber sticks.
- Only fires for **logged-in** users — all `useSync` pulls are gated on `user`
(`useBooksSync.ts`: `pullLibrary` runs on library mount, `handleAutoSync`
periodically). A WebDAV-only user with no Readest account never hits it.
- Android's slower network/IO widens the `await syncClient.pullChanges(...)`
window, so connecting right after opening the app reliably overlaps the
mount-pull → appears consistent. On fast desktop the window is ~µs.
**Fix:** in `pullChanges`, read live state in BOTH the catch (`const latest =
useSettingsStore.getState().settings`) and the finally
(`saveSettings(envConfig, useSettingsStore.getState().settings)`). General fix —
preserves any concurrent settings change, not just WebDAV.
**Test:** `src/__tests__/hooks/useSync-stale-settings-clobber.test.tsx`
renderHook + a deferred `syncClient.pullChanges`; capture `pullChanges` while the
store holds disabled-WebDAV (binds the stale closure), swap the live store to an
enabled-WebDAV object (models the connect mid-pull), resolve the pull, assert the
persisted settings have `webdav.enabled === true`.
**Pattern (recurring):** zustand `settings` destructured from the store hook goes
stale across an `await`; any `saveSettings`/`setSettings` of that closure after
the await clobbers concurrent writes. Always persist
`useSettingsStore.getState().settings` after async work. See the in-place
mutation cousin [[cover-stale-inplace-mutation-memo]] and the WebDAVForm
`persistWebdav` comment that reads live state for the same reason.
@@ -0,0 +1,59 @@
---
name: webdav-filesync-refactor-plan
description: Planned refactor extracting a provider-agnostic file-sync engine (LWW/CRDT) so WebDAV/Drive/Dropbox/FTP/SFTP reuse it
metadata:
node_type: memory
type: project
originSessionId: a9e2f86a-c773-4f5d-95d7-4451d332de5d
---
**STATUS: MERGED as PR #4784** (squash commit `99b9adfe8` on `origin/main`). Branch `refactor/file-sync-engine` (worktree `/Users/chrox/dev/readest-refactor-file-sync-engine`), 11 commits off `origin/main` (cecb1c53). All gates green: `pnpm test` 6244 pass / 9 skip / 0 fail, `pnpm lint` + `pnpm format:check` clean. NOT yet pushed / PR'd (awaiting user confirm). Decisions from AskUserQuestion: shared LocalStore bridge + single PR.**
**As-built structure** (matches the proposal below):
- `src/services/sync/file/`: `provider.ts` (`FileSyncProvider`/`FileEntry`/`FileHead`/`FileSyncError{code:AUTH_FAILED|NOT_FOUND|NETWORK|CONFLICT|UNKNOWN,status?}`), `layout.ts` (de-WebDAV'd `SYNC_*` consts; paths pure), `wire.ts` (`RemoteBookConfig`/`RemoteLibraryIndex` + build/parse; `writerVersion:'readest-webdav-1'` FROZEN), `merge.ts` (`mergeNotes`/`mergeBookConfig`/`mergeBookMetadata`/`isRemoteBookMetadataNewer` + law tests), `localStore.ts` (`LocalStore` iface), `appLocalStore.ts` (`createAppLocalStore({appService,settings,envConfig})` — the shared bridge that killed the duplicated buffered+streaming loaders), `engine.ts` (`FileSyncEngine(provider,store)` class + free `deleteRemoteBookDir(provider,hash)`), `index.ts`.
- `src/services/sync/providers/webdav/`: `client.ts` (moved WebDAVClient), `WebDAVProvider.ts` (`createWebDAVProvider(settings)`; maps WebDAVRequestError→FileSyncError; Tauri-only `uploadStream`/`downloadStream` own URL+auth via tauriUpload/Download), `connectSettings.ts` (moved).
- Consumers: `useWebDAVSync.ts` + `WebDAVForm.tsx` build provider+store+engine (`engine.pushBookConfig/pullBookConfig/pushBookFile/pushBookCover` / `engine.syncLibrary(books,{strategy,syncBooks,deviceId,onProgress})`); `WebDAVBrowsePane.tsx` builds a provider for `deleteRemoteBookDir(provider,hash)`. `useWebDAVSync`'s `{pushNow,pullNow}` external API unchanged.
- Tests: new `sync/file/{layout,wire,merge,provider-conformance,engine-metadata-sync}.test.ts` (engine test = retargeted #4756 gate via fake provider+store); `webdav-{encode-path,connect-settings,delete}.test.ts` repointed; old `webdav-metadata-sync.test.ts` deleted; old `src/services/webdav/` removed entirely.
- Behaviour preserved: syncLibrary ported line-for-line (strategy gating, hash-dir discovery, HEAD short-circuits, streaming heap-pressure path, #4756 metadata LWW + config pull-merge-push). Callback guards that were `if(options.x)` became unconditional because the store always supplies them (form provided all; equivalent).
- Plan doc: `.agents/plans/2026-06-25-file-sync-engine-refactor.md`.
**Post-implementation `/autoplan` review (Codex + Claude eng subagent + my pass), 3 follow-up commits added:**
- `fix(sync)` **data-loss (Codex HIGH, pre-existing, not a refactor regression)**: `WebDAVForm.handleSyncNow` loaded the library locally but never `setLibrary`'d, so engine `addBookToLibrary`/`updateBookMetadata` merged against an EMPTY zustand store and persisted a downloaded book / metadata update as the *entire* library, wiping `library.json`. Trigger: Sync now while `libraryLoaded===false` (launched into reader/settings) + a remote download/metadata-newer. Fix = `setLibrary(currentLibrary)` in handleSyncNow + load-if-unloaded guard in `appLocalStore.addBookToLibrary`/`updateBookMetadata` (mirrors the existing `useLibraryStore.updateBooks` hardening). `setLibrary` sets `libraryLoaded:true`. New `appLocalStore.test.ts` regression test.
- `refactor(sync)` **list() error contract**: `client.ts` `listDirectory` threw a plain `Error` (and let raw fetch rejects escape), so `WebDAVProvider.mapError` flattened all `list()` failures to `FileSyncError(UNKNOWN)`. Now throws the same `WebDAVRequestError` taxonomy as the file-level helpers (AUTH_FAILED/NOT_FOUND/NETWORK). Harmless before (both engine `list()` sites only `console.warn`) but violated the `provider.ts` contract. Added `list()` conformance cases.
- `test(sync)` **engine path coverage**: the metadata gate only hit buffered metadata+config paths. Added `engine-sync-paths.test.ts` for streaming `uploadStream` (+HEAD short-circuit +one-shot retry), remote discovery→`downloadStream`→addBook, and `receive` strategy (no writes).
- Review verdict: refactor itself is a faithful, clean port (no regressions in the port). Findings were 1 pre-existing data-loss bug + 1 contract gap the refactor introduced + test gaps. All green: `pnpm test` 6254 pass, lint, format:check. Still NOT pushed/PR'd.
**Follow-up feature commit `feat(sync): incremental WebDAV Sync now + bounded concurrency`:**
- **Incremental by default**: `engine.syncLibrary` was a full walk of all books each run. Now diffs local vs remote `library.json` per hash: push only `local.book.updatedAt > remoteIndex.book.updatedAt` (or local-only); skip equal; remote-newer books pull config in the reconcile pass (so peer progress still propagates without re-walking). Key fact: `book.updatedAt` bumps on EVERY progress/notes/metadata save (`bookDataStore.saveConfig` rewrites the book w/ `updatedAt: now` + re-persists library.json), so the index is a reliable per-book change cursor. Boundary: a peer's `config.json` pushed by their reader-hook but not yet reflected in the index (their index entry stale) is missed until Full Sync or book-open — the reader hook doesn't rewrite library.json per page-turn. `send` mode (no index pull) → push all.
- **Full Sync toggle** (`settings.webdav.fullSync`, default off) in WebDAVForm → re-checks every book (the old full behavior). New `SettingsSwitchRow` "Full Sync".
- **Bounded concurrency** default 4: reconcile/download/push phases run over a `runPool(items, limit, worker)` (shared-cursor worker loops) instead of sequential. `concurrency` engine option.
- Tests: incremental skip/push/pull/fullSync + concurrency cap (maxInFlight===limit) in `engine-sync-paths.test.ts`; #4756 config-merge tests retargeted to local-newer (the push case where merge-before-push matters). `engine.ts` runPool + `isLocalNewer`.
- **Live Chrome verify** (web dev build vs user's real 192.168.2.3:6065 WebDAV, 675 books): full sync crawled 115→222→301; incremental Sync now finished with NO progress crawl (Last synced 11:15→15:26:55, instant); Full Sync toggle renders; browse pane lists /Readest (books/ + library.json 802KB); zero console errors. All gates green: `pnpm test` 6261 pass, lint, format:check.
- Remaining/future (unchanged from below): per-field LWW on config scalars; content-hash cover (coverHash #4544) for WebDAV; an actual 2nd provider; `webdavSyncStore`/`webdavBrowseUtils` stay WebDAV-named. Manual runtime check against a real WebDAV server/device is the only thing the automated gates can't cover.
---
ORIGINAL PLAN (design proposed, since implemented as above). Follows merged [[webdav-metadata-sync-4756]].
**Goal**: extract a provider-agnostic file-based sync engine from the WebDAV-specific transport so future providers (Google Drive / Dropbox / FTP / SFTP) only implement a small file-ops interface; all merge + orchestration lives in one base service. Refactor WebDAV as the FIRST provider.
**User decisions (locked via AskUserQuestion)**:
- Scope = **Extraction + improve semantics** (make LWW/CRDT first-class; user phrase "autoplan" = drive it forward).
- Streaming lives **inside the provider interface** (provider owns URL+auth; the settings form stops knowing WebDAV URLs).
- **Fresh separate branch / PR.** Now that #4776 is merged, branch the refactor off **updated `origin/main`** (no longer off the fix branch — main already has the fixes). Use `pnpm worktree:new`.
**Current seam (already fairly clean)**: `src/services/webdav/WebDAVSync.ts` (~1140 LOC) = orchestration + merge + wire formats (provider-agnostic). `WebDAVClient.ts` (~593) = transport. `WebDAVPaths.ts` = layout (pure fns of rootPath; only `normalizeRootPath`/URL-building are WebDAV-specific). Engine only calls 8 client primitives: getFile/getFileBinary/putFile/putFileBinary/headFile/listDirectory/ensureDirectory/deleteDirectory (+ buildRequestUrl/buildBasicAuthHeader for streaming). Reader hook `app/reader/hooks/useWebDAVSync.ts` (~604) calls pull/pushBookConfig directly. `WebDAVForm.tsx` wires app callbacks + streaming (tauriUpload/Download).
**Proposed structure**:
- `src/services/sync/file/`: `provider.ts` (FileSyncProvider iface, FileEntry, FileSyncError{code:AUTH_FAILED|NOT_FOUND|NETWORK|CONFLICT|UNKNOWN,status?}), `layout.ts` (de-WebDAV'd Readest/books/<hash> paths), `wire.ts` (RemoteBookConfig/RemoteLibraryIndex), `merge.ts` (pure LWW/CRDT), `engine.ts` (FileSyncEngine class over a provider), `index.ts`.
- `src/services/sync/providers/webdav/`: WebDAVProvider (implements iface incl. uploadStream/downloadStream), moved WebDAVClient, webdav-only paths, connectSettings.
**FileSyncProvider** (~10 methods): `rootPath`; readText/readBinary/head/list (null on 404); writeText/writeBinary/ensureDir(paths[])/deleteDir; optional uploadStream(remotePath,localPath)/downloadStream(remotePath,localPath)→bool (provider owns URL+auth, falls back to buffered writeBinary/readBinary when absent). **App callbacks stay engine options** (local I/O via appService/stores): loadConfig, saveBookConfig, loadBookCover/saveBookCover, addBookToLibrary, updateBookMetadata, plus new resolveLocalBookPath(book) for streaming.
**merge.ts declarative policy** (the "improve semantics" payload — each pure + own tests): `mergeNotes` = CRDT (union by id, per-note updatedAt, deletedAt tombstones); `mergeBookConfig` = LWW scalars (config.updatedAt) + notes via mergeNotes; `mergeLibraryIndex` = CRDT membership (union by hash + tombstones) + per-book metadata LWW (book.updatedAt); `mergeBookMetadata` = LWW (book.updatedAt). Route BOTH reader hook and library "Sync now" through the SAME pull→merge→push so read-merge-write is structural, not a special case (the #4756 commit-2 fix).
**Out of scope (note as future)**: per-field LWW on config scalars (needs wire-format field timestamps), content-hash cover detection (coverHash #4544) for WebDAV, an actual 2nd provider impl. `WebDAVBrowsePane` keeps using the client directly (WebDAV-specific browse UI).
**Behavior-preservation testing**: existing `webdav-*` tests stay green (now via WebDAVProvider); retarget `webdav-metadata-sync.test.ts` to the engine; ADD pure `merge.test.ts` (commutativity/idempotence/tombstone laws) + a provider-conformance suite reusable by future providers.
Stale worktree `/Users/chrox/dev/readest-fix-webdav-sync-stale-4756` (branch merged) can be removed via `pnpm worktree:rm`.
@@ -0,0 +1,29 @@
---
name: webdav-metadata-sync-4756
description: WebDAV syncLibrary never refreshed metadata for already-local books; LWW reconciliation on book.updatedAt
metadata:
node_type: memory
type: project
originSessionId: a9e2f86a-c773-4f5d-95d7-4451d332de5d
---
MERGED as PR #4776 (squash commit `cd3a53f50`) into `origin/main` on 2026-06-25. See [[webdav-filesync-refactor-plan]] for the follow-up refactor.
Issue #4756: after a device already holds a book, mobile's later cover/title edits never reached it.
**Root cause** in `services/webdav/WebDAVSync.ts` `syncLibrary`: the pull/download path only processed hashes NOT already in the local library (`!allBooksMap.has(...)`). Already-local books were push-only, so a peer's metadata edit never came down. Worse, the final `pushLibraryIndex` re-wrote `library.json` from `allBooksMap` (still the stale local book), clobbering the peer's newer metadata on the remote.
**Fix**: added an LWW reconciliation pass keyed on `book.updatedAt` (driven by the shared `library.json` index, NOT the per-hash `config.json`). For books present BOTH locally and in the remote index, when `remote.updatedAt > local.updatedAt`: merge metadata (`mergeRemoteBookMetadata` = title/author/metadata/primaryLanguage/updatedAt only — preserves `sourceTitle`/`filePath`/`coverImageUrl`/progress), re-pull `cover.png`, persist via a NEW `updateBookMetadata` option callback, and `allBooksMap.set(hash, merged)` so the index re-push keeps the newer copy. New `SyncLibraryResult.metadataUpdated` counter surfaced in toast + SyncHistoryPanel.
**Key facts**:
- Metadata edits (cover/title) bump `book.updatedAt` via `getBookWithUpdatedMetadata` (utils/book.ts) — that's the LWW signal. `mergeRemoteBookMetadata` mirrors exactly that field list.
- Title edits are a pure metadata op: remote book file is named by `sourceTitle||title` (`buildBookFileName`) inside a hash dir, so a title change never renames the remote file. Don't merge `sourceTitle` (it ties to the on-disk filename).
- No push-side cover clobber: after pulling the cover, the push-loop `pushBookCover` HEAD/size short-circuit matches (local now == remote) and skips.
- `updateBookMetadata` is distinct from `addBookToLibrary` (which no-ops on an existing hash). Wired in `WebDAVForm.tsx` via `useLibraryStore.updateBook`.
- Reconciliation gated on `canPull` so `strategy:'send'` stays push-only.
**Wire format / merge model** (`RemoteBookConfig` envelope): the remote `config.json` is NOT a blob-LWW'd `BookConfig`. `buildRemotePayload` hoists `booknotes` to a top-level sibling of `config` (which is trimmed to progress/location/xpointer/updatedAt). In `pullBookConfig` the `config.updatedAt` LWW decides ONLY the scalars; `mergedConfig.booknotes = mergeNotes(...)` runs unconditionally (element-set CRDT: union by `id`, per-note `updatedAt`, `deletedAt` tombstones). So co-located in one file, merged by two strategies. Library membership in `library.json` = union-by-hash + tombstones (CRDT); per-book metadata = `book.updatedAt` LWW.
**Follow-up fix (same branch, 2nd commit): config merge before push in `syncLibrary`.** `pushBookConfig` is a blind PUT (no server merge). The reader hook (`useWebDAVSync`) pull-merges before pushing, but the library "Sync now" push loop pushed local config blind → could drop a peer's notes or regress newer remote progress until a device opened the book. Fix: push loop now does `pullBookConfig``saveBookConfig`(merged) → `pushBookConfig`(merged superset), gated on `canPull` (`silent` converges, `send` stays blind/authoritative, `receive` never pushes). Convergence works on a lossy single-file transport because notes are a state-based CRDT (each replica holds full state + re-merges).
Related: [[grimmory-native-sync]], [[koplugin-note-deletion-sync]]. WebDAV sync is self-contained (own cover.png + HEAD/size model), separate from native cloud sync's `coverHash`/`coverUpdatedAt` content-addressed signal (#4544).
@@ -0,0 +1,36 @@
---
name: worktree-rebase-submodule-drift
description: "Rebasing a worktree onto an origin/main that bumped foliate-js leaves the submodule at the old commit; pre-push full-suite fails on the new submodule's tests"
metadata:
node_type: memory
type: project
originSessionId: 33b70e98-fb55-467a-b03f-e4065491bc7e
---
When a `pnpm worktree:new` worktree is rebased onto a newer `origin/main` that
**bumped a submodule pointer** (e.g. `packages/foliate-js`), the worktree's
submodule working tree stays at the OLD commit. New upstream tests that import
the bumped submodule code then fail, and the **pre-push hook runs the full
`pnpm test`**, so the push is rejected (not a flake, not your diff).
Symptom seen on #4741 PR: rebase pulled in #4764 (foliate search modes, #4560)
which bumped foliate-js `20ab3ec1 -> 982f168c` and added
`foliate-search-modes.test.ts` importing `foliate-js/search.js`; 8 tests failed
because the worktree submodule was still `20ab3ec1`.
**Fix:** sync the submodule to the commit recorded in the index. The worktree's
submodule `origin` is a local `file://` path (`.../.git/modules/...`) and
`git submodule update --init` fails with `transport 'file' not allowed`. Fetch
the exact commit from GitHub instead:
```
git ls-tree HEAD packages/foliate-js # expected commit (e.g. 982f168c)
cd packages/foliate-js
git fetch https://github.com/readest/foliate-js.git <commit>
git checkout <commit>
```
Then re-run the failing test and `git status --porcelain packages/foliate-js`
(should be clean) before pushing. Run this check after ANY rebase of a worktree
when `git log <base>..origin/main -- packages/foliate-js` is non-empty.
See [[feedback_pr_rebase]], [[feedback_use_worktree]].