From ae03be96d0a52460a5a869843455393cd8886add Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sun, 28 Jun 2026 11:11:46 +0800 Subject: [PATCH] chore(agent): update agent memories (#4833) --- apps/readest-app/.claude/memory/MEMORY.md | 68 ++++++++----------- .../eink-screen-refresh-pageturner-4687.md | 30 ++++++++ ...-highlight-leak-on-annotate-cancel-4791.md | 46 +++++++++++++ .../memory/gdrive-provider-multipr-status.md | 28 ++++++++ .../memory/i18n-extract-prunes-keys.md | 36 ++++++++++ .../.claude/memory/markdown-md-support-774.md | 40 +++++++++++ .../multiwindow-settings-clobber-4580.md | 16 +++++ .../memory/pageturn-bg-replace-reflow-4785.md | 54 +++++++++++++++ .../memory/pdf-scroll-lag-preload-4795.md | 27 ++++++++ .../.claude/memory/recent-read-shelf-3797.md | 16 +++++ .../memory/scrolled-pdf-pinch-zoom-4817.md | 25 +++++++ .../memory/toc-table-heading-clip-4439.md | 40 +++++++++++ .../tts-highlight-granularity-setting.md | 20 ++++++ .../memory/webdav-credential-sync-4810.md | 44 ++++++++++++ apps/readest-app/.gitignore | 1 + .../android/app/src/main/AndroidManifest.xml | 13 ++++ 16 files changed, 464 insertions(+), 40 deletions(-) create mode 100644 apps/readest-app/.claude/memory/eink-screen-refresh-pageturner-4687.md create mode 100644 apps/readest-app/.claude/memory/empty-highlight-leak-on-annotate-cancel-4791.md create mode 100644 apps/readest-app/.claude/memory/i18n-extract-prunes-keys.md create mode 100644 apps/readest-app/.claude/memory/markdown-md-support-774.md create mode 100644 apps/readest-app/.claude/memory/multiwindow-settings-clobber-4580.md create mode 100644 apps/readest-app/.claude/memory/pageturn-bg-replace-reflow-4785.md create mode 100644 apps/readest-app/.claude/memory/pdf-scroll-lag-preload-4795.md create mode 100644 apps/readest-app/.claude/memory/recent-read-shelf-3797.md create mode 100644 apps/readest-app/.claude/memory/scrolled-pdf-pinch-zoom-4817.md create mode 100644 apps/readest-app/.claude/memory/toc-table-heading-clip-4439.md create mode 100644 apps/readest-app/.claude/memory/tts-highlight-granularity-setting.md create mode 100644 apps/readest-app/.claude/memory/webdav-credential-sync-4810.md diff --git a/apps/readest-app/.claude/memory/MEMORY.md b/apps/readest-app/.claude/memory/MEMORY.md index de4b0a42..d01c3eb5 100644 --- a/apps/readest-app/.claude/memory/MEMORY.md +++ b/apps/readest-app/.claude/memory/MEMORY.md @@ -1,12 +1,8 @@ # Readest Project Memory -## Key Reference Documents -- [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 -- [Annotator & Reader Fixes](annotator-reader-fixes.md) — highlight, selection, a11y +## Key Reference Documents (aggregators) +- [Bug Patterns](bug-patterns.md) · [CSS & Style](css-style-fixes.md) — EPUB CSS + style.ts · [TTS](tts-fixes.md) — architecture + bugs +- [Layout & UI](layout-ui-fixes.md) — insets/z-index · [Platform Compat](platform-compat-fixes.md) — Android/iOS/Linux/macOS · [Annotator & Reader](annotator-reader-fixes.md) — highlight/selection/a11y ## Safety & Security - [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 @@ -26,29 +22,25 @@ - [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` +- [TOC table heading clip (#4439)](toc-table-heading-clip-4439.md) — #4400 scroll-wrapper `overflow:auto` clips decorative table's `margin:-1em` bleed; `hoistNegativeMargins` onto wrapper +- [Page-turn bg-replace reflow (#4785)](pageturn-bg-replace-reflow-4785.md) — `#replaceBackground` rebuilt context per frame (getComputedStyle + per-view getBCR), worst at boundaries; snapshot `#bgAnimContext` once per gesture (drag+snap); defer mid-drag preload via `!#touchScrolled` ## Critical Files (Most Bug-Prone) -- `src/utils/style.ts` — central EPUB CSS transformation hub -- `packages/foliate-js/paginator.js` — page layout, image sizing, backgrounds -- `src/services/tts/TTSController.ts` — TTS state machine, section tracking -- `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 +- `src/utils/style.ts` EPUB CSS hub · `packages/foliate-js/paginator.js` layout/image/bg · `src/services/tts/TTSController.ts` TTS state machine +- `src/hooks/useSafeAreaInsets.ts` insets · `src/app/reader/components/FoliateViewer.tsx` view orchestration · `.../annotator/Annotator.tsx` annotation lifecycle ## Sync Notes - [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,...)` +- KOSync: [CFI spine resolution](kosync-cfi-spine-resolution.md) convert via CFI's own spine; [connect() false-positive #4692](kosync-connect-false-positive-4692.md) validate koreader JSON not any 2xx - [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 +- koplugin: [note deletion](koplugin-note-deletion-sync.md) `recordDeletion` tombstone; [stats #4666](koplugin-stats-sync.md) statistics.sqlite3 delta LuaSettings; [bulk download #4751](koplugin-bulk-download-4751.md) `downloadAll()`/`coroutine.status` - [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) +- [WebDAV credential sync (#4810)](webdav-credential-sync-4810.md) — `webdav.*` missing from `SETTINGS_WHITELIST`/`ENCRYPTED_FIELDS`; new nested whitelist group NEEDS matching `mergeSettings` deep-merge or shallow merge clobbers per-device siblings +- [Multi-window settings clobber (#4580)](multiwindow-settings-clobber-4580.md) — stale window overwrites shared settings.json; cross-window broadcast `settingsSync.ts` +- Google Drive: [research](gdrive-sync-provider-research.md) Drive as `FileSyncProvider` token-persist+resumable; [multi-PR status](gdrive-provider-multipr-status.md) PR1+2+3 DONE on `feat/gdrive-sync-core` (5 commits thru `5efbe6b2f`, UNPUSHED, suite 6411 green); native KV+desktop OAuth+connect+ingress 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 format:check` own gate before push @@ -72,6 +64,8 @@ - [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 +- [PDF scrolled-mode lag (#4795/#4031)](pdf-scroll-lag-preload-4795.md) — 415ms/page render vs 50% margin; widen to 200% + `planScrollModePages` nearest-first bounded scheduler; CDP-verified Xiaomi +- [Scrolled-PDF pinch-zoom (#4817)](scrolled-pdf-pinch-zoom-4817.md) — live pinch + no-shift (gap×`--scroll-zoom` + rect-match anchor); cross-page pinch vs native selection MUTUALLY EXCLUSIVE (Android serializes cross-iframe touches), chose native selection (iframes interactive when idle); zoom store/attr desync gotcha - [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` @@ -87,54 +81,47 @@ - [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 ``; TTS/search isolation -- [Word Lens en-en](wordlens-en-en.md) — simplest WordNet synonym; same-lang manifest-driven +- Dict: [popup font size #4443](dict-popup-font-size-4443.md) `--dict-font-scale` MDict `::part(dict-content)`; [lemmatization #4574](dict-lemmatization-4574.md) inflected→lemma `-ses→-sis` before `-es` +- Word Lens: [inline gloss](wordlens-feature.md) CFI-safe `` TTS/search isolation; [en-en](wordlens-en-en.md) WordNet synonym 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` +- [E-ink screen refresh page-turner (#4687)](eink-screen-refresh-pageturner-4687.md) — bindable 'refresh' action; generic `EinkRefreshController.kt` reflection (BOOX/Tolino/Rockchip), NO Onyx SDK bundle; gate Android+`viewSettings.isEink` - [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 +- [Empty highlight leak on annotate cancel (#4791)](empty-highlight-leak-on-annotate-cancel-4791.md) — Annotate eagerly creates placeholder; track `notebookNewHighlightId`, presentation-driven cleanup effect - [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]` +- Native TTS: [iOS #4676](native-ios-tts-4676.md) AVSpeechSynthesizer pause==stop rate `pow^(1/2.5)`; [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; [drift](tts-word-highlight-singletextnode-drift.md) TEXT_NODE fast path ignored offsets, slice `[start,end]` +- [TTS highlight granularity setting](tts-highlight-granularity-setting.md) — Word/Sentence; gate `prepareSpeakWords` on granularity (NOT supportsWordBoundaries); suppress = wordBoundaries && word - [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` +- RSVP: [control-bar overlap REVERT](rsvp-control-bar-overlap-revert.md) #4585 fixed/#4589 stale-reverted; [font face/family #4519](rsvp-font-settings-4519.md) `getBaseFontFamily` overlay top doc; [RTL word #4630](rsvp-rtl-word-display-4630.md) ORP breaks Arabic→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` 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 +- Proofread: [enhancements #4700](proofread-enhancements-4700.md) regex UI/Ctrl+P reuse/`wholeWord` no-op; [per-book CRDT #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 ``; `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 +- OPDS: [Firefox strict-XML #4479](opds-firefox-strict-xml-4479.md) junk after `` slice to last close; [JSON search #4502](opds2-json-search-4502.md) expand `{?query}` BEFORE resolveURL; [HTML desc #4503](opds-html-description-4503.md) decode-once+sanitize; [self-link meta #4749](opds-self-link-metadata-4749.md) `rel:self` deref; [popular dedup #4782](opds-popular-catalog-dedup-4782.md) 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 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 `[卷本册部封]` +- TXT: [author recognition #4390](txt-author-recognition-4390.md) `parseLabeledAuthor`+`isPlausibleAuthorName`; [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` +- [Recently-read shelf (#3797)](recent-read-shelf-3797.md) — PR #4829; reuse `BookItem` + shared `useOpenBook`; flex strip needs grid's gap-aware `flexBasis` calc + `min-w-0` to align with bookshelf columns ## Architecture & Patterns - foliate-js submodule at `packages/foliate-js/`; multiview paginator preloads adjacent sections +- [Markdown .md support (#774)](markdown-md-support-774.md) — in-memory foliate book (no EPUB) in `src/utils/md.ts`; split-at-H1; `isMd()` before `isTxt()`; nav contract: `section.id`↔`splitTOCHref` SAME type (string), `loadText` needed for fragment CFIs, `XMLSerializer` for void-tag XHTML - 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)) @@ -152,5 +139,6 @@ - [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 `(?<=)`/`(?{}); return true; }` BEFORE the page/section side/mode logic. Also added `bindings.refresh?.source === 'native'` to `hasNativeBinding` + the effect dep array so a media key bound to refresh still acquires page-turner key interception. +- `bridge.ts`: `refreshEinkScreen()` → `invoke('plugin:native-bridge|refresh_eink_screen')`. + +**Native generic refresh** (`EinkRefreshController.kt`, new) — the answer to "compatible with most e-ink devices, generic interface not brand SDK". Android has NO public e-ink API; each vendor patches `android.view.View`. Probe via reflection, stop at first success (patterns from KOReader android-luajit-launcher EPD controllers): +1. **Onyx BOOX (Qualcomm)**: `View.refreshScreen(0,0,w,h, 34)` instance method. `34 = FULL(32)+GC16(2)`. +2. **Tolino/Nook (NTX/Freescale)**: `View.postInvalidateDelayed(0L,0,0,w,h, 34)`. +3. **Rockchip (Boyue clones)**: `View.requestEpdMode(View$EINK_MODE.EPD_FULL, true)`. + +Deliberately do NOT bundle the Onyx SDK (`com.onyx.android.sdk.*` classes aren't on-device unless bundled — reflection would always fail) and do NOT call Onyx `setWaveformAndScheme`/None (KOReader does, but it owns the update loop; Readest leaves system auto-update in place, so switching to manual mode could FREEZE later updates). Run on UI thread against `activity.window.decorView`; `success:false` (no controller) is a soft no-op, not an error. iOS Swift stub resolves `{success:false}`. + +**Plugin wiring** added across `models.rs`/`commands.rs`/`mobile.rs`/`desktop.rs`/`lib.rs` + `build.rs` COMMANDS + `permissions/default.toml` `allow-refresh-eink-screen` (build regenerates `reference.md`/`schema.json`/`commands/refresh_eink_screen.toml`). App uses `native-bridge:default` so no capability edit needed. + +**Verified on real hardware**: ONYX BOOX Leaf5 (`ro.product.manufacturer=ONYX`). `pnpm dev-android` build+install; via adb+CDP invoked `plugin:native-bridge|refresh_eink_screen` directly in the WebView → `{success:true}`, logcat `EinkRefresh: onyx full refresh requested` (the Onyx/Qualcomm `View.refreshScreen` path, decor view), and the user visually confirmed 5/5 full GC16 screen flashes in the reader. So the onyx path works on modern BOOX without SDK bundling or `setWaveformAndScheme` priming. (CDP socket is pid-bound `webview_devtools_remote_`; re-forward when the WebView process recycles — see [[cdp-android-webview-profiling]].) + +**i18n**: ran into [[i18n-extract-prunes-keys]] (scanner `removeUnusedKeys:true` deleted ~314 dynamic keys / huge churn). REVERTED the scanner output and added the single `"Refresh Page"` key MANUALLY to all 33 non-en locales (en is key-as-content, needs no entry), aligning each translation with the locale's existing `"Reload Page"`/`"Next Page"` terminology. `check:translations` green. diff --git a/apps/readest-app/.claude/memory/empty-highlight-leak-on-annotate-cancel-4791.md b/apps/readest-app/.claude/memory/empty-highlight-leak-on-annotate-cancel-4791.md new file mode 100644 index 00000000..1809aa0d --- /dev/null +++ b/apps/readest-app/.claude/memory/empty-highlight-leak-on-annotate-cancel-4791.md @@ -0,0 +1,46 @@ +--- +name: empty-highlight-leak-on-annotate-cancel-4791 +description: Annotate eagerly creates a highlight placeholder; cancelling the note must tear it down +metadata: + node_type: memory + type: project + originSessionId: 1c75c865-8e1b-4641-ac20-81692d3ff20b +--- + +#4791 — clicking **Annotate** on a selection eagerly creates a highlight (`note:''`) +as the note anchor (`handleAnnotate` → `handleHighlight(true)` in `Annotator.tsx`), +so the selection stays visible while the NoteEditor is open. Cancelling the note +(Cancel button, overlay, Escape, switching books, closing the notebook) left that +empty highlight leaked into config → showed as a stale card in the left-sidebar +Booknotes list + a phantom yellow highlight. + +**Fix:** +- `handleHighlight` now returns the created `BookNote` only when it pushes a NEW + record (returns `null` when it restyles/toggles an EXISTING one — that record + predates the flow and must survive a cancel). +- `handleAnnotate` stores `created?.id` via `setNotebookNewHighlightId` (new + `notebookStore` field). This tracked id is what distinguishes a removable + placeholder from a pre-existing highlight; do NOT identify it by cfi (a fresh + selection can collide with an existing highlight's cfi). +- `removeEmptyAnnotationPlaceholder(booknotes, id, now)` in `annotatorUtil.ts` + tombstones (`deletedAt`) the live annotation with that id ONLY if it still has + no note text, and returns it so the caller tears the overlay down with + `removeBookNoteOverlays` across ALL views (`getViewsById`, symmetric with how + `handleHighlight` drew it). +- Cleanup is **presentation-driven**, not threaded through every cancel path: + `Notebook.tsx` runs `handleCancelNewAnnotation` from an effect whenever the + creation editor stops being presented (`!(isNotebookVisible && notebookNewAnnotation)`) + — catches Cancel/Escape/overlay/close/swipe/navigate — plus a second effect's + cleanup on `sideBarBookKey` change / unmount for book-switch (pinned) and + reader-close. +- Save survives the guard (placeholder gains note text) and also clears the + tracked id. `handleCancelNewAnnotation` has stable identity (empty deps) so the + effects don't re-fire mid-edit; it reads settings fresh via + `useSettingsStore.getState().settings` (stale-closure guard, see [[webdav-connect-nullified-4780]]). + +**Why id-set-LAST in handleAnnotate matters:** `setNotebookNewHighlightId` is +called after `setNotebookVisible(true)` + `setNotebookNewAnnotation`, so no +intermediate render has (editing=false AND a fresh placeholder id) — prevents the +presentation effect from deleting the placeholder it just created. + +Related: [[instant-highlight-delete-orphan-4773]], [[customize-toolbar-global-serializeconfig]]. diff --git a/apps/readest-app/.claude/memory/gdrive-provider-multipr-status.md b/apps/readest-app/.claude/memory/gdrive-provider-multipr-status.md index 8b2fbcca..9bfa5ef8 100644 --- a/apps/readest-app/.claude/memory/gdrive-provider-multipr-status.md +++ b/apps/readest-app/.claude/memory/gdrive-provider-multipr-status.md @@ -38,6 +38,34 @@ Adding **Google Drive as a second `FileSyncProvider`** for the merged file-sync **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. +**Cloud Sync redesign DONE (commit `1a31a8cbd`):** new "Third-party Cloud Sync" Integrations section with a unified "Cloud Sync" sub-page (`CloudSyncForm`) — WebDAV + Google Drive MUTUALLY EXCLUSIVE via `withActiveCloudProvider` (enabling one disables the other). Radio picker (AIPanel pattern) + shared `FileSyncForm`. WebDAVForm/GoogleDriveForm refactored to embeddable panels; Drive has a "configured-but-inactive" state (`accountLabel` present, `enabled=false`) with frictionless "Use Google Drive" re-activate (no re-OAuth); explicit Disconnect clears the keychain token. Temp concurrency probe removed (upload was already concurrency-4, confirmed). + +**Reader auto-sync DONE (commit `f5e07e50b`):** `useWebDAVSync`→`useFileSync` — the reader auto-syncs the single ACTIVE provider per-book while reading (pull-on-open, debounced push, cover/file). Async engine build (Drive keychain probe) held in state, pull-on-open waits for it; engine keyed on connection-relevant settings (not lastSyncedAt) to avoid re-probing keychain; deviceId/lastSyncedAt write the active provider slice; events renamed `*-file-sync`. WebDAV reader behavior unchanged. + +**Drive feature is functionally complete on desktop:** connect, manual Sync now, auto-sync while reading, exclusive provider switching. Live-verified: connected + synced a 675-book library. + +**DESKTOP PR OPENED: readest/readest#4821** (`feat/gdrive-sync-core`, rebased onto origin/main, all gates green incl. rust). Covers provider + OAuth + native KV + redesign (exclusive Third-party Cloud Sync section, inline radio switch) + reader auto-sync + **premium gating** (any paid plan via `isCloudSyncInPlan`; free sees upgrade CTA; reader auto-sync off for free). Rebase needed `git -c protocol.file.allow=always submodule update --init packages/foliate-js` (foliate-js drift, index wanted `6f1a190`). + +**PR #4821 review fix (pushed `5769682c5`):** CodeQL flagged `escapeDriveLiteral` (driveRest.ts) for not escaping backslashes — fixed (escape `\`→`\\` FIRST, then `'`→`\'`). Was the only review comment. + +**Both branches REBASED onto origin/main `324bb8a36` (was `7e78f80e1`). UNPUSHED, both gates green (lint+format+full suite: mobile 6483, resumable 6486). foliate-js submodule drift on rebase: origin/main now wants `0fa407c4c` (not in local submodule clone whose origin is the main checkout's modules dir); fix `git -C packages/foliate-js fetch https://github.com/readest/foliate-js.git 0fa407c4c... && git -C packages/foliate-js checkout 0fa407c4c...` (the `submodule update --init` shortcut FAILS here — local origin lacks the commit; must fetch from GitHub URL). Current commits: mobile `6728c94f0`(Android)+`8b3dd1cd5`(iOS); resumable `f7a1e5117`.** + +**Branch `feat/gdrive-mobile-oauth` (Android+iOS OAuth) — no longer stacked, off main. PR not opened yet.** + +**Android OAuth (PR4) DONE (commit `eb8e22081`, was `5583c9b38` pre-rebase).** `auth/oauthAndroid.ts` (`runAndroidOAuth` via existing `authWithCustomTab`, DI, 2 tests) + platform dispatch in `googleDriveConnect` (`osType()==='android'`→Custom Tab, else desktop) + Drive row shown on Android. NATIVE (device-verify pending, no Android toolchain in CI): `NativeBridgePlugin.kt` `handleIntent` resolves `com.googleusercontent.apps.:/oauthredirect` via the same `pendingInvoke` as the Supabase callback; matching BROWSABLE intent-filter added to `gen/android/.../AndroidManifest.xml`. + +**iOS OAuth (PR5) DONE (commit `1230fb291`).** `auth/oauthIos.ts` (`runIosOAuth` via `authWithSafari({authUrl, callbackScheme})`; callbackScheme = `deriveReverseDnsRedirectScheme(clientId)` = bare `com.googleusercontent.apps.` — ASWebAuthenticationSession matches on SCHEME not path; DI, 2 tests) + `AuthRequest.callbackScheme?` (nativeAuth.ts; Supabase keeps native `readest` default) + `resolveOAuthRunner` `os==='ios'`→runIos + Drive row on iOS (`isDesktopApp||isAndroidApp||isIOSApp`). `createDriveTokenPersistence` already works on iOS (Keychain via secure-KV). NATIVE (device-verify pending, no iOS toolchain in CI): Swift `auth_with_safari` uses `args.callbackScheme ?? "readest"` (`SafariAuthRequestArgs.callbackScheme: String?`); `Info-ios.plist` CFBundleURLTypes gains the reverse-DNS scheme. macOS Drive uses the desktop deep-link runner (NOT authWithSafari), so no macOS native change. Full suite 6477 green + lint + format + plutil OK. + +**Drive streaming upload/download DONE — own branch `feat/gdrive-resumable-upload` off origin/main (commit `0c9cc1a22`, UNPUSHED).** `uploadStream`+`downloadStream` on GoogleDriveProvider so book files stream from/to disk instead of buffering the whole file in the JS heap (buffered marshal of a large book across the WebView↔Rust bridge crashes the renderer on mobile — this unlocks Drive book sync on Android/iOS and flattens heap on desktop too). `driveRest.resumableCreateUrl`/`resumableUpdateUrl`; `uploadStream` opens a Drive resumable session (POST new `{name,parents}` / PATCH existing `{name}`, metadata in initiation so NO reparent follow-up), reads `Location` session URI, PUTs bytes via `tauriUpload`; `downloadStream` GETs `alt=media` to disk via `tauriDownload` + bearer. Attached **Tauri-only** (`isTauriAppPlatform()`); web keeps buffered fallback. Both swallow→`false` per provider contract (engine retries once). REUSES `@tauri-apps/plugin-upload` already shipped for WebDAV — NO new native code. Single-shot streaming PUT (not chunked mid-stream resume) — sufficient for the heap/OOM fix; chunked-resume-on-failure is a further enhancement. Full suite 6484 green + lint + format. **NOTE: changes desktop Drive book sync from buffered → streaming (previously live-verified buffered); device-verify the streaming path on desktop + mobile.** + +**ALL PRs MERGED to main/dev (dev @ `c6f2a83d9`).** Worktree `feat/gdrive-*` branches no longer needed; work continues in the MAIN repo `/Users/chrox/dev/readest` on `dev` (tracks `origin/main`; there is NO `origin/dev`). +- **#4821** desktop Drive cloud sync + premium Third-party Cloud Sync section. +- **#4824** Drive resumable streaming upload/download. +- **#4823** mobile OAuth (Android Custom Tab + iOS ASWebAuthenticationSession). +- **#4827** Android sync fix: retry THROWN transport errors in `withBackoff` (was 429/5xx only); `mapDriveError` classifies transport throws (incl. Tauri plugin's plain `error sending request` Error) as NETWORK. Root cause: Android pooled keep-alive connection to googleapis.com goes stale mid-sync → every files.list after the first batch threw; sync recovered on its own after ~3-4 min (reqwest evicting dead conns). The retry forces a fresh connection so recovery is fast + kills the error spam. + +**CODE COMPLETE + MERGED.** REMAINING (human/ops-only): (1) on-device re-verify with #4827 in the build — Android sync should no longer stall ~3-4 min / spam `failed to inspect hash dir`; iOS OAuth sign-in; desktop streaming book-sync re-check; (2) Google consent screen → Production (testing caps 100 users). NOTE: Android build auto-generates a deep-link intent-filter for the gdrive reverse-DNS scheme in `gen/android/.../AndroidManifest.xml` (duplicates the manual `gdrive-oauth` filter) — benign build drift, don't commit. + **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 " → add book / Sync now → confirm `Readest/books//{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. diff --git a/apps/readest-app/.claude/memory/i18n-extract-prunes-keys.md b/apps/readest-app/.claude/memory/i18n-extract-prunes-keys.md new file mode 100644 index 00000000..3354bb7f --- /dev/null +++ b/apps/readest-app/.claude/memory/i18n-extract-prunes-keys.md @@ -0,0 +1,36 @@ +--- +name: i18n-extract-prunes-keys +description: "pnpm i18n:extract (removeUnusedKeys) deletes valid keys not statically in the branch; don't commit that churn" +metadata: + node_type: memory + type: feedback + originSessionId: afe50e44-d394-4301-bd81-1368df66f90b +--- + +`pnpm run i18n:extract` (i18next-scanner, `i18next-scanner.config.cjs` has +`removeUnusedKeys: true`) can DELETE ~30+ valid-looking keys from every non-`en` +locale on a feature branch — keys whose source usage isn't statically present in +the current branch (e.g. `"Sync History"`, `"downloaded {{n}} book(s)"`, +`"Match Whole Words"`). The extract diff then shows huge churn (~1000 +/- lines) +unrelated to your change. + +**Why:** the committed locales can be ahead of the branch's source (strings from +features not yet on this base, or built dynamically/in non-scanned modules), and +`removeUnusedKeys` strips anything the scanner can't find. `en/translation.json` +is a tiny key-as-content file (~70 lines, only plural/proper-noun overrides), so +new keys never land there anyway — it stays out of the diff. + +**How to apply:** for a feature that adds a few strings, do NOT commit the +scanner's deletions into an unrelated PR. +1. Run `pnpm run i18n:extract` (optional — only confirms which keys are new). +2. `git checkout -- apps/readest-app/public/locales` to drop ALL the churn. +3. Add ONLY your new keys manually to each locale in `i18n-langs.json` with real + translations. The files are exactly `JSON.stringify(obj, null, 2) + "\n"`, so + a Node script that `JSON.parse`s, appends new keys (insertion order preserved), + and rewrites that way yields a zero-extra-diff result. Skip `en` (key-as-content). + +Match each locale's existing terminology (grep the file for a related key, e.g. +`"Export Annotations"` / `"Annotations"`, before translating). Verify with +`grep -rn '""' apps/readest-app/public/locales | wc -l` == number of locales. + +Related: [[feedback_en_plurals_manual]]. diff --git a/apps/readest-app/.claude/memory/markdown-md-support-774.md b/apps/readest-app/.claude/memory/markdown-md-support-774.md new file mode 100644 index 00000000..df2cc99f --- /dev/null +++ b/apps/readest-app/.claude/memory/markdown-md-support-774.md @@ -0,0 +1,40 @@ +--- +name: markdown-md-support-774 +description: Markdown (.md) reading via in-memory foliate book (no EPUB); split-at-H1; foliate book-object contract gotchas +metadata: + node_type: memory + type: project + originSessionId: a82e979b-0edb-4964-91fd-3677ecfe5679 +--- + +Issue #774: render standalone `.md` files at runtime (NO EPUB conversion). **MERGED as +PR #4816** (branch `feat/markdown-support`). Built test-first via `/autoplan` (CEO+Eng +dual-voice review). Suite green (6365), lint + format clean; live-verified in web app +(import via drop, split TOC, cross-section nav, GFM rendering, pagination). + +**Where:** `src/utils/md.ts` `makeMarkdownBook(file)` builds an in-memory foliate book +modeled on `packages/foliate-js/fb2.js`. Routed in `src/libs/document.ts` `open()` via +`isMd()` **before `isTxt()`** (a `.md` served as `text/plain` would otherwise hit TXT→EPUB). +`'md'` added to `SUPPORTED_BOOK_EXTS` (constants.ts). `sanitize.ts` `sanitizeHtml` gained +`'class'` (code `language-*` theming) + `del`/`ins` tags. Pipeline: strip YAML frontmatter → +`marked`(gfm) → `sanitizeHtml` → split at `

` (preamble = pre-first-H1 content) → nested +heading-outline TOC. + +**Non-obvious foliate book-object contract (cost us the CRITICAL review finding):** +- `section.id` and `splitTOCHref()` output MUST be the SAME type. readest nav + (`services/nav/index.ts:133`) does `new Map(sections.map(s=>[s.id,s]))` then `.get(sectionId)` + where sectionId = `splitTOCHref(href)[0]`. `SectionItem.id` is typed `string`, so use + STRING ids + `splitTOCHref => href.split('#')`. fb2.js uses numbers consistently (works + only because it's untyped JS); do NOT copy fb2's `Number(x)`. +- Fragment CFIs / TOC sub-anchors require `section.loadText` (nav skips sections without it, + index.ts:153). Provide it. +- `SectionItem.cfi` is non-optional → set `cfi: ''` (foliate falls back to `CFI.fake.fromIndex`). +- `createDocument()` parses `application/xhtml+xml`; marked's HTML5 void tags (`

`) + are parse errors there → serialize sections with `XMLSerializer` (not innerHTML). `load()` + and `createDocument()` must derive from the SAME string (CFI round-trip). +- `resolveHref` returns `null` for unresolved anchors (never index 0). jsdom lacks + `URL.createObjectURL` → `load()` is lazy + tests stub it. + +**Deferred follow-ups (open issues):** relative image resolution (web File-objects have no +sibling access — needs the bundle model); Markdown folder/zip "package" model; footnotes/math/ +Mermaid/wikilinks; syntax-highlight token colors. Plan: `.claude/plans/2026-06-26-markdown-md-support-774.md`. diff --git a/apps/readest-app/.claude/memory/multiwindow-settings-clobber-4580.md b/apps/readest-app/.claude/memory/multiwindow-settings-clobber-4580.md new file mode 100644 index 00000000..db767915 --- /dev/null +++ b/apps/readest-app/.claude/memory/multiwindow-settings-clobber-4580.md @@ -0,0 +1,16 @@ +--- +name: multiwindow-settings-clobber-4580 +description: Pagination/global settings revert with multiple desktop windows; cross-window broadcast fix +metadata: + node_type: memory + type: project + originSessionId: 4df1808d-e106-4316-9206-b4e606b4b9bf +--- + +Issue #4580 (fix: PR #4803, branch `fix/multiwindow-settings-revert-4580`): on desktop (Tauri) global view settings (Click/Swipe to Paginate, Show Page Navigation Buttons) "revert to default" — only when multiple windows are open (OP ran `1 + n_opened_books` windows). + +**Root cause:** each Tauri window keeps its own in-memory `useSettingsStore.settings`, loaded once at window open. Global settings persist to ONE shared `settings.json`, and every window writes the WHOLE object via the store's `saveSettings`. A window opened before the user customized a global setting holds the default (e.g. `disableClick=false`); when it later saves (notably `handleCloseBooks` on reader-window close in `ReaderContent.tsx`, but ANY settings write) it clobbers the user's value back to default. Explains "reverts to *default*, only with multiple windows". Note: `replicaCursorStore` avoids this by load-modify-saving from disk each time. + +**Fix:** cross-window broadcast. `src/utils/settingsSync.ts` (`broadcastGlobalSettings` emits `global-settings-window-sync` with `sourceLabel` + the two global blobs; `subscribeSettingsSync` ignores self; `mergeSyncedGlobalSettings` adopts `globalViewSettings`/`globalReadSettings` and preserves all device/window-local fields). Store `saveSettings` calls `broadcastGlobalSettings` after persisting. `useSettingsSync` (mounted in `Providers.tsx`, the shared root for both library + reader windows) adopts broadcasts via `setSettings`. No-op off Tauri. + +Only the two global objects are synced (minimal scope) — covers the reported bug + sibling read settings; top-level scalars left window-local. No save/broadcast loop: receive calls `setSettings` only; the replica publisher subscriber pushes to network (no disk write) and pagination fields aren't in `SETTINGS_WHITELIST` anyway. Live cross-window view update of already-open books is intentionally NOT done (bug is persistence, not live propagation). Related: [[webdav-connect-nullified-4780]] (stale settings closure), [[window-state-sanitize-4398]]. diff --git a/apps/readest-app/.claude/memory/pageturn-bg-replace-reflow-4785.md b/apps/readest-app/.claude/memory/pageturn-bg-replace-reflow-4785.md new file mode 100644 index 00000000..c68221b6 --- /dev/null +++ b/apps/readest-app/.claude/memory/pageturn-bg-replace-reflow-4785.md @@ -0,0 +1,54 @@ +--- +name: pageturn-bg-replace-reflow-4785 +description: "Page-turn frame drops at chapter boundaries (#4785) — per-frame" +metadata: + node_type: memory + type: project + originSessionId: e42ea03e-cda7-4e59-b398-1a28f589b37e +--- + +Issue #4785: swipe page-turn animation drops frames, worst crossing .xhtml +section boundaries, "first open" (Android/Xiaomi). Repro book is a Taiwan light +novel with custom fonts + `.bg`/`background-attachment:fixed` front-matter. + +Root cause (in `packages/foliate-js/paginator.js`, a submodule): +`#replaceBackground()` rebuilt its whole paint context **every frame** of both +swipe phases — `getComputedStyle()` + `this.size` + one +`getBoundingClientRect()` **per rendered view** + a per-view background-reset +write loop + full `#background` DOM rebuild. Those forced reads scale with the +number of loaded views, which **peaks at a chapter boundary** because adjacent +sections are preloaded there — hence "worst at boundaries". Two callers ran it +per-frame: the snap `syncBackground` rAF loop (`#scrollTo`) and the drag-phase +container `scroll` listener (`#onTouchMove`→`scrollBy`→scroll event). + +Everything `#replaceBackground` reads is **invariant for one gesture** (theme/ +texture, bg+container geometry, each view size+bg) — only scroll offset changes. + +Fix: +- Split into `#readBackgroundStyle` / `#computePaginatedBgContext` (the reads) + + `#paintPaginatedBackground(ctx, atPosition)` (writes only; calls the unchanged + pure `computeBackgroundSegments`). +- `#bgAnimContext` field snapshots context once: set in `#onTouchStart` (drag) + and at the start of the animated branch in `#scrollTo` (snap); cleared in + `#onTouchEnd` and both animation `.then()`s. `#replaceBackground` uses + `this.#bgAnimContext ?? this.#computePaginatedBgContext()`. +- Also deferred the heavy mid-drag forward preload: added `&& !this.#touchScrolled` + to the scroll-listener `#loadAdjacentSection` gate (columnize/expand on the main + thread janked the drag). The scroll that settles the gesture re-fires the gate + with the finger up, so the buffer still tops up. + +Tests: `src/__tests__/document/paginator-background-anim-perf.browser.test.ts` +(real Chromium) drives `next()` (snap) and a synthetic touch drag, spying on the +primary iframe `` getComputedStyle. Pre-fix: 39 reads (snap) / 7 (drag); +post-fix ≤3 / ≤1. Existing `paginator-background-segments.test.ts` (pure +`computeBackgroundSegments`) stays green — visual output unchanged. + +Scrolled-mode branch kept inline in `#replaceBackground` (never the per-frame hot +path). Behavior preserved: scrolled set every view bg so the old reset loop was +redundant; `containerSize = containerRect[sideProp]` == old `this.size`. + +NOT the cause (ruled out): `computeBookNav`/`nav.json` is awaited before the view +renders, so first/second-open in-memory state is identical — it can't explain +reading-time swipe jank. See [[booknote-view-autoscroll-4352]] neighbors in +Paginator & Scroll. Related: [[paginator-swipe-bg-flash]], +[[global-annotation-pageturn-perf-4575]], [[paginated-texture-occlusion-4399]]. diff --git a/apps/readest-app/.claude/memory/pdf-scroll-lag-preload-4795.md b/apps/readest-app/.claude/memory/pdf-scroll-lag-preload-4795.md new file mode 100644 index 00000000..8ba31c51 --- /dev/null +++ b/apps/readest-app/.claude/memory/pdf-scroll-lag-preload-4795.md @@ -0,0 +1,27 @@ +--- +name: pdf-scroll-lag-preload-4795 +description: "PDF scrolled-mode rendering lag on Android (#4795/#4031) — fix via widened preload margin + bounded prioritized load scheduler in fixed-layout.js" +metadata: + node_type: memory + type: project + originSessionId: 902324ba-94ee-4c88-804e-ea9f796681f9 +--- + +PDF **scrolled mode** showed blank pages while scrolling on Android (#4795, resurfacing #4031). Reproduced + fixed + CDP-verified on Xiaomi 13 (fuxi). + +**Root cause (measured via CDP on-device):** per-page render (`pdf.js` `onZoom`→`render()`: canvas raster + text layer + annotation layer) ≈ **415 ms** uncached / ~65 ms cached, but the scrolled-mode IntersectionObserver used `rootMargin: '50% 0px'` (~0.5 page of lead). Loads also had **unbounded concurrency** (observer fired `#loadScrollPage` for every intersecting page) and **no viewport prioritization**, so the slow render never finished before the page scrolled into view, and a fling spawned dozens of competing renders. Per-page canvas ≈ **7 MB** at dpr 3 (screen-res, NOT the ~50 MB I first feared) → memory headroom existed; the #3470 OOM was byte-range *parsing* flood (`MAX_CONCURRENT_RANGES`, orthogonal). + +**Fix** (`packages/foliate-js/`): +- `fixed-layout.js`: widened observer to `rootMargin: '200% 0px'` (~2 viewports lead); observer now only **flags `page.visible`** and calls new `#scheduleScrollPages()`. +- New pure exported `planScrollModePages({pages, currentIndex, maxLoaded, maxConcurrent, loadingCount})` → `{load, evict}`: loads **visible+idle pages nearest currentIndex first, bounded by `maxConcurrent - loadingCount`**; evicts **farthest non-visible loaded** beyond `maxLoaded`; **never evicts a visible page** (distance = `|index - currentIndex|`). Unit-tested in `src/__tests__/document/fixed-layout-scroll-scheduler.test.ts`. +- `#scrollMaxLoaded 8→12` (live-canvas cap = memory ceiling), `#scrollMaxConcurrent=3`, `#scrollLoadingCount` tracked in `#loadScrollPage` (inc on start, dec in `finally`, then reschedule so a freed slot pulls the next nearest page). Removed `#evictScrollPages` (scheduler handles it). +- **Terminal `error` state**: a load that throws or returns no src sets `state='error'` (not `'idle'`) so the post-completion reschedule can't retry a persistently failing page in a tight async loop (regression I introduced with reschedule-on-completion). +- `pdf.js`: `MAX_CACHED_PAGES 8→16` (page objects + render blobs are cheap, not the canvas) so back-scroll within the wider window doesn't re-parse. + +**Verified (CDP + screenrecord, identical 9-swipe reading-pace test, fresh region):** baseline = mostly blank frames, settled forward lead **+2** (span [-9,+2]); fix = **every frame fully rendered**, forward lead **+4** (span [-7,+4]). Extreme 8-fling (240 pages/2s) still blanks mid-fling (inherent) but settles to rendered content and **no crash**. + +**Best-practice cache strategy for scrolled PDF on mobile** (asked during this work): two bounded tiers — live-canvas cap = the hard memory ceiling (sized to window+lead), decoded-page cache a bit larger (cheap); distance/viewport-aware LRU never evicting visible; bound+prioritize loads; release bitmaps eagerly (`canvas.width=0`); the biggest unused lever for low-end devices is **capping effective DPR** (canvas mem ∝ DPR²) — not applied here since 12×7 MB≈84 MB is fine on the Xiaomi. + +**CDP on release builds:** the installed Play/release 0.11.12 has **no `webview_devtools_remote_` socket** — WebView debugging is gated behind the `devtools` Cargo feature (`src-tauri/Cargo.toml`); must build+install `pnpm dev-android` (release + `--features devtools`, same keystore so it updates over the store build, library preserved). CDP `webSocketDebuggerUrl` comes back as `ws://localhost/devtools/...` **with no port** (echoes Host header) → rewrite to `ws://127.0.0.1:9222`; `ws` npm pkg is CJS so import default + destructure. See [[cdp-android-webview-profiling]], [[pdf-oom-range-flood-3470]]. + +**WIP caveat:** during this work the foliate-js submodule had unrelated uncommitted `paginator.js` WIP (background-anim perf, #4785) — exclude it from any #4795 commit. diff --git a/apps/readest-app/.claude/memory/recent-read-shelf-3797.md b/apps/readest-app/.claude/memory/recent-read-shelf-3797.md new file mode 100644 index 00000000..f2b9b93a --- /dev/null +++ b/apps/readest-app/.claude/memory/recent-read-shelf-3797.md @@ -0,0 +1,16 @@ +--- +name: recent-read-shelf-3797 +description: "Recently-read carousel at library top (#3797 / PR" +metadata: + node_type: memory + type: project + originSessionId: d5f79cf1-9e58-4ae4-9f8a-46a8e8ca625f +--- + +Opt-in "Recently read" strip in the library Virtuoso header (PR #4829, issue #3797). `selectRecentShelfBooks(books, count)` in `libraryUtils.ts` (filter `!deletedAt && progress != null`, sort by `updatedAt` desc, slice 12). Setting `libraryRecentShelfEnabled` (default false) + View-menu toggle. Rendered via the Virtuoso `Header` through `BookshelfListContext` (stable identity → no grid re-render churn); list `` needs explicit `context={listContext}`. + +**Reuse, don't reimplement:** each slide renders the real `BookItem` (identical cover/title/progress/badges). The open path was extracted to `src/app/library/hooks/useOpenBook.ts` (in-place stale-record probe + `makeBookAvailable` on-demand download for cloud-only synced books + navigate) and is shared by `BookshelfItem` AND the recent shelf. Do NOT open via the select-mode `navigateToReader` path — it skips the download, so a recently-read book that synced (progress + `updatedAt`) without its blob fails to open on a second device. + +**Alignment gotcha (cost several iterations):** a horizontal flex strip with `basis-1/N` does NOT match a CSS-grid column when the grid has a row gap — CSS Grid subtracts the gap from each track, flex `basis` does not (covers come out too wide at 2/3 cols where `BOOKSHELF_GRID_CLASSES` uses `gap-x-4`; matches at `sm+` where `gap-x-0`). Fix: size each slide with the grid's own formula `flexBasis: calc((100% - (var(--rs-cols) - 1) * var(--rs-gap)) / var(--rs-cols))`, with `--rs-cols` (responsive `3/4/6/8/12` ladder when auto, else `libraryColumns`) and `--rs-gap` (`1rem` base / `0px` sm+, mirroring `gap-x-4 sm:gap-x-0`) set on the row. Also `min-w-0` on each flex item, else image covers expand to intrinsic width. Verified 0.00-0.02px edge diff vs a real CSS grid at N=2/3/4/5 (standalone HTML repro + getBoundingClientRect). + +Arrows: plain scroll div + `scrollBy`, shown on overflow (`scrollLeft`/`scrollWidth`, `ResizeObserver`), centered on `.bookitem-main` via measure; `start-2`/`end-2` + `rtl:rotate-180`. Swipe never opens (useLongPress moveThreshold). i18n: `i18n:extract` churns every locale (see [[i18n-extract-prunes-keys]]) — added the 2 keys manually; bo/si/ta/bn best-effort. diff --git a/apps/readest-app/.claude/memory/scrolled-pdf-pinch-zoom-4817.md b/apps/readest-app/.claude/memory/scrolled-pdf-pinch-zoom-4817.md new file mode 100644 index 00000000..a4327d6f --- /dev/null +++ b/apps/readest-app/.claude/memory/scrolled-pdf-pinch-zoom-4817.md @@ -0,0 +1,25 @@ +--- +name: scrolled-pdf-pinch-zoom-4817 +description: Scrolled-PDF live pinch-zoom + the cross-page-pinch vs native-selection tradeoff (readest +metadata: + node_type: memory + type: project + originSessionId: 902324ba-94ee-4c88-804e-ea9f796681f9 +--- + +Live pinch-zoom for scrolled PDF (fixed-layout scrolled mode). The real fix is entirely in foliate-js: PR **readest/foliate-js#43 MERGED** to foliate main as `0fa407c` (on top of #42 `8bcb61e` which already had live pinch + the rect-match anchor + the interactive-when-idle idle-toggle). readest **PR #4817** is therefore **minimal — just the submodule bump to `0fa407c` + one unit test** (`fixed-layout-pinch-zoom.test.ts`, single clean commit `dd39837af`, +39/-1). readest needs NO touch-handling change: `origin/main`'s `useIframeEvents` already detects a two-finger gesture per page (`event.touches` forwarded via `iframeEventHandlers`) and calls `renderer.pinchZoom`. Builds on the scroll-lag scheduler [[pdf-scroll-lag-preload-4795]]. + +**Abandoned detour (do not re-add):** a host-level cross-page-pinch approach (`multiTouch.ts` `updateSourceTouches`/`flattenSourceTouches`, per-iframe `sourceIndex` binding, `allActiveTouches` reading `e.touches`, and a `usePagination` host-click tap fix) was built then fully reverted. It is unnecessary once cross-page pinch is dropped, and same-page pinch + centre-tap toggle both work through existing `origin/main` code (tap goes iframe -> `iframe-single-click` centre zone; the host-click path is never hit when iframes are interactive). + +**Core architectural finding (the crux):** in scrolled FXL, **cross-page pinch and native text selection are mutually exclusive**. Each page is its own iframe; Android **serializes touches across iframe documents** (finger1 on page A gets `touchcancel` the instant finger2 lands on page B — proven via forwarded-touch logs), so a pinch spanning two pages can only be recognized if the *host* owns all touches, which requires `.scroll-page iframe { pointer-events: none }`. But inert iframes kill native selection/taps. So you pick one. User chose **native selection, drop cross-page pinch.** + +**Final design (foliate `fixed-layout.js`):** +- `pinchZoom(ratio)` in scroll mode scales the whole `.scroll-container` live (`computeScrollPinchTransform`, transform-origin at viewport centre). `pinchEnd` snapshots the centre page's `getBoundingClientRect` and the commit re-render (`#renderScrollMode`) scrolls it back to that exact rect (`#restorePinchAnchor`) — no jump. +- **No-shift fix:** the inter-page gap must scale with zoom or the committed gaps don't match the transform-scaled preview. `margin: calc(var(--scroll-page-gap,4px) * var(--scroll-zoom,1))` and `#renderScrollMode` sets `--scroll-zoom = scaleFactor`. Verified preview->commit scale MATCH + position jump <=2px. +- Iframes interactive **when idle** (restored `#setScrollIframeInteraction(true)` in `#handleScrollEvent` settle; `#scrolling` flag + interactive-on-load in `#loadScrollPage` so selection works without scrolling first), inert only **during active scroll** (native-smooth). Same-page pinch flows through the per-iframe forwarded-touch path; pdf.js `setupPanningEvents` handles pan (empty-area drag scrolls host) + native selection (text drag). `overflow-x:auto` + `width:max-content` enable horizontal pan of a zoomed page. + +**Gotcha — zoom store/attribute desync:** setting the `scale-factor` attribute directly (e.g. a test reset) does NOT update readest's `viewSettings.zoomLevel`. Pinch commit = `round(zoomLevel * lastPinchRatio)`, so a desynced store makes commit diverge from the live transform preview. Real pinches keep them in sync; only direct `setAttribute` breaks it. Cost me a long false-positive "shift" chase — reset zoom via a synthetic pinch, never `setAttribute`. + +**Selection re-impl (NOT taken):** host-level selection via `caretRangeFromPoint` + dispatch `selectionchange` on the iframe doc IS viable (readest `handleSelectionchange` -> `makeSelection` -> popup; `getPosition` returns valid coords for scroll-page selections), but loses native OS selection handles/magnifier, and the popup is deferred until a real `touchend` sets `androidTouchEndRef` (Annotator.tsx). Abandoned in favour of native selection. + +CDP-verified on Xiaomi (tap toggle, same-page pinch in/out no-shift, vertical scroll, horizontal pan, iframes `pointer-events:auto` when idle). Native selection itself needs a real finger (CDP synthetic touches don't engage the WebView long-press selection gesture). diff --git a/apps/readest-app/.claude/memory/toc-table-heading-clip-4439.md b/apps/readest-app/.claude/memory/toc-table-heading-clip-4439.md new file mode 100644 index 00000000..cfad4445 --- /dev/null +++ b/apps/readest-app/.claude/memory/toc-table-heading-clip-4439.md @@ -0,0 +1,40 @@ +--- +name: toc-table-heading-clip-4439 +description: "#4400 scroll-wrapper overflow:auto clips negative-margin bleed of decorative layout tables; hoist negative margins onto wrapper" +metadata: + node_type: memory + type: project + originSessionId: 6d1d7362-d152-4248-93c0-76f6aef92329 +--- + +#4439: on a decorative TOC page (nested layout tables), the **top half of the +`CONTENTS` heading is clipped** in paginated mode (0.11.4 regression; 0.11.2 fine). +Reporter blamed [[table-dark-mode-tint-4419]] but that's dark-mode only — this is +light mode. Real cause is **#4400** (`scrollable.ts` + `getPageLayoutStyles`): + +- v0.11.2 sized wide tables with `transform: scale()` — **never clipped**. +- #4391 then #4400 replaced that with wrapping every `` (and display + ``) in `.scroll-wrapper { overflow: auto }` + `table { max-height: var(--available-height) }`. +- These EPUBs lay the contents out as a nested `
` + with `

CONTENTS

` (`line-height:1em`, inside `div.em06`=0.6em). + The **negative top margin** pulls the table (and the heading's first line) above + the wrapper's `overflow:auto` content box, which clips it. Measured: heading top + ~12.8px above the clip box = exactly `-1em` in the 0.6em context (~58% of the line). +- The `-fit` escape (`SCROLL_WRAPPER_FIT_CLASS` → `overflow:visible`) only checks + **horizontal** fit (`scrollWidth-clientWidth`). The table's positive `margin-left:1em` + inflates scrollWidth so it never gets `-fit`, stays `overflow:auto`, and clips. + +**Fix** (PR for #4439): `hoistNegativeMargins(el, wrapper, win)` in `applyScrollableStyle`'s +`wrap()` — move any NEGATIVE computed margins from the wrapped element onto the wrapper +and zero them on the element. Keeps the box in place, lets the element sit flush so the +overflow box can't clip it; also de-inflates scrollWidth so a genuinely-fitting table +gets `-fit`. Positive/auto margins are left alone (over-wide tables still scroll; centered +tables stay centered). CSS can't do per-axis `overflow-x:auto; overflow-y:visible` +(spec coerces `visible`→`auto`), so margin-hoisting is the route, not per-axis overflow. + +Repro is metric-sensitive (whether the inner table is `-fit`). Tests: browser test +`src/__tests__/document/paginator-table-toc-clip.browser.test.ts` + fixture +`repro-4439.epub` (real foliate paginator, asserts heading top not above its clip box); +unit cases in `scrollable.test.ts`. Verified against the literal book (`321123.epub`, +content-7.xhtml spine idx 12): clipped without fix, clean with it. Related: +[[paginated-texture-occlusion-4399]], [[inline-block-column-overflow]]. diff --git a/apps/readest-app/.claude/memory/tts-highlight-granularity-setting.md b/apps/readest-app/.claude/memory/tts-highlight-granularity-setting.md new file mode 100644 index 00000000..8fccca57 --- /dev/null +++ b/apps/readest-app/.claude/memory/tts-highlight-granularity-setting.md @@ -0,0 +1,20 @@ +--- +name: tts-highlight-granularity-setting +description: TTS highlight granularity (word/sentence) user setting and its two-point gating in TTSController +metadata: + node_type: memory + type: project + originSessionId: 33ddd196-7404-4af2-99ac-0d3b19b39b4e +--- + +Settings → TTS → "TTS Highlighting" boxed list has a **Granularity** select (first row, before Style): `Word` (default) / `Sentence`. Field `ttsHighlightGranularity: TTSHighlightGranularity` on `TTSConfig` (`src/services/tts/types.ts`, `src/types/book.ts`), default `'word'` in `DEFAULT_TTS_CONFIG`. UI in `TTSHighlightStyleEditor.tsx` (props `granularity`/`onGranularityChange`), persisted from `TTSPanel.tsx` via `saveViewSettings(..., false, false)` + a value-watching `useEffect` (mirrors `ttsMediaMetadata`). + +Word-by-word highlighting only ever happens on **Edge TTS** (`supportsWordBoundaries() === true`); Web/Native always highlight per sentence. We assume every engine supports sentence highlighting, so picking `word` on a non-word-boundary engine naturally falls back to sentence. + +**Gating lives at two points in `TTSController` (NOT one helper):** +1. `dispatchSpeakMark` suppression: `#suppressMarkHighlight = ttsClient.supportsWordBoundaries() && #highlightGranularity === 'word'`. With `sentence`, don't suppress → the sentence highlight is drawn at mark dispatch. +2. `prepareSpeakWords` early-return: `if (#highlightGranularity === 'sentence') return;` — **gated on granularity only, NOT on `supportsWordBoundaries()`**. Reason: `prepareSpeakWords` is only called by EdgeTTSClient in prod (boundaries present), but `tts-controller.test.ts` calls it directly with the *web* client active (supportsWordBoundaries=false) and expects word highlighting. Adding a `supportsWordBoundaries()` check there would break those existing tests. + +Controller learns the value via `setHighlightGranularity()` (called at creation in `useTTSControl.ts` next to `updateHighlightOptions`, and from a `useEffect` on `viewSettings.ttsHighlightGranularity`). Mock `TTSController` in `useTTSControl.test.tsx` must include `setHighlightGranularity: vi.fn()` or the speak path throws and emits no position/state. + +Related: [[edge-tts-word-highlighting-4017]], [[tts-word-highlight-singletextnode-drift]], [[tts-sync-paragraph-rsvp-3235]]. diff --git a/apps/readest-app/.claude/memory/webdav-credential-sync-4810.md b/apps/readest-app/.claude/memory/webdav-credential-sync-4810.md new file mode 100644 index 00000000..d8cc9733 --- /dev/null +++ b/apps/readest-app/.claude/memory/webdav-credential-sync-4810.md @@ -0,0 +1,44 @@ +--- +name: webdav-credential-sync-4810 +description: "WebDAV credentials weren't synced cross-device; add to settings whitelist + encrypted fields + mergeSettings deep-merge" +metadata: + node_type: memory + type: project + originSessionId: 1a4937d3-05a4-4e1e-b917-d56304b66f2b +--- + +Issue #4810: WebDAV credentials never synced across devices despite the +"Credentials" sync toggle being ON. WebDAV (`webdav.serverUrl/username/password/rootPath`) +was simply absent from the settings replica whitelist — only kosync/readwise/hardcover +were there. + +Fix (`src/services/sync/adapters/settings.ts`): +- Added `webdav.serverUrl`, `webdav.username`, `webdav.password`, `webdav.rootPath` + to `SETTINGS_WHITELIST`. +- Added `webdav.username`, `webdav.password` to `SETTINGS_ENCRYPTED_FIELDS`. +- Deliberately EXCLUDED per-device bookkeeping: `enabled`, `deviceId`, + `lastSyncedAt`, sync sub-toggles. Mirrors KOSync, which syncs credentials + but NOT its `enabled` flag — a fresh device pre-fills the connect form and + the user clicks Connect (avoids auto-arming sync / rotating deviceId). + +**Gotcha (the real trap):** `mergeSettings` in `replicaSettingsSync.ts` does a +top-level shallow merge (`{...current, ...patch}`) plus single-level deep +merges ONLY for explicitly-listed nested groups (globalViewSettings, +globalReadSettings, kosync, readwise, hardcover, dictionarySettings). Adding a +new nested whitelist group WITHOUT a matching deep-merge case there means the +shallow merge REPLACES the whole sub-object with the partial patch, wiping +sibling per-device fields (here: `webdav.enabled`/`deviceId`/sub-toggles would +be clobbered to undefined on every pull). Always add the `if (patch.X)` deep +merge when adding `X.*` paths to `SETTINGS_WHITELIST`. + +Encrypted-path plumbing is generic: `ENCRYPTED_PATHS` in replicaSettingsSync.ts +derives from `SETTINGS_ENCRYPTED_FIELDS`, and replicaPublish uses +`adapter.encryptedFields` — so listing the two webdav fields there is enough +to gate them behind the credentials toggle + crypto session. + +Also updated the credentials-category description (SyncCategoriesSection.tsx) +to list WebDAV; migrated the i18n key in all 33 locale JSONs manually (NOT via +`pnpm i18n:extract`, which prunes valid keys — see [[i18n-extract-prunes-keys]]). + +Related: [[multiwindow-settings-clobber-4580]], [[webdav-metadata-sync-4756]], +[[webdav-connect-nullified-4780]]. diff --git a/apps/readest-app/.gitignore b/apps/readest-app/.gitignore index d120534f..3c6ac646 100644 --- a/apps/readest-app/.gitignore +++ b/apps/readest-app/.gitignore @@ -76,6 +76,7 @@ docs/superpowers .context/ .claude/settings.local.json .claude/skills +.claude/plans # Playwright web e2e /test-results/ diff --git a/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 3e2476c4..e824a3ac 100644 --- a/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -137,6 +137,19 @@ + + + + + + + + + + + + +