diff --git a/apps/readest-app/.claude/memory/MEMORY.md b/apps/readest-app/.claude/memory/MEMORY.md index 914ba4b1..b7d5cc59 100644 --- a/apps/readest-app/.claude/memory/MEMORY.md +++ b/apps/readest-app/.claude/memory/MEMORY.md @@ -34,11 +34,16 @@ - [Custom fonts disappear on cloud sync (#4410)](custom-fonts-reincarnation-4410.md) — CRDT remove-wins: re-import-after-delete needs a `reincarnation` token or the pull re-applies the tombstone; `addFont`/`addTexture` minted none; fix mirrors dictionary (both cases) + OPDS token style; coverage matrix per kind ## Testing +- [Nightly updater Android E2E](nightly-updater-android-e2e.md) — real Xiaomi/HyperOS test of #4577 self-updater; `pnpm dev-android` (--features devtools) for CDP, raw-socket CDP discovery, nightly>stable comparator, MIUI 单次安装授权 install gates - [Android CDP e2e lane](android-cdp-e2e-lane.md) — `pnpm test:android`: adb+CDP drives the installed app on device/emulator; discover-don't-assume targeting, injected hyphenation, MediaStore VIEW transient open (canonical `_data` path gotcha), per-section frame restore; CI workflow with KVM emulator + debug x86_64 APK (no signing secrets) - [CDP Android WebView profiling](cdp-android-webview-profiling.md) — drive the on-device Readest WebView via adb+CDP to run JS probes/benchmarks inside the live app (no rebuild); gotchas: locked device freezes fetch (not invoke), visible:false throttles setTimeout, `__TAURI_INTERNALS__.convertFileSrc/invoke` always present, books in internal `/data/user/0/...`, fs `read{rid,len}` last-8-bytes=nread, `fs|close` not ACL-allowed, curl mishandles WebView HTTP framing - [Tauri Rust↔JS parser parity tests](tauri-parser-parity-tests.md) — #4369 native Rust EPUB/MOBI parser; how to cross-check vs foliate-js in the `.tauri.test.ts` WebView suite (CWD disk path for Rust, Vite URL for JS, normalizer-based compare, cover presence-only, desc whitespace-collapse); the `dcterms:modified`→`published` divergence fix +- [TTS browser e2e harness](tts-browser-e2e-harness.md) — faithful auto-advance test (real `` + real `useTTSControl` + mock ONLY the 3 client modules; mock `speak()` yields `end` to drive the real `forward()` walk); seed readerStore/bookDataStore + `settings.globalViewSettings` (else `getMergedRules` crash stops TTS); reproduce FoliateViewer relocate→setProgress; sample-alice Ch4=section 6/Ch5=section 7; assert badge `false` BEFORE tts-stop +- [TTS sync chrome verification](tts-sync-chrome-verification.md) — Edge TTS WORKS in claude-in-chrome (WebSpeech errors there with `InvalidStateError`); use an Edge voice to verify TTS-driven features live (RSVP followed at ~171 wpm). Synthetic-CFI debug recipe (expose controller, `syncToCfi(view.getCFI(docIndex, word.range))`). Exposed the #3235 cross-realm `instanceof Range` bug (frozen RSVP/paragraph follow) → `isRangeLike()` duck-type fix +- [TTS sync paragraph+RSVP (#3235, PR #4576)](tts-sync-paragraph-rsvp-3235.md) — TTS-is-clock follow: canonical `tts-position{cfi,kind:word|sentence,sectionIndex,sequence}`; in-mode 🔊 audio toggle (`build{Paragraph,Rsvp}TtsSpeakDetail`, live-range gate); **current word/sentence highlight painted on the overlay CLONE via CSS Custom Highlight API** (no DOM mutation, spans inline; offsets relative to para-start map 1:1 to clone, `getTextSubRange` reuse, index-tagged vs stale); kind-gating `decideParagraphTtsHighlight` (Edge word wins, skip coarse sentence); `::highlight()` from `ttsHighlightOptions` ## Build & Vendoring +- [R2 rclone CreateBucket 403 (#4588)](r2-rclone-createbucket-403.md) — single-file `rclone copyto`/`moveto` probes CreateBucket → 403 on object-scoped R2 token; use a directory `rclone copy` (or `no_check_bucket=true`); broke nightly assemble, not the release flow - [pdfjs vendor wasm decoders](pdfjs-vendor-wasm-decoders.md) — scanned PDFs blank in CI build only (0.11.2 regression); pdfjs 5.7.x moved JBIG2 to `jbig2.wasm`, `copy-pdfjs-wasm` allow-list dropped it; `cpx` no-errors on empty glob; local stale `public/vendor` (gitignored, not refreshed by `tauri build`) masked it; fix = copy `wasm/*` ## Platform Compat @@ -47,8 +52,13 @@ - [Window-state sanitizer (#4398)](window-state-sanitize-4398.md) — Windows launch crash (WebView2 0x80070057) from invalid `.window-state.json` (`-32000` minimized sentinel / `0×0`); our plugin already has upstream #253 fix so bad files are stale; defense-in-depth `window-state-sanitizer` plugin registered BEFORE window-state (plugin init = registration order); coord threshold `-16000` (~halfway to the -32000 sentinel; real desktops sit a few thousand px off origin) keeps multi-monitor negatives - [Android Open-with intent flow (#4521)](android-open-with-intent-flow.md) — "Open with"/"Send to" pipeline: `NativeBridgePlugin.kt::handleIntent` → `shared-intent` → `useAppUrlIngress` → `useOpenWithBooks` (VIEW=transient→reader, SEND=library+upload). Telegram fails where file-manager works on TWO axes: cold-start delivery (fixed by #4527, on dev NOT released v0.11.4) + foreign-private-file read (Telegram FileProvider non-persistable grant vs shared-storage FUSE real-path). adb MediaStore VIEW repro tests pipeline but CANNOT reproduce the read axis (MANAGE_EXTERNAL_STORAGE bypasses grant) - [Dict lookup → OEM browser hijack (#4559)](dict-lookup-browser-hijack-4559.md) — VIVO system-dict lookup opened the browser not Eudic. PRIMARY: no `` for `ACTION_PROCESS_TEXT` under targetSdk36 → dictionary apps invisible, only auto-visible browser returned (fix = add `` to plugin manifest). SECONDARY: browser registers PROCESS_TEXT + is default → filter browsers in pure `decideLookupDispatch` (explicit/chooser/unavailable). Remember-the-pick via `IntentSender`+`EXTRA_CHOSEN_COMPONENT`→`LookupChoiceReceiver`→SharedPreferences (`ACTION_CHOOSER` has no native Always); reset row in `CustomDictionaries.tsx` +- [Android sideload same versionCode](android-sideload-same-versioncode.md) — sideloaded APK reinstall allows EQUAL versionCode (only strictly-lower blocked); Play Store's increment rule does NOT apply to sideload. Nightly APKs share base versionCode and still install. Corrects a plausible-but-wrong review claim ## Feature Notes +- [Tap to open image/table (#4600)](tap-to-open-image-table-4600.md) — single-tap opens gallery/table-zoom in **reflowable** EPUBs (long-press unchanged); `iframe-long-press` message renamed to `iframe-open-media`, hook `useLongPressEvent`→`useOpenMediaEvent`; shared `detectMediaTarget`; `handleClick` got `isFixedLayout` +- [#4584 tap-death investigation](issue-4584-tap-death-investigation.md) — UNFIXED; `isPopuped` self-heals (RED HERRING, don't "fix" it); likely WebView-148-specific (emulator=133 can't repro); Android emulator/CDP gesture-verification gotchas (swiftshader ANR=artifact, CDP can't native-select, screenX=0) +- [Dictionary lemmatization (#4574)](dict-lemmatization-4574.md) — inflected selections (`ran`/`mice`/`analyses`) resolve to base headwords (`run`/`mouse`/`analysis`) in dicts that store only lemmas (ODE). Pluggable `lemmatize/` registry (default English, explicit non-English no-op), English rules+irregulars, appended to tail of `buildLookupCandidates` so exact match wins; over-generate + dict-validates; `-ses→-sis` ordered before `-es` +- [Word Wise inline gloss (feat/word-wise)](wordwise-feature.md) — Kindle-style native-language hint above hard words; CFI-safe via `` (epubcfi hoist+merge, NOT just tree-walk); TTS/search isolation (tags:['rt'] + rangeTextExcludingInert + search attributes:['cfi-inert']); gloss data = curated starters, full asset built by `build-wordwise-data.mjs` (ECDICT/CC-CEDICT+HSK) - [Edge TTS word highlighting (#4017, PR #4566)](edge-tts-word-highlighting-4017.md) — keep sentence marks, add word highlight via `audio.metadata` WordBoundary (verbatim input span, 100-ns ticks) synced to `audio.currentTime` by rAF; readaloud endpoint gates on UA (Edg, non-headless) NOT Origin; fixed browser `new WebSocket(url,{headers})` SyntaxError (wss never worked on web); overlay = `` in FOLIATE-PAGINATOR shadow root; dev-web verify recipe (browse --proxy + UA spoof, never Origin header) - [Reference Pages (#672+#4542, PR #4549)](reference-pages-672-4542.md) — 'reference' progressStyle from foliate `pageItem`/`book.pageList` (numeric-max total rule, roman-tail safe); per-book `referencePageCount` via skipGlobal save; verification EPUBs + dev-web synthetic drag-drop import trick; locale-tail rebase-conflict recipe (checkout --ours → re-extract → re-translate) - [OPDS Firefox strict-XML parse (#4479)](opds-firefox-strict-xml-4479.md) — MEK feed has junk after ``; Firefox DOMParser → `` (silent back-nav), Chrome lenient; `parseOPDSXML` slices root start→last close tag; jsdom mirrors Firefox; wired into page.tsx + validateOPDSURL + feedChecker (latter also #4181 `looksLikeXMLContent` swap) @@ -66,6 +76,7 @@ - [Design system → DESIGN.md](feedback_design_system_doc.md) — codify recurring UI/UX rules in `apps/readest-app/DESIGN.md`; never `pl/pr/ml/mr/text-left/text-right` (RTL); §5 boxed list anatomy has uniform `min-h-14` rows and chromeless controls ## Reader UI Fixes +- [Global annotation page-turn lag (#4575)](global-annotation-pageturn-perf-4575.md) — highlighting recurring names = `global` highlights re-fanned-out (TreeWalker + getCFI/occurrence + SVG churn) EVERY page turn (~25-45ms desktop, ×mobile); fix = `WeakMap` memo in `globalAnnotations.ts` skips already-expanded sections; live-profiled via dev-web foliate-view; GBK-TXT synthetic-drop import recipe - [Overlayer splitRange by text nodes](overlayer-splitrange-textnodes.md) — highlight SVG missed bullet-list text when range also touched a `

`: `#splitRangeByParagraph`'s `'p,h1-h4'` selector dropped `li` (3rd whack-a-mole after f087826/920676b); fix = walk text nodes + `img,svg` in overlayer.js, never block-tag selectors; jsdom test stubs `Range.prototype.getClientRects` - [Android image callout freeze](android-image-callout-freeze.md) — long-press `` fires WebView native callout that collides with app touch handlers → whole-app freeze; `-webkit-touch-callout: none` doesn't inherit so put `.no-context-menu` on an ancestor of the image (`.no-context-menu img` rule in globals.css); seen on book covers (#4345) + image preview/zoom (#4420, `ImageViewer.tsx`) - [ProgressBar focus-ring line (#4397)](progressbar-focus-ring-4397.md) — decorative `.progressinfo` footer was `tabIndex={-1}` → Android long-press focused it → stray content-width focus-ring line at the bottom every page; fix = drop tabIndex (role='presentation' must not be focusable); ffmpeg-the-video debugging + live-browser `:focus-visible` confirmation diff --git a/apps/readest-app/.claude/memory/android-sideload-same-versioncode.md b/apps/readest-app/.claude/memory/android-sideload-same-versioncode.md new file mode 100644 index 00000000..8a806777 --- /dev/null +++ b/apps/readest-app/.claude/memory/android-sideload-same-versioncode.md @@ -0,0 +1,14 @@ +--- +name: android-sideload-same-versioncode +description: Android sideloaded APK reinstall allows EQUAL versionCode; only strictly-lower is blocked +metadata: + node_type: memory + type: reference + originSessionId: a58a4eba-7a3c-4560-9b52-e3713c6ad211 +--- + +Sideloaded APK installs (Readest's in-app updater path: `installPackage` → `Intent.ACTION_VIEW` with `application/vnd.android.package-archive` → system package installer, NOT Play Store) permit reinstalling an APK whose `versionCode` is **equal** to the currently installed one — it's an in-place reinstall/update as long as the signing certificate matches. Android's `INSTALL_FAILED_VERSION_DOWNGRADE` only triggers for a **strictly-lower** versionCode. (Play Store, by contrast, requires a strictly-incrementing versionCode — that constraint does NOT apply to sideload.) + +Consequence for the nightly update channel ([[android-open-with-intent-flow]] uses the same NativeBridge install path): Tauri derives `versionCode = major*1000000 + minor*1000 + patch`, dropping any prerelease suffix, so all nightlies on base `0.11.4` share `versionCode=11004`. That is FINE — they reinstall over each other and over stable `0.11.4`. Because the base only ever increases (0.11.4 → 0.11.5 → ...), nightly versionCode is monotonic non-decreasing, so there is never a downgrade. No need to derive a per-build versionCode from the date stamp. The app's `versionName` carries the full `0.11.4-2026061406` string, which is what the JS `getAppVersion()` updater comparison uses. + +A plausible-but-wrong review claim ("same versionCode means Android refuses the install as not-an-upgrade") confuses Play Store rules with sideload behavior. Corrected by the project owner 2026-06-14. diff --git a/apps/readest-app/.claude/memory/dict-lemmatization-4574.md b/apps/readest-app/.claude/memory/dict-lemmatization-4574.md new file mode 100644 index 00000000..eb2a8c0a --- /dev/null +++ b/apps/readest-app/.claude/memory/dict-lemmatization-4574.md @@ -0,0 +1,22 @@ +--- +name: dict-lemmatization-4574 +description: "Dictionary lookup lemmatizes inflected words (ran→run, mice→mouse) before lookup; pluggable per-language registry, English impl, candidate-chain integration" +metadata: + node_type: memory + type: project + originSessionId: d4206b72-47da-4c3d-adab-04df32c1137a +--- + +#4574 FR: dictionary lookup should normalize inflected forms before lookup. Dicts that store only base headwords (Oxford Dictionary of English, Cambridge, Longman) miss `ran`/`mice`/`children`/`analyses`/`realised` even though `run`/`mouse`/`child`/`analysis`/`realise` exist. + +**Integration point** (single, central): `src/services/dictionaries/lookupCandidates.ts` `buildLookupCandidates(word, lang?)` — appends `getLemmaCandidates(lower, lang)` to the tail of `[trimmed, lower, title, upper]`. Lemmas sit AFTER exact/case so exact match always wins. The pre-existing lookup loop in `DictionaryResultsView.tsx` (`useDictionaryResults`, shared by desktop popup + mobile sheet) tries each candidate and breaks on first non-empty hit; wiring was a one-liner passing `langCode` (already in effect scope) as the 2nd arg. Applies to ALL definition providers (mdict/stardict/dict/slob + online builtins). + +**New module** `src/services/dictionaries/lemmatize/`: +- `index.ts` — `getLemmaCandidates(word, lang)` + `Record` registry (`Lemmatizer = (word)=>string[]`). Lang normalized via `normalizedLangCode` (utils/lang.ts) to primary subtag. **Missing/empty lang defaults to `'en'`** (`normalizedLangCode(lang) || 'en'`); **explicit non-English with no registered lemmatizer → `[]`** (we never force English onto e.g. `fr`/`zh`). Add a language = register one fn, no caller changes. +- `english.ts` — `lemmatizeEnglish(word)`: `IRREGULAR_GROUPS` (base→[forms], flattened to inflected→base at load) for suppletive verbs / irregular plurals / irregular comparatives, + regular suffix rules (plural -s/-es/-ies→y/-ves→f,fe/-ses→sis; past -ed/-d/-ied→y + de-double; -ing + e-restore + de-double + -ying→ie; comparative -er/-est/-ier→y; possessive `'s`; adverb -ly). ASCII-single-token guard `/^[a-z][a-z'’-]*$/` (no-op on phrases/numbers/CJK/accented). Lowercases input; never returns the input itself or single letters. + +**Key design insight: over-generate, let the dictionary validate.** The lemmatizer need not be linguistically precise — a bogus stem just misses and the loop moves on. So rules can be liberal. Cost is bounded: lemmas only fire AFTER exact+case all return empty (genuine "not a headword"), and the English rules produce ~2–5 candidates. + +**Ordering gotcha**: `-ses→-sis` rule must come BEFORE generic `-es`/`-s` so `analyses`→`analysis` (the issue's expected noun) is tried ahead of `analyse` (the verb). Both are linguistically valid for `analyses`; issue wants the noun. + +Tests: `__tests__/services/dictionaries/lemmatize/{english,index}.test.ts` + extended `lookupCandidates.test.ts` (all 8 issue cases asserted). Existing trim test's `spaced` sample swapped to `planet` (non-inflecting) since no-lang path now defaults to English lemmatization. Pure functions, fully deterministic — no live MDX needed. Related: [[dict-lookup-browser-hijack-4559]], [[wordwise-feature]]. diff --git a/apps/readest-app/.claude/memory/global-annotation-pageturn-perf-4575.md b/apps/readest-app/.claude/memory/global-annotation-pageturn-perf-4575.md new file mode 100644 index 00000000..2015e626 --- /dev/null +++ b/apps/readest-app/.claude/memory/global-annotation-pageturn-perf-4575.md @@ -0,0 +1,19 @@ +--- +name: global-annotation-pageturn-perf-4575 +metadata: + node_type: memory + type: project + originSessionId: 4640857e-9c37-4ec6-890a-8aa20ec3a3f3 +--- + +**#4575** — "after highlighting several main-character names, page turning is very laggy" (Chinese web-novel TXT). Root cause = the **global highlight** feature (highlight-all-occurrences, `note.global`), NOT plain highlights. The `progress` effect in `Annotator.tsx` (`for (const a of annotationIndex.globals) expandAllRenderedSections(view, a)`) re-fans-out EVERY global note across EVERY rendered section on EVERY page turn. Each pass: TreeWalker over the section DOM (`findTextRanges` in `globalAnnotations.ts`) + `view.getCFI(index, range)` per occurrence (~0.2ms each, the dominant cost) + `overlayer.add` which removes+recreates an SVG and calls `getRects` (forces layout). Overlays already exist after pass 1 → pure waste. + +**Profiled live** (dev-web + claude-in-chrome, real ``): 6 names / 226 occurrences across 2 rendered chapters = **~25–45ms synchronous main-thread per page turn** on desktop (×3–5 on mobile = the lag). Leads 姜窈(73×) 驰厉(66×) per 2 chapters. + +**Fix (PR branch `fix/global-annot-pageturn-4575`, commit f1404c6b1):** module-level `WeakMap>` `expandedByDoc` in `globalAnnotations.ts`. `expandGlobalAnnotation` skips when `docMemo.get(note.id) === signature`; records after expanding (even 0 matches). `signature = updatedAt:style:color:text`. `removeGlobalAnnotationOverlays` clears the memo entry. Turns 2..N → ~0ms; one-time cost stays at section-render (`onCreateOverlay`). 5 unit tests in `src/__tests__/utils/global-annotations.test.ts`. + +**Correctness invariant:** `doc` & `overlayer` are created/destroyed together per section content-record, and `getContents()` returns STABLE doc/overlayer refs across separate calls (verified). So "same doc ⟺ overlays still present" — a re-rendered section gets a fresh `doc` → memo miss → re-expand; never wrongly skips. + +**Secondary complaints in the issue (NOT fixed here):** slow TXT import (`txt.ts` parse, "2MB should be instant"), slow TOC/notes open. Separate concerns. + +**Repro recipe — import a GBK TXT into dev-web without the native picker:** copy the .txt into `public/`, then in-browser `fetch('/file.txt')` → `arrayBuffer` → `new File([buf], '原名.txt')` → `DataTransfer` → dispatch a synthetic `drop` `DragEvent` (with `Object.defineProperty(ev,'dataTransfer',{value:dt})`) on `.library-page`. Raw bytes preserved → app's encoding detection handles GBK. Books at `/Users/chrox/Documents/books/issues/4575/`. See [[tts-sync-chrome-verification]] for the live-foliate-view profiling pattern. diff --git a/apps/readest-app/.claude/memory/issue-4584-tap-death-investigation.md b/apps/readest-app/.claude/memory/issue-4584-tap-death-investigation.md new file mode 100644 index 00000000..83723376 --- /dev/null +++ b/apps/readest-app/.claude/memory/issue-4584-tap-death-investigation.md @@ -0,0 +1,48 @@ +--- +name: issue-4584-tap-death-investigation +description: "#4584 single-taps-dead-after-picture-zoom: isPopuped self-heals (red herring), likely WebView-148-specific, plus Android emulator/CDP gesture-verification gotchas" +metadata: + node_type: memory + type: project + originSessionId: a41b6cab-c0f3-4740-a4c0-61a10b68fc09 +--- + +#4584 (Android): after using picture zoom (long-press image → ImageViewer → close), +single taps stop registering until app restart while long-press keeps working. OP on +Android 16 / WebView 148. **NOT root-caused / NOT fixed.** PR #4600 only adds single-tap +as a second way into the viewer (see [[tap-to-open-image-table-4600]]); it does not fix +the tap-death. + +**Red herring — don't "fix" `isPopuped`.** An adversarial analysis pointed at +`useTextSelector.ts` `handleSingleClick`'s `isPopuped` branch consuming a tap without +resetting the flag (unlike its `isTextSelected`/`isUpToPopup` siblings). But it +**self-heals**: the branch calls `handleDismissPopup()` → `showingPopup` false → +`handleShowPopup(false)` → `isPopuped` false after 500ms. And the uncancelled 500ms +timer in `handleShowPopup` always converges `isPopuped` to the FINAL `showingPopup` +(timers are same-delay FIFO), so it can't strand `isPopuped=true` with `showingPopup=false`. +Net effect is at most a one-tap glitch, NOT permanent. Resetting it in the branch is +harmless but does NOT explain #4584. + +Likely **WebView-148-specific**: the OP reproduces; the maintainer's Xiaomi and the +Pixel_9_Pro emulator (WebView **133**) do NOT. A stronger unverified permanent-death +candidate is `isInstantAnnotating`/`scrollLocked` getting stuck when the ImageViewer +overlay swallows the `pointerup` (needs instant-annotation enabled). + +**Android emulator gesture-verification gotchas (this machine):** +- Host-GPU `Pixel_9_Pro` AVD CRASHES on rapid pinch (Vulkan `bad_function_call`); a + re-launch can hang at Vulkan init. `-gpu swiftshader_indirect` boots reliably but is + too slow for the WebView reader → repeated "Input dispatching timed out for + FocusEvent" ANRs under automation. **That ANR is an EMULATOR ARTIFACT, not app code** + (JS stays responsive; CPU runs native arm64 on Apple Silicon, only the GPU is + swiftshader; CPU profile of page-turns is light; full-screen backdrop-blur made zero + frame-time difference). +- CDP `Input.dispatchTouchEvent` CANNOT trigger the WebView's native text-SELECTION + gesture, so the annotation popup never appears (blocks reproducing selection-driven + bugs). It also leaves `screenX=0`, so `handlePageFlip` treats every synthetic tap as + left/`prev()` (no-op at page 1 → navigate mid-book to test page-turns via relocate). +- Native `adb shell input tap` does NOT reach the WebView's click pipeline. +- Long-press via CDP only works if you HOLD the touch on one open connection and poll + (a touchStart→sleep→touchEnd across separate `node` invocations releases too early). +- Drive the live on-device WebView: `adb forward tcp:9222 localabstract:webview_devtools_remote_` + + a `ws` CDP client; the debug APK exposes the socket. VIEW-intent a pushed EPUB via its + MediaStore `content://.../` to import it. diff --git a/apps/readest-app/.claude/memory/nightly-updater-android-e2e.md b/apps/readest-app/.claude/memory/nightly-updater-android-e2e.md new file mode 100644 index 00000000..6c1b3824 --- /dev/null +++ b/apps/readest-app/.claude/memory/nightly-updater-android-e2e.md @@ -0,0 +1,18 @@ +--- +name: nightly-updater-android-e2e +description: "How to E2E-test the nightly self-updater on a real Android (Xiaomi/HyperOS) device — devtools build, CDP-over-adb, MIUI install gates" +metadata: + node_type: memory + type: reference + originSessionId: b0c01e3c-9485-45fe-8ae3-eb5f2762f8fa +--- + +End-to-end validating the in-app nightly updater (#4577) on the physical Xiaomi 13 (`fuxi`, model 2211133C, HyperOS V816 / Android 16, arm64). Verified the full chain: stable `0.11.4` on nightly channel → fetch `nightly/latest.json` → detect newer nightly → dialog+changelog → download APK → minisign-verify → Android PackageInstaller → app relaunches as `0.11.4-2026061506` with user data intact. + +**Get CDP on a release build:** `pnpm dev-android` = `tauri android build -t aarch64 -- --features devtools` then `adb install -r`. The `devtools` cargo feature enables WebView remote debugging; the **CI nightly has no such flag → no CDP socket**. The local build is release-signed via `src-tauri/gen/android/keystore.properties` (alias `upload`, keystore at `/Users/chrox/dev/Android/keys/upload-readest-keystore.jks`), cert SHA-256 `652d1167…` — SAME as the CI release/nightly cert, so it installs over (and is replaced by) the real nightly. versionCode for `0.11.4[-stamp]` is always `11004` (Tauri ignores the prerelease), and sideload allows equal versionCode. After the updater installs the real nightly, CDP is gone again (no devtools). + +**Comparator (`utils/version.ts` `isUpdateNewer`):** on equal X.Y.Z, a nightly outranks the matching stable (`c.isNightly && !cur.isNightly → true`). So a stable build on the nightly channel IS offered the latest nightly — no need to stamp an older version. Two nightlies compare by the 10-digit stamp. + +**CDP-over-adb gotcha:** `adb forward tcp:9333 localabstract:webview_devtools_remote_` (use the LIVE pid — stale sockets for dead pids linger in `/proc/net/unix`). The WebView's `/json` HTTP server breaks BOTH curl and Node `http` (framing → "empty reply"/"socket hang up"); fetch the page list over a RAW TCP socket instead, and build the ws URL yourself as `ws://127.0.0.1:9333/devtools/page/` (the returned `webSocketDebuggerUrl` reflects the request Host header and drops the port). Then `Runtime.evaluate` works. Helper pattern lived in `/tmp/nightly-test/cdp.cjs`. Settings store is NOT on `window`; drive the real UI via evaluated `.click()` (Settings Menu → "Nightly Builds (Unstable)" toggle → "About Readest" → "Check Update"). + +**MIUI/HyperOS install gates (the hard part; needs adb taps + uiautomator, NOT WebView):** (1) "Readest 正尝试安装应用" → tap 继续. (2) "Couldn't find ICP registration info" (China-region nag) → tap **Install** (left grey), NOT the blue Exit. (3) Enhanced-protection installer shows NO direct install button — the visible "安装" is the AD app's (`installBtn`, `com.aliyun.tongyi` etc. — don't tap it); the real path is top-right **More (⋮) → "单次安装授权"** (one-time auth), then the bottom **OK**. "Security authorization → Authorize unverified apps" uses Face-unlock as the verification method (per-install biometric; single-install-auth covered it here). Use `uiautomator dump` to get exact button bounds — native dialogs are introspectable (unlike the WebView). Pin every adb cmd with `ANDROID_SERIAL` when an emulator is also attached; zsh doesn't word-split `$ADB` vars (use the env var or inline the full path). diff --git a/apps/readest-app/.claude/memory/r2-rclone-createbucket-403.md b/apps/readest-app/.claude/memory/r2-rclone-createbucket-403.md new file mode 100644 index 00000000..6f750b41 --- /dev/null +++ b/apps/readest-app/.claude/memory/r2-rclone-createbucket-403.md @@ -0,0 +1,14 @@ +--- +name: r2-rclone-createbucket-403 +description: rclone copyto/moveto 403s on object-scoped R2 tokens (hidden CreateBucket probe); use a directory rclone copy +metadata: + node_type: memory + type: project + originSessionId: b0c01e3c-9485-45fe-8ae3-eb5f2762f8fa +--- + +Single-file `rclone copyto`/`moveto` to Cloudflare R2 first issues a **CreateBucket** probe — `PUT /` with no object key — to "ensure the bucket exists." An **Object Read & Write** R2 token (least-privilege; can't create buckets) returns `403 AccessDenied` (a real S3 `AccessDenied`, with an *empty* `request id` because R2 short-circuits it). A **directory** copy `rclone copy DIR dst/` does NOT probe — it `PutObject`s the key directly → 200. + +**Why it bit us:** The nightly channel (#4577) `assemble-manifest` job promoted `nightly/latest.json` via `copyto` + server-side `moveto` → 403, so the manifest was never published (build legs uploaded fine). The build legs and the stable release flow (`upload-to-r2.yml` writing `releases/latest.json`) were unaffected because they use `rclone copy DIR/`. The token had Admin earlier (build legs passed), then was narrowed to Object-R&W mid-investigation, which is when even the build-leg style would have started needing the directory form. Fixed in PR #4588. + +**How to apply:** For any R2 write in CI use the directory form — `mkdir -p d && cp f d/ && rclone copy d r2:bucket/prefix/` (mirrors `upload-to-r2.yml`) — OR add `no_check_bucket = true` to the `[r2]` rclone.conf (equiv. `--s3-no-check-bucket`). R2 `PutObject` is atomic, so a `.tmp`+`moveto` "atomic promote" adds nothing over a direct write. Diagnose live with a push-triggered throwaway probe workflow (`on: push` to a feature branch — `workflow_dispatch` won't dispatch a workflow that isn't on the default branch); `rclone -vv --dump bodies` reveals `PUT /` (CreateBucket) vs `PUT //`. diff --git a/apps/readest-app/.claude/memory/tap-to-open-image-table-4600.md b/apps/readest-app/.claude/memory/tap-to-open-image-table-4600.md new file mode 100644 index 00000000..13eb0d1e --- /dev/null +++ b/apps/readest-app/.claude/memory/tap-to-open-image-table-4600.md @@ -0,0 +1,32 @@ +--- +name: tap-to-open-image-table-4600 +description: Single-tap opens image gallery / table zoom in reflowable EPUBs; the iframe-long-press message was renamed to iframe-open-media +metadata: + node_type: memory + type: project + originSessionId: a41b6cab-c0f3-4740-a4c0-61a10b68fc09 +--- + +PR #4600 (motivated by [[issue-4584-tap-death-investigation]]). In **reflowable** +EPUBs a single tap on an `` / ``-with-`` / `` now opens the +`ImageViewer` (gallery) / `TableViewer` (zoom), in addition to long-press. + +- **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 ``) still + follow the link (the existing `sup, a, audio, video` skip). + +Impl in `src/app/reader/utils/iframeEventHandlers.ts`: +- New shared `detectMediaTarget(el) -> {elementType:'image',src} | {elementType:'table',html} | null`, + used by BOTH `handleLongPress` and the new single-tap branch in `postSingleClick`. +- The single-tap branch sits AFTER the link/footnote/`isMouseDown`/`!longHoldTimeout`/ + Word-Wise-gloss guards, so drag/long-hold/double-tap can't double-trigger; it rides + the 250ms double-click deferral. +- `handleClick` gained an `isFixedLayout` param, passed from `FoliateViewer` + `docLoadHandler` via `!!bookData?.isFixedLayout`. + +**RENAME gotcha:** the window message `iframe-long-press` is now **`iframe-open-media`** +(it fires for both gestures). Consumer hook `useLongPressEvent` → **`useOpenMediaEvent`** +(internal handler `handleOpenMedia`). The long-press DETECTOR +`addLongPressListeners`/`handleLongPress` keeps its name (it still detects a real +long-press). Search `iframe-open-media`, not `iframe-long-press`. diff --git a/apps/readest-app/.claude/memory/tts-browser-e2e-harness.md b/apps/readest-app/.claude/memory/tts-browser-e2e-harness.md new file mode 100644 index 00000000..4a811b64 --- /dev/null +++ b/apps/readest-app/.claude/memory/tts-browser-e2e-harness.md @@ -0,0 +1,41 @@ +--- +name: tts-browser-e2e-harness +description: How to build a faithful TTS auto-advance browser e2e test (real view + real useTTSControl + mock client only) +metadata: + node_type: memory + type: project + originSessionId: fc8905a6-43fb-4e61-ae78-a2081098f6bb +--- + +Recipe + gotchas for `src/__tests__/services/tts-auto-advance.browser.test.tsx` — a real-Chromium browser test that mounts the real foliate `` + real `TTSController` + real `useTTSControl` hook and mocks ONLY the speech client. Verifies TTS reading from the last paragraph of Ch4 auto-advances into Ch5, the page turns, and the "Back to TTS Location" badge never appears (TTS location stays in view). + +**Why a browser test, not jsdom:** real page-turn needs real layout. Unit config (`vitest.config.mts`) excludes `**/*.browser.test.{ts,tsx}`; browser config (`vitest.browser.config.mts`) includes them — name the file `*.browser.test.tsx` and it routes to the right runner automatically. + +**The only seam = the speech client.** `vi.mock` the three modules (`WebSpeechClient`/`EdgeTTSClient`/`NativeTTSClient`) returning a mock from a hoisted `function` (so vi.mock factories can reference it). The mock's `speak()` is tiny because `TTSController.#speak` only reads the event `code`: when last code is `end` it calls `forward()`, and the REAL `view.tts` walks the REAL document to the next sentence / across the section boundary. So: +```ts +speak: async function* (_ssml, signal, preload) { + if (preload) return; // preloadSSML iterates the generator — emit nothing on preload + await sleep(25); // lets React flush + relocate run between sentences + if (signal.aborted) return; + yield { code: 'end' }; +} +``` +`supportsWordBoundaries()` → false (sentence path; word path needs the client to emit boundaries). Keep `parseSSMLMarks`, `foliate-js/tts.js`, ssml, overlayer all REAL (do NOT mock them — that's what [[tts-fixes]]-style unit tests do, but here we want the genuine walk). + +**Mock the contexts, keep the stores real.** `useEnv`/`useAuth` throw outside providers → `vi.mock('@/context/EnvContext', () => ({ useEnv: () => ({ envConfig:{}, appService: null }) }))` (+ AuthContext `useAuth: () => ({ user: null })`). `appService: null` is fine — all mobile/iOS branches in `useTTSControl`/`useTTSMediaSession` are null-guarded, and `getMediaSession()` returns the real `navigator.mediaSession` (not `TauriMediaSession`) in Chromium so the heavy branch is skipped. + +**Seed stores directly (skip the heavy `initViewState`):** +- `useReaderStore.setState` → `viewStates[bookKey]` with all `ViewState` fields, `isPrimary: true`, `view`, `viewSettings`. +- `useBookDataStore.setState` → `booksData[id]` = `{ id, book:{title,author,coverImageUrl,primaryLanguage:'en'}, file:null, config, bookDoc, isFixedLayout:false }`. `id = bookKey.split('-')[0]`. +- GOTCHA: `useSettingsStore` defaults `settings: {} as SystemSettings`, so `proofreadStore.getMergedRules` → `settings.globalViewSettings.proofreadRules` throws → the speak chain rejects → `controller.error` → state `stopped` → it never crosses sections (symptom: stuck at the start section). FIX: `useSettingsStore.setState({ settings: { ...s.settings, globalViewSettings: viewSettings } })`. +- `setProgress` early-returns unless BOTH `viewStates[key]` and `booksData[id]` exist; only `isPrimary` writes `ttsLocation`/config. + +**Reproduce FoliateViewer's `relocate → setProgress` glue** (the one production bit not under test) so `useBookProgress` updates on every page turn — that's what drives the badge effect. Copy `commitRelocate` (`detail.cfi/tocItem/pageItem/section/location/time/range`, `atEnd` → last page). Synchronous (skip the rAF batching) is fine and more deterministic. + +**Navigation API:** index nav is on the RENDERER (`view.renderer.goTo({ index })`, `view.renderer.next()/prev()` return Promises). `view.goTo(href)` takes an href/cfi STRING. Initial display via `view.goToFraction(0)` after `view.open(bookDoc)` + `renderer.setAttribute` (max-column-count 1, sizes, margins, gap) in a fixed-size container appended to body. + +**sample-alice.epub spine (all linear, 0-based sections):** 0 cover · 1 title · 2 about · 3 main0=Ch1 · 4 Ch2 · 5 Ch3 · **6 main3=Ch4** · **7 main4=Ch5** … Ch4/Ch5 are adjacent — the boundary to cross. `hydrateBookNav(bookDoc, await computeBookNav(bookDoc))` (from `@/services/nav`) before `view.open` so relocate's `tocItem.label` carries "Chapter N …" → assert `getProgress().sectionLabel` matches `/Chapter 5/`. + +**Drive + assert** with `renderHook(useTTSControl)` + `act`: navigate to Ch4 last page (goTo index 6, then `renderer.next()` until primaryIndex !== 6, then one `prev()`), dispatch `eventDispatcher.dispatch('tts-speak',{ bookKey, index:6 })`, poll `result.current.showBackToCurrentTTSLocation` (track "ever true") until `view.renderer.primaryIndex === 7`, settle ~700ms, then assert: primaryIndex 7, sectionLabel /Chapter 5/, `resolveCFI(ttsLocation).index === 7`, badge `false` and never appeared. Assert BEFORE `tts-stop` (handleStop resets the badge flag to false, making a post-stop assertion meaningless). Cross-section page turn is driven by the controller's `onSectionChange` callback (wired in the constructor, not the effect) so it's robust to the effect-registration race. + +Related: [[tts-fixes]], [[edge-tts-word-highlighting-4017]], [[android-cdp-e2e-lane]]. diff --git a/apps/readest-app/.claude/memory/tts-sync-chrome-verification.md b/apps/readest-app/.claude/memory/tts-sync-chrome-verification.md new file mode 100644 index 00000000..2b17376c --- /dev/null +++ b/apps/readest-app/.claude/memory/tts-sync-chrome-verification.md @@ -0,0 +1,38 @@ +--- +name: tts-sync-chrome-verification +description: Edge TTS DOES work in the claude-in-chrome browser (WebSpeech errors there); recipe to verify TTS-driven features live + the cross-realm CFI bug it exposed +metadata: + node_type: memory + type: reference + originSessionId: c39202b7-d8c2-4150-a618-c31857a8ad73 +--- + +Verifying TTS-driven reader features (RSVP/paragraph follow, #3235) live in the +`claude-in-chrome` (GStack) browser on `pnpm dev-web` (localhost:3001): + +- **Edge TTS WORKS in claude-in-chrome.** Earlier assumption that "TTS can't run + in the automated browser" is WRONG. Select an **Edge voice** and read-aloud + plays + emits per-word boundaries. Verified: RSVP followed the spoken word at + **~171 wpm** (natural audio pace, not the 300-wpm self-timer). +- **WebSpeech, by contrast, errors** there: `InvalidStateError: Transition was + aborted because of invalid state` (no system voices / autoplay). So if TTS + "does nothing" in chrome, the engine is WebSpeech — switch to Edge to test. +- The `readaloud` endpoint UA gate (Edg, non-headless) noted in + [[edge-tts-word-highlighting-4017]] did NOT block it here on dev-web. + +**Synthetic-CFI debug recipe (no audio needed)** — how the #3235 sync bug was +found: temporarily expose the live controller (`window.__rsvpDebug = { controller, +view, bookKey }` in the `isActive` effect), enter RSVP, then in the page: +`cfi = view.getCFI(word.docIndex, word.range); controller.syncToCfi(cfi)` for a +few words and assert `currentState.currentIndex` lands on that word. This drives +the real CFI→range path with real iframe Ranges without needing TTS audio. + +**The bug it exposed (#3235):** RSVP/paragraph "follow TTS" silently never +advanced. Root cause = the cross-realm `instanceof Range` pitfall +([[iframe-cross-realm-instanceof]]): `view.resolveCFI(cfi).anchor(doc)` returns a +Range from the **book iframe's realm**, so `anchor instanceof Range` (top realm) +is always false → `resolveCfiToRange`/`applySyncCfi` returned null → `syncToCfi` +false → frozen. jsdom is single-realm so unit tests passed but it died in-app. +Fix = `isRangeLike()` duck-type (`cloneRange` is unique to Range) in +`src/utils/range.ts`, used at all 4 CFI-resolution sites (RSVPController + +useParagraphMode). See [[tts-browser-e2e-harness]] for the e2e harness. diff --git a/apps/readest-app/.claude/memory/tts-sync-paragraph-rsvp-3235.md b/apps/readest-app/.claude/memory/tts-sync-paragraph-rsvp-3235.md new file mode 100644 index 00000000..8e818a91 --- /dev/null +++ b/apps/readest-app/.claude/memory/tts-sync-paragraph-rsvp-3235.md @@ -0,0 +1,24 @@ +--- +name: tts-sync-paragraph-rsvp-3235 +metadata: + node_type: memory + type: project + originSessionId: c39202b7-d8c2-4150-a618-c31857a8ad73 +--- + +#3235 "sync paragraph & speed reader with TTS" (PR #4576, branch `feat/tts-sync-paragraph-rsvp`). TTS is the clock; visual modes follow one-way (never drive TTS back). Fixed-layout gated off (indicator 'unsupported'). + +**Position bus.** `TTSController.#dispatchPosition(cfi, kind)` emits canonical `tts-position {cfi, kind:'word'|'sentence', sectionIndex, sequence}` (monotonic `#positionSequence`). `useTTSControl` forwards it +bookKey, plus `tts-playback-state {state}`. Followers drop stale events via `sequence <= lastSequenceSeen` (dispatch awaits listeners serially → out-of-order possible). Edge emits BOTH a per-sentence mark AND per-word boundaries; WebSpeech/Native emit sentence only. + +**Cross-realm.** CFI anchors from `view.resolveCFI(cfi).anchor(doc)` are iframe-realm Ranges → top-realm `instanceof Range` is ALWAYS false. Use `isRangeLike` (`src/utils/range.ts`, duck-types `cloneRange`). See [[tts-sync-chrome-verification]] (this exact bug froze the follow; Edge TTS verify recipe lives there). + +**In-mode audio toggle.** Both modes get a 🔊 toggle (IoVolumeHigh/IoVolumeMediumOutline) to start read-along from the focused word/paragraph. `buildParagraphTtsSpeakDetail` / `buildRsvpTtsSpeakDetail` build the `tts-speak` detail = `{bookKey, index(spine), range}`; range included ONLY when live (`range.startContainer.ownerDocument === currentDoc`) else dropped → TTS uses its own start. Session-active (playing OR paused) vs playing tracked separately so a pause keeps the indicator + layout ('paused' status); only a full stop clears. + +**Current word/sentence highlight (the non-obvious part).** Paragraph overlay renders a CLONE of the paragraph (`range.cloneContents()` → `dangerouslySetInnerHTML`), so the iframe's TTS highlight is NOT visible there. Reproduce it on the clone with the **CSS Custom Highlight API**: `CSS.highlights.set('readest-tts-paragraph', new Highlight(range))` + a `::highlight(readest-tts-paragraph){…}` rule. Wins over ``-wrapping: no DOM mutation (clone markup + fade-in animation untouched), and it spans inline-element boundaries natively (sentence highlights). Supported in all targets (guard `CSS.highlights && typeof Highlight !== 'undefined'`, no-op else). +- Offsets computed in the hook (iframe realm) relative to the PARAGRAPH START via a pre-range `setStart(para.start) setEnd(target.start)` toString length (`computeParagraphHighlightOffsets`). They map 1:1 onto the clone's text because both the clone and the offsets start at para.start. Overlay rebuilds the clone-range with `getTextSubRange(base, start, end)` (reused from `src/services/tts/wordHighlight.ts`) where base = `selectNodeContents(.paragraph-content)`. +- Tag the highlight with the paragraph INDEX; the overlay effect applies it only when `highlight.index === focusIndex` so a stale highlight never paints the wrong paragraph (focus + highlight are separate dispatches). Effect deps include `paragraphs` so it rebuilds the range against fresh clone DOM. +- `::highlight()` style derived from `viewSettings.ttsHighlightOptions` via `buildTtsHighlightCssText` (highlight/outline → translucent `color-mix` bg; underline/squiggly/strikethrough → text-decoration). `::highlight` pseudo only supports color/background-color/text-decoration/text-shadow. + +**kind-gating** (`decideParagraphTtsHighlight {kind, hasWordPositions}` → word|sentence|skip): word→'word'; sentence after words seen→'skip' (else the coarse sentence flickers over the current word); sentence w/ no words→'sentence'. `hasWordPositionsRef` reset on full stop. `applySyncCfi(cfi, highlight)` always selects the paragraph (so 'skip' still follows) and dispatches `paragraph-tts-highlight` only when highlight. RSVP shows ONE word so needs no highlight — mode-specific. + +**Files.** `useParagraphMode.ts` (applySyncCfi, handlePosition kind-gate, pendingSync.kind, toggleTtsAudio), `ParagraphOverlay.tsx` (CSS.highlights effect + `paragraph-tts-highlight` listener + `::highlight` rule), `paragraphTts.ts` (pure helpers, unit-tested), RSVP mirror in `rsvp/RSVPControl.tsx`+`rsvpTts.ts`+`RSVPOverlay.tsx`, `TTSFollowIndicator.tsx`. SettingsDialog `!z-[10050]` to open over the RSVP overlay (z-[10000]). diff --git a/apps/readest-app/.claude/memory/wordwise-feature.md b/apps/readest-app/.claude/memory/wordwise-feature.md new file mode 100644 index 00000000..e8a2229c --- /dev/null +++ b/apps/readest-app/.claude/memory/wordwise-feature.md @@ -0,0 +1,30 @@ +--- +name: wordwise-feature +description: "Word Wise inline-gloss feature — CFI-safe ruby technique, TTS/search isolation, gloss-data build caveat" +metadata: + node_type: memory + type: project + originSessionId: 5a24d2e1-f19b-4e29-b1f6-e5e4969e2a7b +--- + +Kindle-style **Word Wise**: a short native-language gloss above difficult words as you read, gated by a **CEFR vocabulary-level slider (A1–C2)** mapped to frequency-rank cutoffs in `difficulty.ts` (A1=most hints/low cutoff … C2=fewest; an approximation, NOT per-word CEFR; zh = analogy over HSK), tap-to-open the existing dictionary. **Shipped as PR #4589 (Closes #2146)** — branch `feat/word-wise` squashed to 2 commits (code, then `data(wordwise)`), rebased on origin/main, pushed. Worktree `/Users/chrox/dev/readest-feat-word-wise`. Spec + plan in `apps/readest-app/docs/superpowers/{specs,plans}/2026-06-14-word-wise*`. (Do NOT reference "jinshu" in repo docs/code — scrubbed per user request.) + +**Files:** `src/services/wordwise/{types,difficulty,glossIndex,planner}.ts` (pure pipeline), `src/app/reader/utils/{wordwiseRuby,wordwiseSection}.ts` (DOM inject/clear + orchestration), `src/components/settings/WordWisePanel.tsx`, `scripts/build-wordwise-data.mjs`, `public/wordwise/{en-zh,zh-en}.json` + `ATTRIBUTION.md`. + +**CFI-safe DOM mutation (the key technique).** Glosses render as `wordgloss` injected into the live iframe doc. foliate `epubcfi.js` `rawChildNodes` HOISTS a `cfi-skip` wrapper's children and REMOVES a `cfi-inert` subtree, then `indexChildNodes` re-merges the now-adjacent text nodes into one chunk → CFI is byte-identical to plain text in BOTH `fromRange` and `toRange`. So highlights/bookmarks/progress are unaffected. `xcfi.ts` honors the same attrs → KOSync safe too. Gate test: `epubcfi-ruby-inline.test.ts` (the pre-existing `epubcfi-{inert,skip}.test.ts` only cover block wrappers, NOT mid-text inline ruby with text on both sides). NOTE: an early research subagent wrongly claimed `cfi-skip`/`cfi-inert` only affect tree-walk not offset summation — that's FALSE; they fully neutralize offsets. This retired the need for an overlay-rendering fallback. + +**TTS/search/annotation isolation.** Gloss text must stay out of spoken text, find-in-book, and Edge-TTS word offsets. (1) foliate TTS reject filter already has `tags:['rt']` (`TTSController.ts`) → spoken text excludes gloss automatically. (2) `globalAnnotations` findTextRanges already rejects `rt`/`rp`. (3) `wordHighlight.ts` getTextSubRange walked raw `SHOW_TEXT` → added `isInertText` skip + exported `rangeTextExcludingInert` (used in `TTSController.prepareSpeakWords` instead of `range.toString()`), else Edge word offsets drift vs the gloss-free boundary words — this also fixes a latent furigana bug. (4) search `SearchBar.tsx` only rejected `rt` for ja → added `attributes:['cfi-inert']`. + +**Gloss data caveat (IMPORTANT).** The committed `public/wordwise/*.json` are small CURATED STARTER sets (52 EN→中文, 40 中文→EN) so the feature works out-of-the-box. The FULL frequency-trimmed assets are a maintainer step: `node scripts/build-wordwise-data.mjs en-zh [topN]` (ECDICT, MIT, ~160MB full; difficulty = COCA `frq` rank; EN inflections parsed from the `exchange` field) and `zh-en [topN]` (CC-CEDICT CC-BY-SA + HSK; zh tokenized in-app via jieba `cutZh`). Index is lazy-fetched once from `/wordwise/.json` into an in-memory `GlossIndex`. + +**Gotchas fixed in review:** slider-drag fires repeated refreshes → `refreshSectionGlosses` uses a `WeakMap` generation guard (latest wins on a clean doc); a `pendingWordWiseDictRef` could stick `true` past early-returns and hijack the next selection → read-and-reset pattern; skip pre-paginated/fixed-layout books (ruby overflows fixed boxes); adding a `SettingsPanelType` requires updating the exhaustive `panelIcons` map in `commandRegistry.ts` AND its `react-icons/pi` test mock. Tap-to-dict = `iframeEventHandlers` hit-tests `.ww-gloss` → `wordwise-dictionary` event → `Annotator` synthesizes a selection over the base word. **STALE-CLOSURE bug (fixed post-PR):** `handleWordWiseDictionary` is registered in a `useEffect(…, [bookKey])`, so it closes over the render-time `view` const — which is still `null` when the effect first runs (view mounts after bookKey). At tap time the closure's `view` stays null → `view.renderer.getContents()` undefined → content-not-found early-return → no popup, silent. Fix = read `const view = getView(bookKey)` FRESH inside the handler (mirrors the other handlers at lines ~616/709/748 that already do this). Diagnosed live via `[wwtap]` console-trace instrumentation through the whole path (gloss-hit → dispatch → handler → effect); the `content? true index N` log confirmed the fix. General rule for this file: any foliate event handler bound once with `[bookKey]` deps must call `getView(bookKey)` rather than use the closure `view`. + +**R2 runtime delivery (no bundled packs).** Packs are NOT shipped in the installer: source of truth is `apps/readest-app/data/wordwise/` (committed, outside `public/`+`src/` so unbundled) holding `.json` + `manifest.json` (`{pair,source,target,file,bytes,sha256,entries}`); `scripts/sync-wordwise-r2.mjs` (`pnpm wordwise:sync`, env `WORDWISE_R2_BUCKET`) uploads to the `cdn.readest.com` R2 bucket (`WORDWISE_CDN_BASE`). Runtime: `src/services/wordwise/glossPacks.ts` = `fetchManifest`→`resolvePack(source,hint)`→`ensurePack`(sha256 verify, single-flight via in-flight Map, `allowDownload` flag, `?v=` cache-bust on pack URL only)→`loadGlossIndex` (memo does NOT cache null → retries after download), stored in appService `'Data'` (IndexedDB on web). `getPackStatus`/`deletePack`/`listAvailableTargets` drive the UI. + +**CRITICAL Tauri download gotcha (bug-class, invisible to tests):** to download a file cross-platform you MUST write to an ABSOLUTE path (`await appService.resolveFilePath(rel,'Data')`) and read/delete it with base `'None'`. The Rust `download_file`/`tauriDownload` resolves a relative `dst` against the process CWD, but `appService.readFile(rel,'Data')` resolves against AppData → silent mismatch → download "fails" → feature dead on desktop/mobile. Tests that inject a fake downloader NEVER catch this (stub `defaultDownloader`); add a `downloadViaTempFile(appService, downloadFileFn, …)` helper test with a fake AppService keyed by absolute path. Route downloads through the Rust path (not webview `fetch`) to avoid cross-origin/CORS on the webview — CSP is fine (`connect-src` whitelists `https://*.readest.com`). Web branch uses `webDownload` (fetch+stream). See [[android-nativefile-remotefile-io]] for related Tauri IO. + +**Real pack generation + data stack (lightweight).** Generated 13 committed packs (~11MB total) in `data/wordwise/`: en↔中文 via ECDICT(MIT)/CC-CEDICT(CC-BY-SA)+HSK (highest quality there); es/fr/de/pt/it/ru↔en via the lightweight stack. Difficulty = **FrequencyWords** (hermitdave, OpenSubtitles/OPUS, CC-BY-SA-4.0, `word count` lines, rank=line#). Glosses = **WikDict** (`download.wikdict.com/dictionaries/sqlite/2/.sqlite3`, per-pair SQLite 6–30MB, both directions, DBnary/Wiktionary, CC-BY-SA-3.0) — query `simple_translation(written_rep, trans_list)`, trans_list is `|`-joined best-first. CLI `build-wikdict [topN]` (needs `sqlite3` CLI). TRAP: the kaikki 100MB-gz `downloads//-extract.jsonl.gz` is the MONOLINGUAL Wiktionary (same-language defs, 0% English) — NOT bilingual; the English glosses live in the heavy `kaikki.org/dictionary//...jsonl` (0.9–3GB, CC-BY-SA-4.0, the `build` CLI mode) or the 22GB all-langs dump (never needed; never shipped — shipped packs are ~0.3–2.6MB, downloaded on demand). `buildPack` `skipTop:1000` (don't gloss the commonest words) + `topN` (default 20000). `shortGloss` strips POS/`[…]`/`CL:` + 24-char cap. ja/ko/th X→en still deferred (need a segmenter). gloss data files are CC-BY-SA → ship the NOTICE in `data/wordwise/ATTRIBUTION.md` + each pack's `meta`. + +**UI in Settings → Language** (not a top-level tab): `WordWiseSubPanel` opened via a `NavigationRow` in `LangPanel`, mirroring the `CustomDictionaries` sub-page swap (`useKeyDownActions` back/Esc + early-return render). Hint-language `SettingsSelect` = `getLangOptions(TRANSLATED_LANGS)` base-code-filtered against manifest targets (targets are base codes `zh`; TRANSLATED_LANGS has `zh-CN`/`pt-BR` → match on base); value `wordWiseHintLang` ('' = auto = app UI lang via `getLocale()`). Global `ReadSettings.wordWiseAutoDownload` (default true) gates reader auto-download; `isMetered()` (`src/utils/network.ts`, Network Information API, absent on iOS/Tauri → unmetered). Generalized: `WordWiseSourceLang=string`; `difficulty.ts` FREQUENCY cutoffs for all non-zh + HSK-scale (`level×3000`) for zh; `canTokenizeSource` blocks ja/ko/th (tier 3, need a segmenter). Spec: `docs/superpowers/specs/2026-06-14-wordwise-r2-delivery.md`. + +Related: [[edge-tts-word-highlighting-4017]], [[footnote-aside-namespace-order-4438]], [[empty-start-cfi-sync]], [[android-nativefile-remotefile-io]]. diff --git a/apps/readest-app/.gitignore b/apps/readest-app/.gitignore index ebea2b3f..67a800dd 100644 --- a/apps/readest-app/.gitignore +++ b/apps/readest-app/.gitignore @@ -62,6 +62,9 @@ src-tauri/gen # vendor /public/vendor +# docs +docs/superpowers + # Auto Generated PWA files /public/sw.js /public/workbox-*.js