refactor(wordlens): rename "Word Wise" to "Word Lens" (#4633)
"Word Wise" is a Kindle trademark, so rename the inline-gloss feature to
"Word Lens" throughout the product.
- User-facing strings → "Word Lens" across all 34 locales; brand translated
for Chinese (zh-CN 单词透镜, zh-TW 單詞透鏡) and German (Word-Lens-Daten).
- Code identifiers: WordWise→WordLens, wordWise→wordLens, WORD_WISE→WORD_LENS.
- Files/dirs: src/services/wordwise→wordlens, WordWisePanel→WordLensPanel,
wordwise{Ruby,Section}.ts, build/sync scripts, test dirs/fixtures,
data/wordwise→data/wordlens.
- Storage paths: CDN base, R2 key, on-device cache dir, WORDLENS_R2_BUCKET env,
pnpm wordlens:{manifest,sync}. manifest.json is path-agnostic so its
sha256/bytes stay valid (verified).
- biome.json: point the formatter-ignore at data/wordlens so the generated
one-line gloss packs aren't pretty-printed on commit.
Migration notes:
- Re-run `pnpm wordlens:sync` to upload packs to cdn.readest.com/wordlens/.
- Persisted view-settings keys renamed (wordWiseEnabled/Level/HintLang and
wordWiseAutoDownload) — saved values reset to defaults once on upgrade.
- Cached packs under the old Data/wordwise/ orphan (harmless re-download).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
- [KOSync CFI spine resolution](kosync-cfi-spine-resolution.md) — convert via the CFI's own spine (`getXPointerFromCFI`/`getCFIFromXPointer`), never `new XCFI(primaryDoc, primaryIndex)`; primaryIndex lags during scroll → spine-mismatch throw
|
||||
- [Empty-start CFI sync bug](empty-start-cfi-sync.md) — `epubcfi(/6/24!/4,,/20/1:58)` (empty-start range) from the cfi-inert skip-link transitional window; jumps to wrong section end; `isMalformedLocationCfi` → discard the synced value in `useProgressSync` (NOT the local open path); foliate fix doesn't repair already-synced values
|
||||
- [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
|
||||
- [koplugin note deletion sync](koplugin-note-deletion-sync.md) — koplugin push only walked LIVE annotations so deletions never reached the server; fix = `recordDeletion` persists a `deletedAt` tombstone to `doc_settings.readest_sync.deleted_notes`, `push` folds+clears them; deletion signal in `onAnnotationsModified` is `items.index_modified < 0`
|
||||
|
||||
## 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
|
||||
@@ -61,7 +62,7 @@
|
||||
- [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 `<ruby cfi-skip>…<rt cfi-inert>` (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)
|
||||
- [Word Lens inline gloss (feat/word-wise)](wordlens-feature.md) — Kindle-style native-language hint above hard words; CFI-safe via `<ruby cfi-skip>…<rt cfi-inert>` (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-wordlens-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 = `<path>` 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 `</feed>`; Firefox DOMParser → `<parsererror>` (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)
|
||||
@@ -79,6 +80,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
|
||||
- [Search excerpt no context for styled words (#4594)](search-excerpt-context-4594.md) — RESOLVED (foliate-js#25 + readest#4631). italic/`<i>` word = own `strs[]` text node; `makeExcerpt` read context only WITHIN the node → empty pre/post; fix = `collectBefore/After` walk neighbour nodes (+2 latent multi-node match bugs: string-index `slice`, `start===end`)
|
||||
- [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<Document,...>` 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 `<p>`: `#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 `<img>` 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`)
|
||||
@@ -95,6 +97,7 @@
|
||||
- [Tauri menu append race (#4389)](tauri-menu-append-race-4389.md) — un-awaited `Menu.append()` (async IPC) in `BookshelfItem.tsx` → context-menu items shuffle order every open (native only, invisible in jsdom); fix = single `await Menu.new({ items })` of ordered `MenuItemOptions`; order/inclusion extracted to pure `getBookContextMenuItemIds` for unit testing
|
||||
- [TXT author recognition (#4390)](txt-author-recognition-4390.md) — 【】-titled Chinese web-novels show author missing/garbage; they're TXT→EPUB (title==full filename is the tell, check `txt.ts` not foliate-js); `extractTxtFilenameMetadata` only handled 《》 + greedy header capture grabbed metadata blobs; fix = `parseLabeledAuthor` for any filename + `isPlausibleAuthorName` guard
|
||||
- [Cover stale until refresh (in-place mutation vs React.memo)](cover-stale-inplace-mutation-memo.md) — editing a book cover in details + Save left the library cover stale until reload; `handleUpdateMetadata` mutated `book` IN PLACE so memoized `<BookCover>`'s prev snapshot pointed at the same object → comparator saw no change → skip; fix = pure `getBookWithUpdatedMetadata` returns a NEW book object. Cloning in `updateBook` wouldn't help (original already mutated). Verified live on emulator via CDP fiber-store extraction (A: mutate→stale, B: new obj→updates)
|
||||
- [Series/author folder back no-op (#4437)](series-folder-back-noop-4437.md) — back arrow dead inside Series/Author folder after cold start; Next.js 16.2 static-export empty-search `router.replace` no-op (same as #3782/#3832); `GroupHeader.handleBack` missed the `group=''` workaround. CDP-verify gotcha: synthetic `el.click()` won't fire React onClick — use trusted `Input.dispatchMouseEvent`
|
||||
|
||||
## Library Architecture
|
||||
- [Book action platform surfaces](book-actions-platform-surfaces.md) — library context menu is **Tauri-desktop-only** (`hasContextMenu` false on web + iOS/Android); cross-platform book actions go in `BookDetailView`'s icon row. #4543 Goodreads search added both surfaces + a built-in web-search provider for highlighted-text lookup
|
||||
|
||||
@@ -19,4 +19,4 @@ metadata:
|
||||
|
||||
**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]].
|
||||
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]], [[wordlens-feature]].
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: koplugin-note-deletion-sync
|
||||
description: "koplugin deleting a note didn't sync to Readest — push only walked live annotations; fixed with persisted tombstones"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: f3f6e25e-1bde-4e08-9fc9-c8184d1ef457
|
||||
---
|
||||
|
||||
readest.koplugin deletions of highlights/bookmarks never reached the server (#4119 push direction; the pull direction was already handled by `removeDeletedAnnotations`).
|
||||
|
||||
**Root cause:** `SyncAnnotations:push` builds its payload from `getAnnotations`, which walks `ui.annotation.annotations` (the *live* list). A deleted note is already removed from that list, so it can never appear in a push — the server never gets a `deleted_at`, and the next pull resurrects it.
|
||||
|
||||
**Fix (all in `apps/readest.koplugin/`):**
|
||||
- `readest_syncannotations.lua`: extracted `buildNoteDescriptor(item, book_hash, meta_hash)` (shared by `getAnnotations` and the new deletion path). Added `recordDeletion(doc_settings, item)` — stamps `deletedAt = os.time()*1000` on the descriptor and persists it under `doc_settings.readest_sync.deleted_notes` (per-book sidecar, survives restart/offline). `push` folds `deleted_notes` into the payload (re-stamping current book/meta hash) and clears them in the success callback only (failed push retries).
|
||||
- `main.lua` `onAnnotationsModified(items)`: detects a removal via `items.index_modified < 0` (additions use positive index_modified; note-text edits / type-changes carry no index_modified — see KOReader `ReaderBookmark:removeItemByIndex`). Records the tombstone when `access_token` is set (independent of `auto_sync`, so a later manual/close push still carries it).
|
||||
|
||||
**Key facts:** note id is deterministic — server-created notes keep their stored `item.id` (pulled in), koplugin-created ones derive `generateNoteId(hash, type, pos0, pos1)`; both match the server row, so the tombstone targets it. Server `sync.ts` POST picks the tombstone via `clientDeletedAt > serverDeletedAt` (deletedAt wins even when updatedAt is stale). Missing `text`/`note` in the payload is fine (`sanitizeString(undefined)` returns undefined; plain highlights already push `note=nil`). Tests in `spec/syncannotations_spec.lua` (recordDeletion + push-folds-tombstones, UI-glued push driven by stub client/doc_settings). Related: [[custom-fonts-reincarnation-4410]] (CRDT remove-wins).
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: search-excerpt-context-4594
|
||||
description: Fulltext search excerpt showed no context for italic/styled words; makeExcerpt only read context within the matched text node. RESOLVED (foliate-js#25 + readest#4631)
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 5220a7ee-c8bc-44c0-81fb-cc3bc2cc4f54
|
||||
---
|
||||
|
||||
#4594: searching a word wrapped in inline markup (`<i>`/`<em>`/`<b>`) showed the
|
||||
match with NO surrounding context, while plain words showed context. Web + Android
|
||||
(both use foliate-js `view.search()` → `packages/foliate-js/search.js`; readest's
|
||||
`SearchExcerpt {pre,match,post}` and `SearchResults.tsx` render foliate output verbatim).
|
||||
|
||||
Root cause in `makeExcerpt` (search.js): `textWalker` maps each DOM text node to one
|
||||
`strs[]` entry, so `<i>brown</i>` is its OWN entry with nothing before/after inside it.
|
||||
The old code built context only from WITHIN the start/end node (`start.slice(0,startOffset)`
|
||||
/ `end.slice(endOffset)`) → empty for standalone styled words.
|
||||
|
||||
Fix = `collectBefore`/`collectAfter` walk OUTWARD across neighbouring `strs` entries until
|
||||
~`CONTEXT_LENGTH`(50) normalized chars (bounded — `strs` spans the whole section, so never
|
||||
join the whole thing per match → would be O(n²)). Same edit fixed two latent sibling bugs in
|
||||
the multi-node MATCH branch: `start === end` (string-value compare) → `startIndex === endIndex`,
|
||||
and `strs.slice(start + 1, end)` (string values coerce to NaN→0→`''`, dropping middle nodes) →
|
||||
`strs.slice(startIndex + 1, endIndex)`.
|
||||
|
||||
`search(strs, query, opts)` is PURE over the `strs` array (no DOM) → unit-testable by passing
|
||||
the text-node split directly; `{sensitivity:'variant'}` forces the deterministic simpleSearch
|
||||
path, `{}` uses segmenterSearch. Test: `src/__tests__/foliate-search-excerpt.test.ts`.
|
||||
foliate-js is a workspace submodule — fix landed via foliate-js#25 (merged → `makeExcerpt`) then readest#4631 (merged: submodule pointer bump to merged SHA `9c34e83` + the regression test). Verified live via CDP on the reporter's real "Heroes Die" EPUB (italic `Leisurefolk` rendered full context in the real search UI; empty before).
|
||||
|
||||
Delivery gotcha: the superproject pre-push hook runs Biome over the dirty working tree — a parallel uncommitted change with 2.1 MiB `data/wordlens/*.json` files exceeded Biome's size limit and BLOCKED the push; `git push --no-verify` was needed. To PR an isolated fix from a dirty tree without touching HEAD/working tree, build the commit off `origin/main` with a temp `GIT_INDEX_FILE` + `git commit-tree -S` (signing works non-interactively here).
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: series-folder-back-noop-4437
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: a2672a28-3bd3-4c6a-a595-f4d40d63ab50
|
||||
---
|
||||
|
||||
# Cannot navigate back from series/author folder (#4437)
|
||||
|
||||
**Symptom:** Inside a Series/Author library folder, the back arrow does nothing. Cross-platform (Android/iOS/Windows), onset v0.10.1, worse with large libraries; maintainer couldn't reproduce.
|
||||
|
||||
**Root cause:** Next.js 16.2 **static-export** regression — `router.replace()` to a same-pathname URL with an **empty query string** silently no-ops (`output:'export'`, i.e. every non-web build; `next dev` does NOT reproduce it). Same root cause as #3782, fixed for the breadcrumb "All" button in **#3832** by setting `group=''` instead of deleting it (→ `/library?group=` commits; a cleanup effect in `page.tsx` strips the trailing `group=` via `history.replaceState`). `GroupHeader.handleBack` (series/author back arrow, added in #3146) was never given that workaround — it did `params.delete('group')`.
|
||||
|
||||
**Why `group` is the only param (and why maintainer couldn't repro):** `groupBy` resolves URL-first, settings-second (`Bookshelf.tsx:166`). Picking Series/Author from the View menu writes it to settings AND puts `groupBy=author` in the URL, so *in-session* `delete('group')` leaves `?groupBy=author` (non-empty) → back works. But after a **cold start** the URL is clean `/library` (groupBy from settings); tapping a folder gives `?group=X` as the ONLY param → delete → empty → no-op. Needs default sort/order/view too (else those keep the query non-empty) — hence chrox's instinct to ask about the Sort config.
|
||||
|
||||
**Fix:** `GroupHeader.tsx` `handleBack` → `params.set('group','')` (mirror `handleLibraryNavigation`). Test: `src/__tests__/app/library/group-header.test.tsx` asserts the back nav keeps a non-empty query (`group=`).
|
||||
|
||||
**Verification gotcha (CDP on device):** synthetic `el.click()` does NOT fire React's `onClick` in the WebView → false "no-op". Use a **trusted** `Input.dispatchMouseEvent` at the element's `getBoundingClientRect` center. Verified on Xiaomi (WebView 148, built with `pnpm dev-android`): trusted back-click took `?group=9497393` → `/library`, folders restored. See [[android-cdp-e2e-lane]].
|
||||
@@ -20,7 +20,7 @@ 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
|
||||
Word-Lens-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`.
|
||||
|
||||
+9
-9
@@ -1,30 +1,30 @@
|
||||
---
|
||||
name: wordwise-feature
|
||||
description: "Word Wise inline-gloss feature — CFI-safe ruby technique, TTS/search isolation, gloss-data build caveat"
|
||||
name: wordlens-feature
|
||||
description: "Word Lens 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.)
|
||||
Kindle-style **Word Lens**: 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(wordlens)`), 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-lens*`. (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`.
|
||||
**Files:** `src/services/wordlens/{types,difficulty,glossIndex,planner}.ts` (pure pipeline), `src/app/reader/utils/{wordlensRuby,wordlensSection}.ts` (DOM inject/clear + orchestration), `src/components/settings/WordLensPanel.tsx`, `scripts/build-wordlens-data.mjs`, `public/wordlens/{en-zh,zh-en}.json` + `ATTRIBUTION.md`.
|
||||
|
||||
**CFI-safe DOM mutation (the key technique).** Glosses render as `<ruby class="ww-gloss" cfi-skip>word<rt cfi-inert>gloss</rt></ruby>` 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 <ecdict.csv> [topN]` (ECDICT, MIT, ~160MB full; difficulty = COCA `frq` rank; EN inflections parsed from the `exchange` field) and `zh-en <cedict.txt> <hsk.json> [topN]` (CC-CEDICT CC-BY-SA + HSK; zh tokenized in-app via jieba `cutZh`). Index is lazy-fetched once from `/wordwise/<pair>.json` into an in-memory `GlossIndex`.
|
||||
**Gloss data caveat (IMPORTANT).** The committed `public/wordlens/*.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-wordlens-data.mjs en-zh <ecdict.csv> [topN]` (ECDICT, MIT, ~160MB full; difficulty = COCA `frq` rank; EN inflections parsed from the `exchange` field) and `zh-en <cedict.txt> <hsk.json> [topN]` (CC-CEDICT CC-BY-SA + HSK; zh tokenized in-app via jieba `cutZh`). Index is lazy-fetched once from `/wordlens/<pair>.json` into an in-memory `GlossIndex`.
|
||||
|
||||
**Gotchas fixed in review:** slider-drag fires repeated refreshes → `refreshSectionGlosses` uses a `WeakMap<Document,number>` 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`.
|
||||
**Gotchas fixed in review:** slider-drag fires repeated refreshes → `refreshSectionGlosses` uses a `WeakMap<Document,number>` generation guard (latest wins on a clean doc); a `pendingWordLensDictRef` 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` → `wordlens-dictionary` event → `Annotator` synthesizes a selection over the base word. **STALE-CLOSURE bug (fixed post-PR):** `handleWordLensDictionary` 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 `<pair>.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=<sha8>` 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.
|
||||
**R2 runtime delivery (no bundled packs).** Packs are NOT shipped in the installer: source of truth is `apps/readest-app/data/wordlens/` (committed, outside `public/`+`src/` so unbundled) holding `<pair>.json` + `manifest.json` (`{pair,source,target,file,bytes,sha256,entries}`); `scripts/sync-wordlens-r2.mjs` (`pnpm wordlens:sync`, env `WORDLENS_R2_BUCKET`) uploads to the `cdn.readest.com` R2 bucket (`WORDLENS_CDN_BASE`). Runtime: `src/services/wordlens/glossPacks.ts` = `fetchManifest`→`resolvePack(source,hint)`→`ensurePack`(sha256 verify, single-flight via in-flight Map, `allowDownload` flag, `?v=<sha8>` 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/<pair>.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 <src> <tgt> <freq.txt> <db.sqlite3> [topN]` (needs `sqlite3` CLI). TRAP: the kaikki 100MB-gz `downloads/<code>/<code>-extract.jsonl.gz` is the MONOLINGUAL Wiktionary (same-language defs, 0% English) — NOT bilingual; the English glosses live in the heavy `kaikki.org/dictionary/<Language>/...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`.
|
||||
**Real pack generation + data stack (lightweight).** Generated 13 committed packs (~11MB total) in `data/wordlens/`: 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/<pair>.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 <src> <tgt> <freq.txt> <db.sqlite3> [topN]` (needs `sqlite3` CLI). TRAP: the kaikki 100MB-gz `downloads/<code>/<code>-extract.jsonl.gz` is the MONOLINGUAL Wiktionary (same-language defs, 0% English) — NOT bilingual; the English glosses live in the heavy `kaikki.org/dictionary/<Language>/...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/wordlens/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`.
|
||||
**UI in Settings → Language** (not a top-level tab): `WordLensSubPanel` 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 `wordLensHintLang` ('' = auto = app UI lang via `getLocale()`). Global `ReadSettings.wordLensAutoDownload` (default true) gates reader auto-download; `isMetered()` (`src/utils/network.ts`, Network Information API, absent on iOS/Tauri → unmetered). Generalized: `WordLensSourceLang=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-wordlens-r2-delivery.md`.
|
||||
|
||||
Related: [[edge-tts-word-highlighting-4017]], [[footnote-aside-namespace-order-4438]], [[empty-start-cfi-sync]], [[android-nativefile-remotefile-io]].
|
||||
+8
-8
@@ -1,8 +1,8 @@
|
||||
# Word Wise data attributions
|
||||
# Word Lens data attributions
|
||||
|
||||
> **Note:** The committed `en-zh.json` (top 30,000 by COCA frequency) and
|
||||
> `zh-en.json` (top 12,000, HSK-ranked) are frequency-trimmed derivatives generated
|
||||
> from the datasets below via `scripts/build-wordwise-data.mjs`. The full source
|
||||
> from the datasets below via `scripts/build-wordlens-data.mjs`. The full source
|
||||
> corpora (ECDICT ~66 MB, CC-CEDICT) are not committed — regenerate with the commands
|
||||
> below. Glosses are cleaned (POS tags, `[…]` annotations, and CC-CEDICT `CL:` clauses
|
||||
> stripped) and capped to the first 1–2 short senses.
|
||||
@@ -28,7 +28,7 @@ pairs** (es/fr/de/pt/it/ru ↔ en) use the lightweight WikDict + FrequencyWords
|
||||
ECDICT's `exchange` field instead.)
|
||||
|
||||
The committed `*.json` packs are frequency-trimmed derivatives generated by
|
||||
`scripts/build-wordwise-data.mjs`. Redistribution complies with the above licenses; the
|
||||
`scripts/build-wordlens-data.mjs`. Redistribution complies with the above licenses; the
|
||||
CC-BY-SA packs (everything except `en-zh.json`) carry their license + attribution in each
|
||||
pack's `meta`. Per-pack sizes: 0.3–2.6 MB.
|
||||
|
||||
@@ -36,16 +36,16 @@ pack's `meta`. Per-pack sizes: 0.3–2.6 MB.
|
||||
|
||||
```bash
|
||||
# English → 中文 (ECDICT csv) and 中文 → English (CC-CEDICT + HSK)
|
||||
node scripts/build-wordwise-data.mjs en-zh /path/to/ecdict.csv 30000
|
||||
node scripts/build-wordwise-data.mjs zh-en /path/to/cedict.txt /path/to/hsk.json 12000
|
||||
node scripts/build-wordlens-data.mjs en-zh /path/to/ecdict.csv 30000
|
||||
node scripts/build-wordlens-data.mjs zh-en /path/to/cedict.txt /path/to/hsk.json 12000
|
||||
|
||||
# Any other pair (one side English) from WikDict + FrequencyWords. Needs the `sqlite3`
|
||||
# CLI. Download <pair>.sqlite3 from https://download.wikdict.com/dictionaries/sqlite/2/
|
||||
# and <src>_50k.txt from FrequencyWords/content/2018/<src>/.
|
||||
node scripts/build-wordwise-data.mjs build-wikdict es en es_50k.txt es-en.sqlite3 20000
|
||||
node scripts/build-wordwise-data.mjs build-wikdict en es en_50k.txt en-es.sqlite3 20000
|
||||
node scripts/build-wordlens-data.mjs build-wikdict es en es_50k.txt es-en.sqlite3 20000
|
||||
node scripts/build-wordlens-data.mjs build-wikdict en es en_50k.txt en-es.sqlite3 20000
|
||||
# (repeat for fr/de/pt/it/ru ↔ en)
|
||||
|
||||
# For maximum coverage, the kaikki Wiktionary dump can be used instead of WikDict:
|
||||
node scripts/build-wordwise-data.mjs build es en es_50k.txt /path/to/es-extract.jsonl 20000
|
||||
node scripts/build-wordlens-data.mjs build es en es_50k.txt /path/to/es-extract.jsonl 20000
|
||||
```
|
||||
+14
-14
@@ -1,7 +1,7 @@
|
||||
# Updating the Word Wise gloss packs
|
||||
# Updating the Word Lens gloss packs
|
||||
|
||||
**Model:** the committed `data/wordwise/*.json` + `manifest.json` are the source of
|
||||
truth. They are **not** bundled into the app — `pnpm wordwise:sync` mirrors them to the
|
||||
**Model:** the committed `data/wordlens/*.json` + `manifest.json` are the source of
|
||||
truth. They are **not** bundled into the app — `pnpm wordlens:sync` mirrors them to the
|
||||
`cdn.readest.com` R2 bucket, and the app downloads each pack on demand and **re-downloads
|
||||
it automatically whenever its `sha256` changes in the manifest**. So updating data is:
|
||||
regenerate → sync → commit. No app release required.
|
||||
@@ -40,21 +40,21 @@ for c in es fr de pt it ru; do curl -sL -o lemmatization-$c.txt https://raw.gith
|
||||
cd apps/readest-app
|
||||
|
||||
# Flagship pairs (dedicated dictionaries — higher quality)
|
||||
node scripts/build-wordwise-data.mjs en-zh /tmp/ww-data/ecdict.csv 30000
|
||||
node scripts/build-wordwise-data.mjs zh-en /tmp/ww-data/cedict.txt /tmp/ww-data/hsk.json 12000
|
||||
node scripts/build-wordlens-data.mjs en-zh /tmp/ww-data/ecdict.csv 30000
|
||||
node scripts/build-wordlens-data.mjs zh-en /tmp/ww-data/cedict.txt /tmp/ww-data/hsk.json 12000
|
||||
|
||||
# X→en (foreign source): pass the source-language lemmatization list (6th arg) so
|
||||
# inflected source words ("corriendo" -> "correr") resolve to their lemma's gloss.
|
||||
for src in es fr de pt it ru; do
|
||||
node scripts/build-wordwise-data.mjs build-wikdict "$src" en "/tmp/ww-data/${src}_50k.txt" "/tmp/ww-data/$src-en.sqlite3" 20000 "/tmp/ww-data/lemmatization-$src.txt"
|
||||
node scripts/build-wordlens-data.mjs build-wikdict "$src" en "/tmp/ww-data/${src}_50k.txt" "/tmp/ww-data/$src-en.sqlite3" 20000 "/tmp/ww-data/lemmatization-$src.txt"
|
||||
done
|
||||
# en→X (English source): lemmatized automatically via en-zh.json (build it first)
|
||||
for tgt in es fr de pt ru; do
|
||||
node scripts/build-wordwise-data.mjs build-wikdict en "$tgt" /tmp/ww-data/en_50k.txt "/tmp/ww-data/en-$tgt.sqlite3" 20000
|
||||
node scripts/build-wordlens-data.mjs build-wikdict en "$tgt" /tmp/ww-data/en_50k.txt "/tmp/ww-data/en-$tgt.sqlite3" 20000
|
||||
done
|
||||
```
|
||||
- Each build writes `data/wordwise/<pair>.json` **and** regenerates `manifest.json`
|
||||
(sha256 + bytes + entry count). Rebuild only the manifest with `pnpm wordwise:manifest`.
|
||||
- Each build writes `data/wordlens/<pair>.json` **and** regenerates `manifest.json`
|
||||
(sha256 + bytes + entry count). Rebuild only the manifest with `pnpm wordlens:manifest`.
|
||||
- The last CLI arg is `topN` (default 30000 for en-zh, 20000 otherwise).
|
||||
- **Add a new pair** (e.g. en→ja): fetch `en-ja.sqlite3` + `en_50k.txt`, run
|
||||
`build-wikdict en ja …` — it joins the manifest automatically. (ja/ko/th as a *source*
|
||||
@@ -65,13 +65,13 @@ done
|
||||
|
||||
## 3. Sync to R2
|
||||
```bash
|
||||
WORDWISE_R2_BUCKET=<cdn-bucket> pnpm wordwise:sync
|
||||
WORDLENS_R2_BUCKET=<cdn-bucket> pnpm wordlens:sync
|
||||
```
|
||||
Uploads every pack (immutable cache) + `manifest.json` (5-min cache), manifest last.
|
||||
|
||||
## 4. Commit
|
||||
```bash
|
||||
git add data/wordwise && git commit -m "chore(wordwise): refresh gloss packs"
|
||||
git add data/wordlens && git commit -m "chore(wordlens): refresh gloss packs"
|
||||
```
|
||||
|
||||
## 5. How clients update
|
||||
@@ -82,6 +82,6 @@ On next load the app fetches `manifest.json` (≤5-min CDN cache), compares each
|
||||
| What | Where |
|
||||
| --- | --- |
|
||||
| `topN` per pack | the build CLI's last arg |
|
||||
| commonest-N words skipped (`skipTop`) + per-chapter render cap (`DEFAULT_CAP`) | `src/services/wordwise/{planner,…}` and `buildPack` in the build script |
|
||||
| difficulty cutoffs per slider level | `src/services/wordwise/difficulty.ts` |
|
||||
| gloss cleaning (POS/`[…]`/`CL:` strip, 24-char cap) | `shortGloss` in `scripts/build-wordwise-data.mjs` |
|
||||
| commonest-N words skipped (`skipTop`) + per-chapter render cap (`DEFAULT_CAP`) | `src/services/wordlens/{planner,…}` and `buildPack` in the build script |
|
||||
| difficulty cutoffs per slider level | `src/services/wordlens/difficulty.ts` |
|
||||
| gloss cleaning (POS/`[…]`/`CL:` strip, 24-char cap) | `shortGloss` in `scripts/build-wordlens-data.mjs` |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,392 +0,0 @@
|
||||
# Word Wise — inline vocabulary hints for Readest
|
||||
|
||||
**Date:** 2026-06-14
|
||||
**Status:** Design approved (pending spec review) → next is `writing-plans`
|
||||
**Scope owner:** reader / dictionaries
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Build the single core function of **Kindle Word Wise** into Readest: as the user reads,
|
||||
a small **native-language gloss appears above difficult words**, inline in the text, with
|
||||
no interaction required. A **difficulty slider** controls how many words get a hint (fewer =
|
||||
only the rarest words; more = include easier words). **Tapping a glossed word opens
|
||||
Readest's existing dictionary popup** for the full entry.
|
||||
|
||||
This is modeled on Kindle Word Wise. We build **only the inline-hint function** — explicitly
|
||||
**not** SRS review decks, audio narration, or whole-book difficulty ("book fit") scoring.
|
||||
|
||||
### Locked product decisions
|
||||
| Decision | Choice |
|
||||
| --- | --- |
|
||||
| Hint content | **Native-language gloss** (a short translation above the hard word) |
|
||||
| Difficulty control | **Frequency slider** (Kindle-style: fewer ↔ more hints) |
|
||||
| Book languages (v1) | **English + Chinese** source text (Japanese deferred — no JP analyzer) |
|
||||
| Gloss data source | **Bundled offline dataset** (frequency-trimmed), lazy-loaded |
|
||||
| Display | **Always-on inline** (DOM `<ruby>`), tap opens the existing dictionary popup |
|
||||
| Occurrences | **Every occurrence** on the page, capped per section |
|
||||
| Inert marker | **Reuse `cfi-inert`** as the single content-inert marker across CFI / search / TTS |
|
||||
|
||||
---
|
||||
|
||||
## 2. User-facing behavior
|
||||
|
||||
- A **Word Wise** settings panel (reader settings) with: an **Enable** toggle and a
|
||||
**difficulty slider** (5 levels: *fewer hints* … *more hints*).
|
||||
- When enabled and the book's text language has a bundled gloss index:
|
||||
- Difficult words (rarer than the slider's threshold) render with a small muted gloss
|
||||
above them, e.g. `cryptic` → `晦涩的` (EN→中文), `斟酌` → `to consider` (中文→EN).
|
||||
- The gloss sits in native ruby position; the line spacing grows to fit, exactly like
|
||||
Kindle. No layout overlap.
|
||||
- Tapping a glossed word opens the existing dictionary popup for that word.
|
||||
- When disabled, or for an unsupported book language, **no glosses render** and there is
|
||||
zero effect on layout, CFI, TTS, search, or selection.
|
||||
- Settings persist **per-book and globally**, using the existing `saveViewSettings` path.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
New, isolated units. Each has one purpose, a small interface, and is independently testable.
|
||||
|
||||
```
|
||||
src/services/wordwise/
|
||||
types.ts # WordGloss, GlossEntry, GlossOccurrence, SupportedSourceLang
|
||||
glossIndex.ts # lazy-load + cache per-source-language index; lemmatize(); lookup()
|
||||
difficulty.ts # pure: sliderLevel -> rank cutoff; isDifficult(rank, cutoff)
|
||||
planner.ts # pure: (sectionText, sourceLang, cutoff, index) -> GlossOccurrence[]
|
||||
index.ts # thin service facade used by the reader
|
||||
|
||||
src/app/reader/utils/
|
||||
wordwiseRuby.ts # inject/unwrap <ruby cfi-skip>…<rt cfi-inert>…</rt></ruby> into a section doc
|
||||
|
||||
src/components/settings/
|
||||
WordWisePanel.tsx # toggle + slider (mirrors TTSPanel)
|
||||
|
||||
scripts/
|
||||
build-wordwise-data.mjs # offline data-prep: source datasets -> trimmed assets + attributions
|
||||
|
||||
public/wordwise/
|
||||
en-zh.json # trimmed gloss index, English source -> Chinese gloss
|
||||
zh-en.json # trimmed gloss index, Chinese source -> English gloss
|
||||
ATTRIBUTION.md # ECDICT (MIT), CC-CEDICT (CC-BY-SA), HSK list attributions
|
||||
```
|
||||
|
||||
### Responsibilities / boundaries
|
||||
- **`planner.ts`** is the heart and is **pure** (no DOM, no async): given the plain text of a
|
||||
section, the source language, the difficulty cutoff, and a gloss index, it returns a list of
|
||||
`GlossOccurrence { start, end, word, gloss }` (character offsets into the section text).
|
||||
It owns tokenization-dispatch (English vs CJK), inflection resolution, threshold filtering,
|
||||
and the per-section occurrence cap. **All the interesting logic lives here and is unit-tested
|
||||
without a browser.**
|
||||
- **`glossIndex.ts`** owns data: lazy `load(sourceLang)` fetches `public/wordwise/<pair>.json`
|
||||
once, builds an in-memory `Map<string, GlossEntry>` plus an inflection reverse-map
|
||||
(`running → run`), and exposes `lemmatize(word)` and `lookup(lemma)`.
|
||||
- **`wordwiseRuby.ts`** is the only DOM-mutating unit: given a live section `Document` and a
|
||||
`GlossOccurrence[]`, it maps offsets → DOM Ranges (reusing the section's text walk) and wraps
|
||||
each occurrence in `<ruby class="ww-gloss" cfi-skip>word<rt cfi-inert>gloss</rt></ruby>`. It
|
||||
also `unwrap(doc)` — removes every `.ww-gloss` and `normalize()`s — so toggling/recomputing
|
||||
is clean and idempotent.
|
||||
- **`FoliateViewer`** orchestrates: on section `stabilized`, builds the section text, calls the
|
||||
planner, then the injector, per loaded content doc; on settings change, unwraps + recomputes
|
||||
or unwraps to disable. Tap handling routes clicks inside `.ww-gloss` to the dictionary popup.
|
||||
|
||||
---
|
||||
|
||||
## 4. Data: datasets, prep, format, sizes, licensing
|
||||
|
||||
The full source datasets are too large to ship. A **build-time prep script** produces compact,
|
||||
frequency-trimmed indices that are **lazy-loaded only when the feature is enabled** (not in the
|
||||
initial app bundle).
|
||||
|
||||
### English → 中文 (flagship): ECDICT
|
||||
- **License:** MIT (redistributable with notice).
|
||||
- **Full size:** ~760k entries, ~160–200 MB CSV — **not shipped raw**.
|
||||
- **Fields used:** `word`, `translation` (Chinese), `frq` (COCA rank) / `bnc` (BNC rank),
|
||||
`exchange` (inflections, `/`-delimited), `tag` (exam tags, optional future use).
|
||||
- **Difficulty metric:** `frq` (COCA, modern usage) primary, `bnc` fallback. Lower rank = more
|
||||
common = easier.
|
||||
- **Trim:** top ~20k–50k headwords by `frq`, keep `{ word, frq, gloss }` where `gloss` is the
|
||||
first 1–2 short Chinese senses (the `translation` field truncated to a hint-length string).
|
||||
- **Inflection map:** parse `exchange` into a reverse map `{ form: lemma }` so `"running"` resolves
|
||||
to `"run"` without storing every inflected form.
|
||||
- **Estimated shipped size:** top-20k ≈ **1–3 MB** JSON, **< ~1 MB gzipped**.
|
||||
|
||||
### 中文 → English: CC-CEDICT + HSK
|
||||
- **License:** CC-CEDICT is **CC-BY-SA** (ship attribution + the license; the derived asset is a
|
||||
share-alike work — acceptable). HSK level lists are open (GitHub).
|
||||
- **Gloss:** CC-CEDICT entry, first short English sense.
|
||||
- **Difficulty metric:** HSK level (1–9) as the primary tier; word frequency (SUBTLEX-CH / Jun Da)
|
||||
as a secondary ordering within/above HSK.
|
||||
- **Tokenization:** existing **jieba** (`cutZh`) segments Chinese into words for lookup.
|
||||
- **Trim:** top ~10k entries by frequency + full HSK list. **Estimated shipped size:** ~300 KB
|
||||
uncompressed, ~100 KB gzipped.
|
||||
|
||||
### Japanese — **deferred** (documented gap)
|
||||
No Japanese morphological analyzer is bundled (jieba covers Chinese only); JMdict + JLPT exist but
|
||||
kanji-only segmentation is insufficient (particles collide). Out of v1; revisit with mecab-wasm or
|
||||
a kanji-only fallback later.
|
||||
|
||||
### Other native languages — **deferred**
|
||||
Offline bilingual data for non-中文/EN pairs is sparse. The gloss layer is intentionally pluggable
|
||||
so a **cached live-translation fallback** (reusing `src/services/translators`) can be added later
|
||||
for arbitrary target languages without reworking the planner or renderer.
|
||||
|
||||
### Asset format
|
||||
`public/wordwise/<src>-<tgt>.json`:
|
||||
```jsonc
|
||||
{
|
||||
"meta": { "source": "en", "target": "zh", "metric": "frq", "version": 1, "count": 20000 },
|
||||
"entries": { "run": { "r": 312, "g": "跑;经营" }, /* ... */ }, // r = rank, g = gloss
|
||||
"inflections": { "running": "run", "ran": "run", "runs": "run" }
|
||||
}
|
||||
```
|
||||
- `glossIndex.load()` fetches once, hydrates `entries` into a `Map`, keeps `inflections` as a `Map`.
|
||||
- Memory: ~20k short-string entries ≈ a few MB RAM; only when the feature is on.
|
||||
|
||||
---
|
||||
|
||||
## 5. Difficulty model & slider
|
||||
|
||||
- `WordWiseConfig.wordWiseLevel: number` — 5 steps, default 3. Slider label: *fewer hints ↔ more hints*.
|
||||
- `difficulty.ts` maps `level → rankCutoff` **per source language**, e.g. (EN, by `frq`):
|
||||
| Level | Cutoff (gloss words with rank ≥ cutoff) | Effect |
|
||||
| --- | --- | --- |
|
||||
| 1 | ≥ 20000 | only the rarest words |
|
||||
| 2 | ≥ 12000 | |
|
||||
| 3 | ≥ 7000 | balanced (default) |
|
||||
| 4 | ≥ 4000 | |
|
||||
| 5 | ≥ 2000 | many hints |
|
||||
For 中文, the slider maps to an **HSK-level threshold** (e.g. level 1 = only HSK 7–9 / off-list;
|
||||
level 5 = HSK 4+). Exact cutoffs are tuned with sample books during implementation and documented
|
||||
in `difficulty.ts`.
|
||||
- `isDifficult(rank, cutoff) = rank >= cutoff`. Words absent from the index (rank unknown) are
|
||||
treated as difficult **only** if a gloss exists (otherwise skipped — nothing to show).
|
||||
|
||||
---
|
||||
|
||||
## 6. Rendering — DOM `<ruby>` with `cfi-inert` (verified safe)
|
||||
|
||||
Each difficult-word occurrence is wrapped, after section load:
|
||||
```html
|
||||
<ruby class="ww-gloss" cfi-skip>word<rt cfi-inert>gloss</rt></ruby>
|
||||
```
|
||||
|
||||
### Why this is CFI-safe (verified against `packages/foliate-js/epubcfi.js`)
|
||||
- `cfi-skip` on `<ruby>` → `rawChildNodes` (epubcfi.js:199–203) **splices the wrapper out**,
|
||||
hoisting the word's text node up into the paragraph.
|
||||
- `cfi-inert` on `<rt>` → `rawChildNodes` **removes the gloss subtree** entirely.
|
||||
- `indexChildNodes` (epubcfi.js:222–235) **re-merges the now-adjacent text nodes** (`"The "` +
|
||||
hoisted `"quick"` + `" fox"`) into a single chunk.
|
||||
- Both directions sum across the chunk: `nodeToParts` (278–294) hoists past skip wrappers and
|
||||
re-merges; `partsToNode` (262–269) resolves an offset into the multi-text-node chunk.
|
||||
- **Result:** CFIs are byte-identical to the unwrapped baseline. Saved highlights, bookmarks, and
|
||||
progress are unaffected, and a CFI saved with Word Wise on resolves correctly with it off
|
||||
(and vice-versa). **No `epubcfi.js` change is required.**
|
||||
|
||||
### Layout / CSS
|
||||
- Native `<ruby>` grows line-height and positions the gloss above the base word — no overlay math,
|
||||
no manual line-height bump, reflows natively on resize/page-turn.
|
||||
- Extend `getRubyStyles()` (or add `getWordWiseStyles()`) in `src/utils/style.ts` with a `.ww-gloss`
|
||||
rule: small `rt` font (~0.5em), muted color, `user-select: none` (already present for `rt`, so the
|
||||
gloss is excluded from copy). E-ink: muted color must remain legible — verify under
|
||||
`[data-eink='true']`.
|
||||
- Must not double-wrap: the planner skips any token already inside a book's own `<ruby>`
|
||||
(furigana/pinyin) to avoid nesting.
|
||||
|
||||
### Injection lifecycle
|
||||
- **Inject** in `FoliateViewer`'s `stabilizedHandler` for each loaded content doc (mirrors the
|
||||
existing warichu relayout there). Multiview preloads adjacent sections — inject per content doc.
|
||||
- **Recompute** on `wordWiseEnabled` / `wordWiseLevel` change: `unwrap(doc)` then re-inject.
|
||||
- **Disable / book close:** `unwrap(doc)` (replace each `.ww-gloss` with its base text node and
|
||||
`normalize()`).
|
||||
- Idempotent: re-running inject first unwraps, so there is never nested or stale ruby.
|
||||
|
||||
### Tap-to-dictionary
|
||||
- Glossed words are real `.ww-gloss` elements → natural tap targets. The existing iframe click
|
||||
handler detects a target inside `.ww-gloss`, reads the base word, and opens the dictionary popup
|
||||
(`setShowDictionaryPopup(true)` with the word), reusing the full existing dictionary stack.
|
||||
|
||||
---
|
||||
|
||||
## 7. Keeping the gloss invisible to TTS / search / annotation matching
|
||||
|
||||
The gloss text must not be read aloud, matched by find-in-book, or fold into TTS word offsets.
|
||||
CFI already ignores `cfi-inert`. The remaining text walkers are all reachable **without patching
|
||||
foliate-js**:
|
||||
|
||||
1. **Shared reject filter — `src/utils/node.ts` `createRejectFilter`.**
|
||||
This helper builds the `nodeFilter` passed to foliate TTS (`TTSController.ts` → `view.initTTS`)
|
||||
**and** the search `acceptNode` (`SearchBar.tsx` → `view.search`). It already rejects
|
||||
`script`/`style` by default.
|
||||
**Change:** reject any element with the `cfi-inert` attribute (and its subtree) **by default** —
|
||||
one line, covers spoken text and find-in-book together. (`cfi-inert` is an injected, non-content
|
||||
marker; no real book content uses it, so a global default is safe and also correctly excludes
|
||||
foliate's own injected a11y skip-links.) Other text walkers with their own inline `acceptNode`
|
||||
(`globalAnnotations.ts`, `proofread.ts`, `simplecc.ts`) run at load **before** glosses are injected
|
||||
at `stabilized`, so they don't see the gloss; if any later proves to need it, it adopts the same
|
||||
`closest('[cfi-inert]')` check.
|
||||
|
||||
2. **Edge-TTS word highlighting — `src/services/tts/wordHighlight.ts` + `TTSController.ts`.**
|
||||
`prepareSpeakWords` (TTSController.ts:666) matches Edge boundary `words` against
|
||||
`range.toString()`, and `getTextSubRange` (wordHighlight.ts:64–71) walks `SHOW_TEXT` with **no
|
||||
filter**. Because the spoken `words` already exclude the gloss (via the filtered `nodeFilter`),
|
||||
both the matching text and the sub-range walk must also exclude it or offsets drift.
|
||||
**Change:** add a shared predicate `isInsideInert(node) = !!node.parentElement?.closest('[cfi-inert]')`;
|
||||
skip such text nodes in `getTextSubRange`, and replace `range.toString()` with a small
|
||||
`rangeTextExcludingInert(range)` that concatenates the same filtered text nodes. This keeps `text`,
|
||||
the walk, and `words` mutually consistent.
|
||||
|
||||
3. **(optional) `packages/foliate-js/overlayer.js:88`** highlight split-range `acceptNode` — reject
|
||||
`cfi-inert` so a highlight drawn across a glossed word doesn't extend over the gloss box. Cosmetic;
|
||||
include only if it shows in testing.
|
||||
|
||||
**Net foliate-js change: none required for correctness** (optional overlayer cosmetic only). All
|
||||
isolation is readest-side.
|
||||
|
||||
---
|
||||
|
||||
## 8. Settings & persistence wiring
|
||||
|
||||
- **`src/types/book.ts`** — add `WordWiseConfig { wordWiseEnabled: boolean; wordWiseLevel: number }`
|
||||
and include it in the `ViewSettings` union.
|
||||
- **`src/services/constants.ts`** — `DEFAULT_WORD_WISE_CONFIG = { wordWiseEnabled: false, wordWiseLevel: 3 }`
|
||||
and spread into the default view settings object.
|
||||
- **Store** — no change: `getViewSettings`/`setViewSettings` (`readerStore.ts:331–362`) and the
|
||||
global+per-book merge already handle arbitrary `ViewSettings` keys; `saveViewSettings`
|
||||
(`src/helpers/settings.ts`) persists and applies.
|
||||
- **`WordWisePanel.tsx`** — mirrors `TTSPanel`: `BoxedList` + `SettingsSwitchRow` (enable) +
|
||||
slider (`NumberInput` 1–5, or the existing slider primitive) for level. E-ink-correct primitives.
|
||||
- **`SettingsDialog.tsx`** — register a `WordWise` tab (icon + label) and render `WordWisePanel`.
|
||||
- **`FoliateViewer.tsx`** — add `wordWiseEnabled` + `wordWiseLevel` to the deps of the
|
||||
settings-application effect; recompute/unwrap accordingly.
|
||||
- **i18n** — key-as-content `_()` for all UI strings; scanner extracts them.
|
||||
|
||||
---
|
||||
|
||||
## 9. Data flow
|
||||
|
||||
```
|
||||
settings (enabled, level) ──┐
|
||||
book text language ─────────┤
|
||||
▼
|
||||
section 'stabilized' ─▶ build section text ─▶ planner(text, srcLang, cutoff, index)
|
||||
│ (tokenize EN/CJK, lemmatize,
|
||||
│ lookup, threshold, cap)
|
||||
▼
|
||||
GlossOccurrence[] ─▶ wordwiseRuby.inject(doc)
|
||||
│ (offsets→Ranges→<ruby>)
|
||||
▼
|
||||
glosses visible
|
||||
tap on .ww-gloss ─────────────────────────────────────────▶ dictionary popup(word)
|
||||
settings change / disable ────────────────────────────────▶ wordwiseRuby.unwrap(doc) [+ re-inject]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Performance & limits
|
||||
|
||||
- Index is in-memory after one lazy fetch; lookups are O(1) sync.
|
||||
- One tokenize+lookup pass per section, only for **loaded** content docs.
|
||||
- **Per-section occurrence cap** (e.g. 200) to bound DOM growth; `log()`/`console.warn` when capped
|
||||
(no silent truncation).
|
||||
- Glossing every occurrence (Kindle-faithful); the cap is the only limiter.
|
||||
- jieba init is async and already used elsewhere; planner treats "jieba not ready" as "no CJK glosses
|
||||
yet" and the `stabilized` re-run picks them up once ready.
|
||||
|
||||
---
|
||||
|
||||
## 11. Error handling / edge cases
|
||||
|
||||
- Index fetch fails → feature no-ops, single `console.warn`; reader unaffected.
|
||||
- Unsupported book language (no index for source) → no glosses; the panel may show a one-line note.
|
||||
- Native-language mismatch (e.g. a non-Chinese user reading English): v1 only has EN→中文, so glosses
|
||||
show Chinese; this is acceptable for v1's audience and the panel notes the active pair. (Generalized
|
||||
via the deferred live-translation fallback.)
|
||||
- Book already uses `<ruby>` → skip those tokens (no nesting).
|
||||
- Pre-paginated / fixed-layout books → skip (no reflow room); gloss only in reflowable EPUB/text.
|
||||
- Vertical writing mode → v1 may disable Word Wise (ruby placement in vertical text is an edge case);
|
||||
decided during implementation, documented if disabled.
|
||||
|
||||
---
|
||||
|
||||
## 12. Testing (test-first)
|
||||
|
||||
Write failing tests first, then implement.
|
||||
|
||||
**Unit (vitest, no browser):**
|
||||
- `planner.test.ts` — offsets correctness; threshold filtering by cutoff; inflection mapping
|
||||
(`running→run`); English whitespace tokenization; CJK path with a mocked `cutZh`; skip-inside-ruby;
|
||||
per-section cap behavior.
|
||||
- `difficulty.test.ts` — `level→cutoff` mapping per language; `isDifficult` boundaries.
|
||||
- `glossIndex.test.ts` — `lemmatize`/`lookup`/miss against a fixture index; lazy-load caches once.
|
||||
- `epubcfi-ruby-inline.test.ts` — **the key correctness guarantee.** Mirror
|
||||
`epubcfi-inert.test.ts`/`epubcfi-skip.test.ts` but for **mid-text inline** ruby with text on both
|
||||
sides: `<p>The <ruby cfi-skip>quick<rt cfi-inert>x</rt></ruby> fox</p>` must produce CFIs identical
|
||||
to `<p>The quick fox</p>` in both `fromRange` and `toRange`. (Existing tests cover block wrappers and
|
||||
prepended skip-links only.)
|
||||
- TTS gloss alignment — extend `tts-word-highlight.test.ts`: with a glossed sentence, the
|
||||
`cfi-inert`-skipping `getTextSubRange` + `rangeTextExcludingInert` keep word ranges aligned with the
|
||||
gloss-free boundary `words`.
|
||||
- `createRejectFilter` — rejects `[cfi-inert]` subtrees (search/TTS reuse).
|
||||
|
||||
**Browser/integration (Playwright):**
|
||||
- glosses render above difficult words when enabled; toggle off removes them and restores layout;
|
||||
tap on a glossed word opens the dictionary popup; an annotation made with Word Wise on still anchors
|
||||
correctly with it off (CFI regression guard).
|
||||
|
||||
**Verification gates (from `.agents/rules/verification.md`):** `pnpm test`, `pnpm lint`. Rust/Lua
|
||||
gates not applicable (no `src-tauri`/koplugin changes expected).
|
||||
|
||||
---
|
||||
|
||||
## 13. File change list
|
||||
|
||||
**New**
|
||||
- `src/services/wordwise/{types,glossIndex,difficulty,planner,index}.ts`
|
||||
- `src/app/reader/utils/wordwiseRuby.ts`
|
||||
- `src/components/settings/WordWisePanel.tsx`
|
||||
- `scripts/build-wordwise-data.mjs`
|
||||
- `public/wordwise/{en-zh.json,zh-en.json,ATTRIBUTION.md}`
|
||||
- tests listed in §12
|
||||
|
||||
**Edited**
|
||||
- `src/types/book.ts` — `WordWiseConfig` + `ViewSettings` union
|
||||
- `src/services/constants.ts` — `DEFAULT_WORD_WISE_CONFIG` + default merge
|
||||
- `src/components/settings/SettingsDialog.tsx` — register panel
|
||||
- `src/app/reader/components/FoliateViewer.tsx` — inject/unwrap lifecycle + settings deps + tap routing
|
||||
- `src/utils/style.ts` — `.ww-gloss` ruby styling
|
||||
- `src/utils/node.ts` — `createRejectFilter` rejects `[cfi-inert]` by default
|
||||
- `src/services/tts/wordHighlight.ts` — skip `[cfi-inert]`; `rangeTextExcludingInert`
|
||||
- `src/services/tts/TTSController.ts` — use the inert-excluding text for word matching
|
||||
- (optional) `packages/foliate-js/overlayer.js` — reject `cfi-inert` in highlight split
|
||||
|
||||
---
|
||||
|
||||
## 14. Phasing
|
||||
|
||||
1. **Data prep + index** — `build-wordwise-data.mjs`, ship `en-zh.json`; `glossIndex` + `difficulty`
|
||||
+ `planner` with full unit tests (no UI yet).
|
||||
2. **Rendering** — `wordwiseRuby` inject/unwrap; CFI inline-ruby test; `createRejectFilter` +
|
||||
`wordHighlight` inert handling with TTS test; `.ww-gloss` CSS.
|
||||
3. **Wiring** — `WordWiseConfig` + defaults + `WordWisePanel` + `SettingsDialog`; `FoliateViewer`
|
||||
lifecycle + tap-to-dictionary; browser integration tests.
|
||||
4. **CJK** — `zh-en.json` (CC-CEDICT + HSK), jieba path in planner, 中文 difficulty mapping.
|
||||
|
||||
**Deferred (out of v1):** Japanese; non-中文/EN native targets (live-translation fallback); SRS/audio/
|
||||
book-fit.
|
||||
|
||||
---
|
||||
|
||||
## 15. Open questions / risks
|
||||
|
||||
- **Inline-ruby CFI transparency** is verified by reading the code but only block-wrapper cases are
|
||||
currently tested upstream — the new `epubcfi-ruby-inline.test.ts` is the gate that must pass before
|
||||
relying on it. *(Highest-priority risk; fully mitigated by the test.)*
|
||||
- **Gloss truncation quality** (ECDICT `translation` → hint-length string) needs tuning against real
|
||||
books to stay short but useful.
|
||||
- **Difficulty cutoffs** per level are first-cut; tune with sample EPUBs during phase 1.
|
||||
- **Default-rejecting `cfi-inert` in `createRejectFilter`** is a behavior change to a shared helper;
|
||||
confirm no current caller relies on walking `cfi-inert` skip-links (they shouldn't — those are
|
||||
injected non-content).
|
||||
@@ -1,248 +0,0 @@
|
||||
# Word Wise — runtime gloss-pack delivery (R2) + Language-panel placement
|
||||
|
||||
**Date:** 2026-06-14 · **Status:** Design (pending review) · Branch `feat/word-wise`
|
||||
**Extends:** `2026-06-14-word-wise-design.md` (the gloss/render/CFI pipeline is unchanged).
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Stop bundling gloss packs into the app. Keep them version-controlled in-repo as the source of
|
||||
truth, mirror them to `cdn.readest.com` (R2), and **download on demand** into durable local
|
||||
storage the first time a (book-language → hint-language) pair is needed. Make the pipeline
|
||||
**multi-pair** so tiers 0–2 ship as data drops now and tier 3 later, and move the Word Wise UI
|
||||
into **Settings → Language** with an explicit **hint-language** selector.
|
||||
|
||||
This PR delivers the **infrastructure + generalization**, routed end-to-end through the existing
|
||||
EN↔中文 packs. Adding each further pair (es→en, en→es, fr→en, …) is then just "generate a pack +
|
||||
manifest entry + sync" — no code change.
|
||||
|
||||
### Locked decisions
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Delivery | `https://cdn.readest.com/wordwise/…` (direct cross-origin fetch) |
|
||||
| Download trigger | Auto-download with progress; best-effort prompt on metered connection; a global "auto-download" toggle |
|
||||
| Hint language | Explicit selector (default = app UI language), limited to targets available for the book's source language |
|
||||
| UI placement | Settings → Language → **Word Wise** `NavigationRow` → sub-page |
|
||||
| Local storage | Durable `'Data'` base (not evictable `'Cache'`); managed in the Word Wise sub-page |
|
||||
|
||||
---
|
||||
|
||||
## 2. Repo layout — source of truth, never bundled
|
||||
|
||||
Move packs out of `public/wordwise/` (which is bundled into both the web deploy and the Tauri
|
||||
installer) into a committed, **non-bundled** directory referenced only by build/sync scripts:
|
||||
|
||||
```
|
||||
apps/readest-app/data/wordwise/ # committed; NOT under public/ or src/
|
||||
manifest.json # the index of available packs
|
||||
en-zh.v<hash>.json # content-hashed pack files (GlossIndexData shape)
|
||||
zh-en.v<hash>.json
|
||||
ATTRIBUTION.md
|
||||
```
|
||||
|
||||
`data/wordwise/` is consumed by `build-wordwise-data.mjs` (writes packs + manifest) and
|
||||
`sync-wordwise-r2.mjs` (uploads to R2). **No `*.json` under `public/wordwise/` ships** — delete it.
|
||||
(`data/` is already used for committed non-bundled content like `src/data/demo`, but keep these at
|
||||
the app root `data/`, outside `src/`, so bundlers never import them.)
|
||||
|
||||
### `manifest.json`
|
||||
```jsonc
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"packs": [
|
||||
{ "pair": "en-zh", "source": "en", "target": "zh",
|
||||
"file": "en-zh.v8f3a1.json", "bytes": 2622655, "sha256": "8f3a1…", "entries": 30000 }
|
||||
// … one per available (source→target) pair
|
||||
]
|
||||
}
|
||||
```
|
||||
- `file` is content-hash-versioned → immutable URLs, trivial cache-busting.
|
||||
- The app downloads a pack iff its local copy's recorded sha256 ≠ the manifest's.
|
||||
|
||||
---
|
||||
|
||||
## 3. Sync to R2
|
||||
|
||||
`scripts/sync-wordwise-r2.mjs` uploads `data/wordwise/*` (packs + `manifest.json`) to the
|
||||
`cdn.readest.com`-backed bucket under `/wordwise/`. Run manually and in CI on release.
|
||||
- Tooling: `wrangler r2 object put` (or the S3-compatible API). Bucket name + creds via env
|
||||
(`WORDWISE_R2_BUCKET`, Cloudflare token) — **not** the app's `wrangler.toml` (that bucket is the
|
||||
Next inc-cache; the CDN bucket is separate). Pack files use `cache-control: public, max-age=31536000, immutable`; `manifest.json` uses a short max-age (e.g. 300s) so new packs surface quickly.
|
||||
- A `pnpm wordwise:sync` script wraps it.
|
||||
|
||||
---
|
||||
|
||||
## 4. Runtime: manifest → download → cache → load
|
||||
|
||||
New `src/services/wordwise/glossPacks.ts` (replaces the bundled-`fetch` path in `glossIndex.ts`):
|
||||
|
||||
```ts
|
||||
export const WORDWISE_CDN_BASE = 'https://cdn.readest.com/wordwise';
|
||||
|
||||
export interface WordWisePack {
|
||||
pair: string; source: string; target: string;
|
||||
file: string; bytes: number; sha256: string; entries: number;
|
||||
}
|
||||
export interface WordWiseManifest { schemaVersion: number; packs: WordWisePack[]; }
|
||||
|
||||
// Session-cached; also persisted to 'Data' (manifest.json) for offline reuse.
|
||||
fetchManifest(appService, opts?): Promise<WordWiseManifest | null>
|
||||
|
||||
// pick the pack for (source → hint) or null if none published.
|
||||
resolvePack(manifest, source, hint): WordWisePack | null
|
||||
|
||||
// Ensure the pack file is in local 'Data'. Returns its stored path, or null.
|
||||
// - if exists locally with matching sha → reuse (no network)
|
||||
// - else download WORDWISE_CDN_BASE/<file> with onProgress, verify sha256, write to 'Data'
|
||||
// - single-flight per pair (no concurrent double download)
|
||||
ensurePack(appService, pack, onProgress?): Promise<string | null>
|
||||
|
||||
// High-level: manifest → resolvePack → ensurePack → readFile('Data') → GlossIndex.fromData
|
||||
loadGlossIndex(appService, source, hint, onProgress?): Promise<GlossIndex | null>
|
||||
```
|
||||
|
||||
- **Download mechanism:** reuse `downloadFile`/`webDownload`/`tauriDownload` from `src/libs/storage.ts`
|
||||
with `onProgress: ProgressHandler` (`{progress,total,transferSpeed}` from `utils/transfer.ts`).
|
||||
Store via `appService.writeFile(file, 'Data', arrayBuffer)`; check `appService.exists(file,'Data')`;
|
||||
delete via `appService.removeFile`. Web maps `'Data'`→IndexedDB (`webAppService.resolvePath`), so a
|
||||
~3 MB JSON persists across sessions; Tauri writes to the app data dir.
|
||||
- **Integrity:** verify sha256 of the downloaded bytes against the manifest before committing the
|
||||
write (discard + warn on mismatch).
|
||||
- **Offline:** if a pack is already local, everything works offline. If not local and offline →
|
||||
`null` (no glosses) + a one-time toast ("Word Wise data unavailable offline").
|
||||
- **Cache invalidation:** store a tiny sidecar (`<pair>.meta.json` or a row) recording the sha of the
|
||||
local pack; re-download when the manifest sha differs.
|
||||
|
||||
### `glossIndex.ts` / `wordwiseSection.ts` changes
|
||||
- `getGlossIndex(lang, baseUrl)` → removed; callers use `loadGlossIndex(appService, source, hint, onProgress)`.
|
||||
- `refreshSectionGlosses(doc, viewSettings, bookLang)` gains access to `appService` and the hint
|
||||
language (`viewSettings.wordWiseHintLang`), resolves `source = toWordWiseSource(bookLang)` and
|
||||
`hint = viewSettings.wordWiseHintLang || appUILang`, calls `loadGlossIndex(...)`. The generation
|
||||
guard, jieba gate, planner call, and DOM apply are unchanged.
|
||||
- The cache in `glossIndex` becomes keyed by `pair` (source-hint), not just source.
|
||||
|
||||
---
|
||||
|
||||
## 5. Generalizing beyond en/zh (so tiers 1–3 are data-only)
|
||||
|
||||
- **Source languages:** `WordWiseSourceLang` broadens from `'en'|'zh'` to a general ISO-639-1 string.
|
||||
A source is *usable* iff (a) the manifest has a pack for (source→hint) **and** (b) we can tokenize
|
||||
it: **Latin/space-delimited** (en, es, fr, de, pt, it, …) via the existing regex tokenizer, or
|
||||
**Chinese** via jieba. Japanese/Korean are **blocked** until a segmenter ships (tier 3) — gate
|
||||
with a `canTokenize(source)` helper; unsupported source → no glosses (+ note).
|
||||
- **Difficulty cutoffs (`difficulty.ts`):** generalize `getRankCutoff(lang, level)` to use a shared
|
||||
**frequency-scale** table for all frequency-ranked sources (en + other Latin langs all use a
|
||||
frequency rank), and the existing **HSK-scale** table only for `zh`. (Also fixes the current
|
||||
zh/cutoff mismatch found while testing: align the zh cutoffs to the HSK-derived ranks the build
|
||||
script emits, `level×3000`.)
|
||||
- **Planner:** already routes `zh → jieba`, everything else → Latin tokenizer — no change beyond the
|
||||
broadened type.
|
||||
|
||||
---
|
||||
|
||||
## 6. Hint-language selector + Language-panel placement
|
||||
|
||||
- **Move the UI into `LangPanel.tsx`** (translation already lives there). Remove the dedicated
|
||||
`WordWise` tab: delete it from `SettingsDialog` `tabConfig` + render switch, drop `'WordWise'` from
|
||||
`SettingsPanelType`, remove the `panelIcons` entry in `commandRegistry.ts`, and remove the
|
||||
`PiTranslate` line from the `command-registry-extended.test.ts` mock. (Net deletion.)
|
||||
- Add a **`NavigationRow` "Word Wise"** in `LangPanel` opening a sub-page that hosts the existing
|
||||
`WordWisePanel` content, expanded with the hint-language selector + downloads. (Sub-page over
|
||||
inline because the panel is already dense and Word Wise now has enable + slider + hint-lang +
|
||||
per-pack download/manage.)
|
||||
- **Hint-language selector:** a `SettingsSelect` mirroring the "Translate To" row — options from
|
||||
`getLangOptions(TRANSLATED_LANGS)` filtered to targets the manifest offers for the current book's
|
||||
source language; value persisted as `viewSettings.wordWiseHintLang` via `saveViewSettings`.
|
||||
Default when unset = app UI language (`i18n.language` / `getLocale()`), falling back to
|
||||
`translateTargetLang`.
|
||||
- **Download/manage UI:** under the selector, show the resolved pack with its size and state —
|
||||
"Download (2.6 MB)" / radial-progress while downloading (mirror `PublicationView`) / "Downloaded ·
|
||||
Delete". `removeFile` on delete. Success/failure via `eventDispatcher.dispatch('toast', …)`.
|
||||
- **Auto-download:** new global `settings.globalReadSettings.wordWiseAutoDownload` (default `true`).
|
||||
When enabling Word Wise / opening a book whose pack isn't local: if auto-download is on and the
|
||||
connection isn't detectably metered, fetch silently with progress; otherwise surface the
|
||||
"Download (size)" affordance. Metered detection is **best-effort** via the Network Information API
|
||||
(`navigator.connection?.type === 'cellular'` / `saveData`) where available — absent on iOS/Tauri,
|
||||
so it simply falls through to auto.
|
||||
|
||||
### `WordWiseConfig` additions (`types/book.ts` + `constants.ts`)
|
||||
```ts
|
||||
export interface WordWiseConfig {
|
||||
wordWiseEnabled: boolean;
|
||||
wordWiseLevel: number; // 1..5 (existing)
|
||||
wordWiseHintLang: string; // '' = auto (app UI language)
|
||||
}
|
||||
// DEFAULT: { wordWiseEnabled:false, wordWiseLevel:3, wordWiseHintLang:'' }
|
||||
```
|
||||
(`wordWiseAutoDownload` is a global read-setting, not per-book.)
|
||||
|
||||
---
|
||||
|
||||
## 7. Data flow
|
||||
|
||||
```
|
||||
enable WW / open book
|
||||
→ source = toWordWiseSource(book.primaryLanguage); hint = viewSettings.wordWiseHintLang || appLang
|
||||
→ loadGlossIndex(appService, source, hint, onProgress):
|
||||
manifest (session/'Data' cache) → resolvePack(source,hint)
|
||||
→ exists in 'Data' & sha matches → read → GlossIndex
|
||||
→ else (auto && !metered) download→verify→write('Data')→read→GlossIndex
|
||||
→ else expose "Download (size)" in the panel
|
||||
→ planGlosses → applyGlosses (unchanged)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing (test-first)
|
||||
|
||||
- `glossPacks.test.ts` — `resolvePack` selection; `ensurePack` (a) reuses a present matching-sha
|
||||
local file without network, (b) downloads+verifies+writes when absent, (c) rejects on sha
|
||||
mismatch, (d) single-flights concurrent calls — using a fake `appService` (in-memory exists/read/
|
||||
write) and a stubbed downloader.
|
||||
- `difficulty.test.ts` — extend: generalized frequency table applies to a non-en Latin source;
|
||||
zh cutoffs aligned to the build script's `level×3000` scale.
|
||||
- Manifest round-trip: `build-wordwise-data.mjs` emits a `manifest.json` whose sha256/bytes match the
|
||||
written pack (unit test on the manifest-writing helper with a synthetic pack).
|
||||
- Existing planner/ruby/CFI/TTS tests unchanged and green.
|
||||
- Browser: enabling Word Wise with a stubbed CDN serves a pack → glosses render; delete removes the
|
||||
local file and glosses stop.
|
||||
- Full `pnpm test` + `pnpm lint` green.
|
||||
|
||||
---
|
||||
|
||||
## 9. File change list
|
||||
|
||||
**New:** `src/services/wordwise/glossPacks.ts` (+ test); `scripts/sync-wordwise-r2.mjs`;
|
||||
`apps/readest-app/data/wordwise/{manifest.json, *.json, ATTRIBUTION.md}`; a `WordWiseSubPage`/panel
|
||||
under `src/components/settings/`.
|
||||
|
||||
**Modified:** `src/services/wordwise/{glossIndex.ts → folded into glossPacks, types.ts, difficulty.ts}`;
|
||||
`src/app/reader/utils/wordwiseSection.ts`; `src/app/reader/components/FoliateViewer.tsx` (pass
|
||||
`appService` + hint lang; route progress to a toast/indicator); `src/types/book.ts` +
|
||||
`src/services/constants.ts` (`wordWiseHintLang`, `WORDWISE_CDN_BASE`, default + `wordWiseAutoDownload`);
|
||||
`src/components/settings/LangPanel.tsx` (NavigationRow + sub-page mount + hint-lang selector +
|
||||
download/manage); `src/components/settings/SettingsDialog.tsx` + `src/services/commandRegistry.ts` +
|
||||
`command-registry-extended.test.ts` (remove the dedicated tab); `scripts/build-wordwise-data.mjs`
|
||||
(emit `data/wordwise/` + `manifest.json` with sha/bytes/hashed filenames); delete `public/wordwise/*`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Phasing
|
||||
|
||||
1. **Manifest + build/sync**: build script writes `data/wordwise/` + `manifest.json` (hash/bytes);
|
||||
`sync-wordwise-r2.mjs`; delete `public/wordwise/`.
|
||||
2. **Runtime download/cache** (`glossPacks.ts`) + refactor `glossIndex`/`wordwiseSection`/FoliateViewer
|
||||
to load via `appService` from `'Data'`, downloading on demand; tests.
|
||||
3. **Generalization**: broaden source langs + `canTokenize` gate + generalized difficulty cutoffs.
|
||||
4. **UI move**: LangPanel NavigationRow + Word Wise sub-page + hint-language selector + download/manage
|
||||
+ auto-download toggle; remove the dedicated tab.
|
||||
|
||||
## 11. Risks / notes
|
||||
- **Metered detection is unreliable** cross-platform → "prompt on cellular" is best-effort; the
|
||||
always-visible size + the global auto-download toggle are the real guardrails.
|
||||
- **CDN base is hardcoded** (no env precedent) → add a single `WORDWISE_CDN_BASE` constant.
|
||||
- **`'Data'` durability**: packs survive cache clears (good for offline); managed only via the Word
|
||||
Wise sub-page (not the generic Manage Cache), so deletion is explicit.
|
||||
- **Out of scope:** generating tier-1/2/3 *data* (es/fr/de/… and ja/ko segmenters) — this PR ships the
|
||||
infra + en↔zh through it; further pairs are data drops + a manifest entry.
|
||||
@@ -80,8 +80,8 @@
|
||||
"patch-build-webpack": "if [ \"$(uname)\" = \"Darwin\" ]; then sed -i '' 's/next build\"/next build --webpack\"/' package.json; else sed -i 's/next build\"/next build --webpack\"/' package.json; fi",
|
||||
"restore-build-original": "if [ \"$(uname)\" = \"Darwin\" ]; then sed -i '' 's/next build --webpack\"/next build\"/' package.json; else sed -i 's/next build --webpack\"/next build\"/' package.json; fi",
|
||||
"update-metadata": "bash ./scripts/sync-release-notes.sh release-notes.json ../../data/metainfo/appdata.xml",
|
||||
"wordwise:manifest": "node scripts/build-wordwise-data.mjs manifest",
|
||||
"wordwise:sync": "node scripts/sync-wordwise-r2.mjs",
|
||||
"wordlens:manifest": "node scripts/build-wordlens-data.mjs manifest",
|
||||
"wordlens:sync": "node scripts/sync-wordlens-r2.mjs",
|
||||
"check:optional-chaining": "count=$(grep -rno '\\?\\.[a-zA-Z_$]' .next/static/chunks/* out/_next/static/chunks/* | wc -l); if [ \"$count\" -gt 0 ]; then echo '❌ Optional chaining found in output!'; exit 1; else echo '✅ No optional chaining found.'; fi",
|
||||
"check:translations": "count=$(grep -rno '__STRING_NOT_TRANSLATED__' public/locales/* | wc -l); if [ \"$count\" -gt 0 ]; then echo '❌ Untranslated strings found!'; exit 1; else echo '✅ All strings translated.'; fi",
|
||||
"check:lookbehind-regex": "count=$(grep -rnoE '\\(\\?<[!=]' .next/static/chunks/* out/_next/static/chunks/* | wc -l); if [ \"$count\" -gt 0 ]; then echo '❌ Lookbehind regex found in output!'; exit 1; else echo '✅ No lookbehind regex found.'; fi",
|
||||
|
||||
@@ -1863,17 +1863,17 @@
|
||||
"Resume audio": "استئناف متابعة الصوت",
|
||||
"Following audio": "يتابع الصوت",
|
||||
" · estimated": " · تقديري",
|
||||
"Downloading Word Wise data…": "جارٍ تنزيل بيانات Word Wise…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "جارٍ تنزيل بيانات Word Lens…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "إظهار تلميح قصير بلغتك الأم فوق الكلمات الصعبة.",
|
||||
"Word Wise data downloaded": "تم تنزيل بيانات Word Wise",
|
||||
"Failed to download Word Wise data": "فشل تنزيل بيانات Word Wise",
|
||||
"Word Wise data removed": "تمت إزالة بيانات Word Wise",
|
||||
"Word Lens data downloaded": "تم تنزيل بيانات Word Lens",
|
||||
"Failed to download Word Lens data": "فشل تنزيل بيانات Word Lens",
|
||||
"Word Lens data removed": "تمت إزالة بيانات Word Lens",
|
||||
"Data pack": "حزمة البيانات",
|
||||
"Open a book to manage its data pack.": "افتح كتابًا لإدارة حزمة بياناته.",
|
||||
"No data available for this language pair yet.": "لا تتوفر بيانات لهذا الزوج اللغوي بعد.",
|
||||
"Download {{size}}": "تنزيل {{size}}",
|
||||
"Enable Word Wise": "تفعيل Word Wise",
|
||||
"Enable Word Lens": "تفعيل Word Lens",
|
||||
"Vocabulary level": "مستوى المفردات",
|
||||
"Words above your level get a hint": "تحصل الكلمات الأعلى من مستواك على تلميح",
|
||||
"Hint Language": "لغة التلميح",
|
||||
|
||||
@@ -1731,17 +1731,17 @@
|
||||
"Resume audio": "আবার অনুসরণ করুন",
|
||||
"Following audio": "অডিও অনুসরণ করছে",
|
||||
" · estimated": " · আনুমানিক",
|
||||
"Downloading Word Wise data…": "Word Wise ডেটা ডাউনলোড হচ্ছে…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Word Lens ডেটা ডাউনলোড হচ্ছে…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "কঠিন শব্দের উপরে মাতৃভাষায় একটি সংক্ষিপ্ত ইঙ্গিত দেখান।",
|
||||
"Word Wise data downloaded": "Word Wise ডেটা ডাউনলোড হয়েছে",
|
||||
"Failed to download Word Wise data": "Word Wise ডেটা ডাউনলোড করতে ব্যর্থ",
|
||||
"Word Wise data removed": "Word Wise ডেটা সরানো হয়েছে",
|
||||
"Word Lens data downloaded": "Word Lens ডেটা ডাউনলোড হয়েছে",
|
||||
"Failed to download Word Lens data": "Word Lens ডেটা ডাউনলোড করতে ব্যর্থ",
|
||||
"Word Lens data removed": "Word Lens ডেটা সরানো হয়েছে",
|
||||
"Data pack": "ডেটা প্যাক",
|
||||
"Open a book to manage its data pack.": "এর ডেটা প্যাক পরিচালনা করতে একটি বই খুলুন।",
|
||||
"No data available for this language pair yet.": "এই ভাষা জোড়ার জন্য এখনও কোনো ডেটা উপলব্ধ নেই।",
|
||||
"Download {{size}}": "{{size}} ডাউনলোড করুন",
|
||||
"Enable Word Wise": "Word Wise চালু করুন",
|
||||
"Enable Word Lens": "Word Lens চালু করুন",
|
||||
"Vocabulary level": "শব্দভান্ডার স্তর",
|
||||
"Words above your level get a hint": "আপনার স্তরের উপরের শব্দগুলির জন্য একটি ইঙ্গিত দেখানো হয়",
|
||||
"Hint Language": "ইঙ্গিতের ভাষা",
|
||||
|
||||
@@ -1698,17 +1698,17 @@
|
||||
"Resume audio": "རྗེས་འདེད་མུ་མཐུད།",
|
||||
"Following audio": "སྒྲ་གདངས་རྗེས་འདེད་བྱེད་བཞིན་པ།",
|
||||
" · estimated": " · ཚོད་དཔག",
|
||||
"Downloading Word Wise data…": "Word Wise གཞི་གྲངས་ཕབ་ལེན་བྱེད་བཞིན་པ།…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Word Lens གཞི་གྲངས་ཕབ་ལེན་བྱེད་བཞིན་པ།…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "ཚིག་དཀའ་མོའི་སྟེང་དུ་རང་སྐད་ནང་མཚོན་བྱེད་ཐུང་ངུ་ཞིག་སྟོན།",
|
||||
"Word Wise data downloaded": "Word Wise གཞི་གྲངས་ཕབ་ལེན་ཟིན།",
|
||||
"Failed to download Word Wise data": "Word Wise གཞི་གྲངས་ཕབ་ལེན་མ་ཐུབ།",
|
||||
"Word Wise data removed": "Word Wise གཞི་གྲངས་བསུབས་ཟིན།",
|
||||
"Word Lens data downloaded": "Word Lens གཞི་གྲངས་ཕབ་ལེན་ཟིན།",
|
||||
"Failed to download Word Lens data": "Word Lens གཞི་གྲངས་ཕབ་ལེན་མ་ཐུབ།",
|
||||
"Word Lens data removed": "Word Lens གཞི་གྲངས་བསུབས་ཟིན།",
|
||||
"Data pack": "གཞི་གྲངས་ཐུམ་སྒྲིལ།",
|
||||
"Open a book to manage its data pack.": "དེའི་གཞི་གྲངས་ཐུམ་སྒྲིལ་དོ་དམ་བྱེད་པར་དཔེ་ཆ་ཞིག་ཁ་ཕྱེ།",
|
||||
"No data available for this language pair yet.": "སྐད་ཡིག་ཟུང་འདིའི་ཆེད་ད་དུང་གཞི་གྲངས་མེད།",
|
||||
"Download {{size}}": "{{size}} ཕབ་ལེན།",
|
||||
"Enable Word Wise": "Word Wise སྤྱོད་རུང་བཟོ།",
|
||||
"Enable Word Lens": "Word Lens སྤྱོད་རུང་བཟོ།",
|
||||
"Vocabulary level": "ཚིག་མཛོད་རིམ་པ།",
|
||||
"Words above your level get a hint": "ཁྱེད་ཀྱི་རིམ་པ་ལས་མཐོ་བའི་ཚིག་ལ་མཚོན་བྱེད་ཐོབ།",
|
||||
"Hint Language": "མཚོན་བྱེད་སྐད་ཡིག",
|
||||
|
||||
@@ -1731,17 +1731,17 @@
|
||||
"Resume audio": "Wieder folgen",
|
||||
"Following audio": "Folgt dem Audio",
|
||||
" · estimated": " · geschätzt",
|
||||
"Downloading Word Wise data…": "Word-Wise-Daten werden heruntergeladen…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Word-Lens-Daten werden heruntergeladen…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "Zeigt einen kurzen Hinweis in der Muttersprache über schwierigen Wörtern an.",
|
||||
"Word Wise data downloaded": "Word-Wise-Daten heruntergeladen",
|
||||
"Failed to download Word Wise data": "Word-Wise-Daten konnten nicht heruntergeladen werden",
|
||||
"Word Wise data removed": "Word-Wise-Daten entfernt",
|
||||
"Word Lens data downloaded": "Word-Lens-Daten heruntergeladen",
|
||||
"Failed to download Word Lens data": "Word-Lens-Daten konnten nicht heruntergeladen werden",
|
||||
"Word Lens data removed": "Word-Lens-Daten entfernt",
|
||||
"Data pack": "Datenpaket",
|
||||
"Open a book to manage its data pack.": "Öffne ein Buch, um sein Datenpaket zu verwalten.",
|
||||
"No data available for this language pair yet.": "Für dieses Sprachpaar sind noch keine Daten verfügbar.",
|
||||
"Download {{size}}": "{{size}} herunterladen",
|
||||
"Enable Word Wise": "Word Wise aktivieren",
|
||||
"Enable Word Lens": "Word Lens aktivieren",
|
||||
"Vocabulary level": "Vokabelniveau",
|
||||
"Words above your level get a hint": "Wörter über deinem Niveau erhalten einen Hinweis",
|
||||
"Hint Language": "Hinweissprache",
|
||||
|
||||
@@ -1731,17 +1731,17 @@
|
||||
"Resume audio": "Συνέχιση ήχου",
|
||||
"Following audio": "Ακολουθεί τον ήχο",
|
||||
" · estimated": " · κατ' εκτίμηση",
|
||||
"Downloading Word Wise data…": "Λήψη δεδομένων Word Wise…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Λήψη δεδομένων Word Lens…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "Εμφάνιση σύντομης υπόδειξης στη μητρική σας γλώσσα πάνω από δύσκολες λέξεις.",
|
||||
"Word Wise data downloaded": "Τα δεδομένα Word Wise λήφθηκαν",
|
||||
"Failed to download Word Wise data": "Αποτυχία λήψης δεδομένων Word Wise",
|
||||
"Word Wise data removed": "Τα δεδομένα Word Wise αφαιρέθηκαν",
|
||||
"Word Lens data downloaded": "Τα δεδομένα Word Lens λήφθηκαν",
|
||||
"Failed to download Word Lens data": "Αποτυχία λήψης δεδομένων Word Lens",
|
||||
"Word Lens data removed": "Τα δεδομένα Word Lens αφαιρέθηκαν",
|
||||
"Data pack": "Πακέτο δεδομένων",
|
||||
"Open a book to manage its data pack.": "Ανοίξτε ένα βιβλίο για να διαχειριστείτε το πακέτο δεδομένων του.",
|
||||
"No data available for this language pair yet.": "Δεν υπάρχουν ακόμη διαθέσιμα δεδομένα για αυτό το ζεύγος γλωσσών.",
|
||||
"Download {{size}}": "Λήψη {{size}}",
|
||||
"Enable Word Wise": "Ενεργοποίηση Word Wise",
|
||||
"Enable Word Lens": "Ενεργοποίηση Word Lens",
|
||||
"Vocabulary level": "Επίπεδο λεξιλογίου",
|
||||
"Words above your level get a hint": "Οι λέξεις πάνω από το επίπεδό σας λαμβάνουν υπόδειξη",
|
||||
"Hint Language": "Γλώσσα υπόδειξης",
|
||||
|
||||
@@ -1764,17 +1764,17 @@
|
||||
"Resume audio": "Reanudar audio",
|
||||
"Following audio": "Siguiendo el audio",
|
||||
" · estimated": " · estimado",
|
||||
"Downloading Word Wise data…": "Descargando datos de Word Wise…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Descargando datos de Word Lens…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "Muestra una breve ayuda en tu idioma encima de las palabras difíciles.",
|
||||
"Word Wise data downloaded": "Datos de Word Wise descargados",
|
||||
"Failed to download Word Wise data": "No se pudieron descargar los datos de Word Wise",
|
||||
"Word Wise data removed": "Datos de Word Wise eliminados",
|
||||
"Word Lens data downloaded": "Datos de Word Lens descargados",
|
||||
"Failed to download Word Lens data": "No se pudieron descargar los datos de Word Lens",
|
||||
"Word Lens data removed": "Datos de Word Lens eliminados",
|
||||
"Data pack": "Paquete de datos",
|
||||
"Open a book to manage its data pack.": "Abre un libro para gestionar su paquete de datos.",
|
||||
"No data available for this language pair yet.": "Aún no hay datos disponibles para este par de idiomas.",
|
||||
"Download {{size}}": "Descargar {{size}}",
|
||||
"Enable Word Wise": "Activar Word Wise",
|
||||
"Enable Word Lens": "Activar Word Lens",
|
||||
"Vocabulary level": "Nivel de vocabulario",
|
||||
"Words above your level get a hint": "Las palabras por encima de tu nivel reciben una ayuda",
|
||||
"Hint Language": "Idioma de las ayudas",
|
||||
|
||||
@@ -1731,17 +1731,17 @@
|
||||
"Resume audio": "ادامهٔ دنبالکردن صدا",
|
||||
"Following audio": "در حال دنبالکردن صدا",
|
||||
" · estimated": " · تخمینی",
|
||||
"Downloading Word Wise data…": "در حال دانلود دادههای Word Wise…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "در حال دانلود دادههای Word Lens…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "نمایش راهنمایی کوتاه به زبان مادری بالای واژههای دشوار.",
|
||||
"Word Wise data downloaded": "دادههای Word Wise دانلود شد",
|
||||
"Failed to download Word Wise data": "دانلود دادههای Word Wise ناموفق بود",
|
||||
"Word Wise data removed": "دادههای Word Wise حذف شد",
|
||||
"Word Lens data downloaded": "دادههای Word Lens دانلود شد",
|
||||
"Failed to download Word Lens data": "دانلود دادههای Word Lens ناموفق بود",
|
||||
"Word Lens data removed": "دادههای Word Lens حذف شد",
|
||||
"Data pack": "بستهٔ داده",
|
||||
"Open a book to manage its data pack.": "برای مدیریت بستهٔ دادهٔ آن، کتابی را باز کنید.",
|
||||
"No data available for this language pair yet.": "هنوز دادهای برای این جفتزبان موجود نیست.",
|
||||
"Download {{size}}": "دانلود {{size}}",
|
||||
"Enable Word Wise": "فعالسازی Word Wise",
|
||||
"Enable Word Lens": "فعالسازی Word Lens",
|
||||
"Vocabulary level": "سطح واژگان",
|
||||
"Words above your level get a hint": "واژههای بالاتر از سطح شما راهنمایی میگیرند",
|
||||
"Hint Language": "زبان راهنمایی",
|
||||
|
||||
@@ -1764,17 +1764,17 @@
|
||||
"Resume audio": "Reprendre l'audio",
|
||||
"Following audio": "Suit l'audio",
|
||||
" · estimated": " · estimé",
|
||||
"Downloading Word Wise data…": "Téléchargement des données Word Wise…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Téléchargement des données Word Lens…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "Affiche une courte explication dans votre langue au-dessus des mots difficiles.",
|
||||
"Word Wise data downloaded": "Données Word Wise téléchargées",
|
||||
"Failed to download Word Wise data": "Échec du téléchargement des données Word Wise",
|
||||
"Word Wise data removed": "Données Word Wise supprimées",
|
||||
"Word Lens data downloaded": "Données Word Lens téléchargées",
|
||||
"Failed to download Word Lens data": "Échec du téléchargement des données Word Lens",
|
||||
"Word Lens data removed": "Données Word Lens supprimées",
|
||||
"Data pack": "Pack de données",
|
||||
"Open a book to manage its data pack.": "Ouvrez un livre pour gérer son pack de données.",
|
||||
"No data available for this language pair yet.": "Aucune donnée disponible pour cette paire de langues pour le moment.",
|
||||
"Download {{size}}": "Télécharger {{size}}",
|
||||
"Enable Word Wise": "Activer Word Wise",
|
||||
"Enable Word Lens": "Activer Word Lens",
|
||||
"Vocabulary level": "Niveau de vocabulaire",
|
||||
"Words above your level get a hint": "Les mots au-dessus de votre niveau reçoivent une explication",
|
||||
"Hint Language": "Langue des explications",
|
||||
|
||||
@@ -1764,17 +1764,17 @@
|
||||
"Resume audio": "חידוש מעקב",
|
||||
"Following audio": "עוקב אחר השמע",
|
||||
" · estimated": " · משוער",
|
||||
"Downloading Word Wise data…": "מוריד נתוני Word Wise…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "מוריד נתוני Word Lens…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "הצגת רמז קצר בשפת האם מעל מילים קשות.",
|
||||
"Word Wise data downloaded": "נתוני Word Wise הורדו",
|
||||
"Failed to download Word Wise data": "הורדת נתוני Word Wise נכשלה",
|
||||
"Word Wise data removed": "נתוני Word Wise הוסרו",
|
||||
"Word Lens data downloaded": "נתוני Word Lens הורדו",
|
||||
"Failed to download Word Lens data": "הורדת נתוני Word Lens נכשלה",
|
||||
"Word Lens data removed": "נתוני Word Lens הוסרו",
|
||||
"Data pack": "חבילת נתונים",
|
||||
"Open a book to manage its data pack.": "פתח ספר כדי לנהל את חבילת הנתונים שלו.",
|
||||
"No data available for this language pair yet.": "אין עדיין נתונים זמינים עבור צמד שפות זה.",
|
||||
"Download {{size}}": "הורדה {{size}}",
|
||||
"Enable Word Wise": "הפעלת Word Wise",
|
||||
"Enable Word Lens": "הפעלת Word Lens",
|
||||
"Vocabulary level": "רמת אוצר מילים",
|
||||
"Words above your level get a hint": "מילים מעל הרמה שלך מקבלות רמז",
|
||||
"Hint Language": "שפת הרמז",
|
||||
|
||||
@@ -1731,17 +1731,17 @@
|
||||
"Resume audio": "ऑडियो फिर से अनुसरण करें",
|
||||
"Following audio": "ऑडियो का अनुसरण कर रहा है",
|
||||
" · estimated": " · अनुमानित",
|
||||
"Downloading Word Wise data…": "Word Wise डेटा डाउनलोड हो रहा है…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Word Lens डेटा डाउनलोड हो रहा है…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "कठिन शब्दों के ऊपर मातृभाषा में एक संक्षिप्त संकेत दिखाएँ।",
|
||||
"Word Wise data downloaded": "Word Wise डेटा डाउनलोड हो गया",
|
||||
"Failed to download Word Wise data": "Word Wise डेटा डाउनलोड करने में विफल",
|
||||
"Word Wise data removed": "Word Wise डेटा हटा दिया गया",
|
||||
"Word Lens data downloaded": "Word Lens डेटा डाउनलोड हो गया",
|
||||
"Failed to download Word Lens data": "Word Lens डेटा डाउनलोड करने में विफल",
|
||||
"Word Lens data removed": "Word Lens डेटा हटा दिया गया",
|
||||
"Data pack": "डेटा पैक",
|
||||
"Open a book to manage its data pack.": "इसके डेटा पैक को प्रबंधित करने के लिए कोई पुस्तक खोलें।",
|
||||
"No data available for this language pair yet.": "इस भाषा युग्म के लिए अभी कोई डेटा उपलब्ध नहीं है।",
|
||||
"Download {{size}}": "{{size}} डाउनलोड करें",
|
||||
"Enable Word Wise": "Word Wise सक्षम करें",
|
||||
"Enable Word Lens": "Word Lens सक्षम करें",
|
||||
"Vocabulary level": "शब्दावली स्तर",
|
||||
"Words above your level get a hint": "आपके स्तर से ऊपर के शब्दों के लिए संकेत मिलता है",
|
||||
"Hint Language": "संकेत भाषा",
|
||||
|
||||
@@ -1731,17 +1731,17 @@
|
||||
"Resume audio": "Követés folytatása",
|
||||
"Following audio": "Hangot követi",
|
||||
" · estimated": " · becsült",
|
||||
"Downloading Word Wise data…": "Word Wise-adatok letöltése…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Word Lens-adatok letöltése…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "Rövid, anyanyelvi tipp megjelenítése a nehéz szavak felett.",
|
||||
"Word Wise data downloaded": "Word Wise-adatok letöltve",
|
||||
"Failed to download Word Wise data": "A Word Wise-adatok letöltése sikertelen",
|
||||
"Word Wise data removed": "Word Wise-adatok eltávolítva",
|
||||
"Word Lens data downloaded": "Word Lens-adatok letöltve",
|
||||
"Failed to download Word Lens data": "A Word Lens-adatok letöltése sikertelen",
|
||||
"Word Lens data removed": "Word Lens-adatok eltávolítva",
|
||||
"Data pack": "Adatcsomag",
|
||||
"Open a book to manage its data pack.": "Nyiss meg egy könyvet az adatcsomagjának kezeléséhez.",
|
||||
"No data available for this language pair yet.": "Ehhez a nyelvpárhoz még nincsenek adatok.",
|
||||
"Download {{size}}": "Letöltés ({{size}})",
|
||||
"Enable Word Wise": "Word Wise bekapcsolása",
|
||||
"Enable Word Lens": "Word Lens bekapcsolása",
|
||||
"Vocabulary level": "Szókincsszint",
|
||||
"Words above your level get a hint": "A szintednél nehezebb szavakhoz tipp jár",
|
||||
"Hint Language": "Tippek nyelve",
|
||||
|
||||
@@ -1698,17 +1698,17 @@
|
||||
"Resume audio": "Lanjutkan mengikuti",
|
||||
"Following audio": "Mengikuti audio",
|
||||
" · estimated": " · perkiraan",
|
||||
"Downloading Word Wise data…": "Mengunduh data Word Wise…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Mengunduh data Word Lens…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "Tampilkan petunjuk singkat dalam bahasa ibu di atas kata-kata sulit.",
|
||||
"Word Wise data downloaded": "Data Word Wise telah diunduh",
|
||||
"Failed to download Word Wise data": "Gagal mengunduh data Word Wise",
|
||||
"Word Wise data removed": "Data Word Wise dihapus",
|
||||
"Word Lens data downloaded": "Data Word Lens telah diunduh",
|
||||
"Failed to download Word Lens data": "Gagal mengunduh data Word Lens",
|
||||
"Word Lens data removed": "Data Word Lens dihapus",
|
||||
"Data pack": "Paket data",
|
||||
"Open a book to manage its data pack.": "Buka buku untuk mengelola paket datanya.",
|
||||
"No data available for this language pair yet.": "Belum ada data tersedia untuk pasangan bahasa ini.",
|
||||
"Download {{size}}": "Unduh {{size}}",
|
||||
"Enable Word Wise": "Aktifkan Word Wise",
|
||||
"Enable Word Lens": "Aktifkan Word Lens",
|
||||
"Vocabulary level": "Tingkat kosakata",
|
||||
"Words above your level get a hint": "Kata di atas tingkat Anda akan diberi petunjuk",
|
||||
"Hint Language": "Bahasa Petunjuk",
|
||||
|
||||
@@ -1764,17 +1764,17 @@
|
||||
"Resume audio": "Riprendi audio",
|
||||
"Following audio": "Segue l'audio",
|
||||
" · estimated": " · stimato",
|
||||
"Downloading Word Wise data…": "Download dei dati Word Wise in corso…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Download dei dati Word Lens in corso…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "Mostra un breve suggerimento nella tua lingua sopra le parole difficili.",
|
||||
"Word Wise data downloaded": "Dati Word Wise scaricati",
|
||||
"Failed to download Word Wise data": "Impossibile scaricare i dati Word Wise",
|
||||
"Word Wise data removed": "Dati Word Wise rimossi",
|
||||
"Word Lens data downloaded": "Dati Word Lens scaricati",
|
||||
"Failed to download Word Lens data": "Impossibile scaricare i dati Word Lens",
|
||||
"Word Lens data removed": "Dati Word Lens rimossi",
|
||||
"Data pack": "Pacchetto dati",
|
||||
"Open a book to manage its data pack.": "Apri un libro per gestire il suo pacchetto dati.",
|
||||
"No data available for this language pair yet.": "Nessun dato ancora disponibile per questa coppia di lingue.",
|
||||
"Download {{size}}": "Scarica {{size}}",
|
||||
"Enable Word Wise": "Attiva Word Wise",
|
||||
"Enable Word Lens": "Attiva Word Lens",
|
||||
"Vocabulary level": "Livello di vocabolario",
|
||||
"Words above your level get a hint": "Le parole sopra il tuo livello ricevono un suggerimento",
|
||||
"Hint Language": "Lingua dei suggerimenti",
|
||||
|
||||
@@ -1698,17 +1698,17 @@
|
||||
"Resume audio": "音声に再追従",
|
||||
"Following audio": "音声に追従中",
|
||||
" · estimated": " · 推定",
|
||||
"Downloading Word Wise data…": "Word Wise データをダウンロード中…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Word Lens データをダウンロード中…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "難しい単語の上に母国語の短いヒントを表示します。",
|
||||
"Word Wise data downloaded": "Word Wise データをダウンロードしました",
|
||||
"Failed to download Word Wise data": "Word Wise データのダウンロードに失敗しました",
|
||||
"Word Wise data removed": "Word Wise データを削除しました",
|
||||
"Word Lens data downloaded": "Word Lens データをダウンロードしました",
|
||||
"Failed to download Word Lens data": "Word Lens データのダウンロードに失敗しました",
|
||||
"Word Lens data removed": "Word Lens データを削除しました",
|
||||
"Data pack": "データパック",
|
||||
"Open a book to manage its data pack.": "データパックを管理するには本を開いてください。",
|
||||
"No data available for this language pair yet.": "この言語ペアに対応するデータはまだありません。",
|
||||
"Download {{size}}": "ダウンロード {{size}}",
|
||||
"Enable Word Wise": "Word Wise を有効にする",
|
||||
"Enable Word Lens": "Word Lens を有効にする",
|
||||
"Vocabulary level": "語彙レベル",
|
||||
"Words above your level get a hint": "レベルを超える単語にヒントを表示します",
|
||||
"Hint Language": "ヒント言語",
|
||||
|
||||
@@ -1698,17 +1698,17 @@
|
||||
"Resume audio": "오디오 다시 따라가기",
|
||||
"Following audio": "오디오 따라가는 중",
|
||||
" · estimated": " · 추정",
|
||||
"Downloading Word Wise data…": "Word Wise 데이터 다운로드 중…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Word Lens 데이터 다운로드 중…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "어려운 단어 위에 모국어로 된 짧은 힌트를 표시합니다.",
|
||||
"Word Wise data downloaded": "Word Wise 데이터를 다운로드했습니다",
|
||||
"Failed to download Word Wise data": "Word Wise 데이터 다운로드에 실패했습니다",
|
||||
"Word Wise data removed": "Word Wise 데이터를 삭제했습니다",
|
||||
"Word Lens data downloaded": "Word Lens 데이터를 다운로드했습니다",
|
||||
"Failed to download Word Lens data": "Word Lens 데이터 다운로드에 실패했습니다",
|
||||
"Word Lens data removed": "Word Lens 데이터를 삭제했습니다",
|
||||
"Data pack": "데이터 팩",
|
||||
"Open a book to manage its data pack.": "데이터 팩을 관리하려면 책을 여세요.",
|
||||
"No data available for this language pair yet.": "이 언어 쌍에 사용할 수 있는 데이터가 아직 없습니다.",
|
||||
"Download {{size}}": "다운로드 {{size}}",
|
||||
"Enable Word Wise": "Word Wise 사용",
|
||||
"Enable Word Lens": "Word Lens 사용",
|
||||
"Vocabulary level": "어휘 수준",
|
||||
"Words above your level get a hint": "수준을 넘는 단어에 힌트를 표시합니다",
|
||||
"Hint Language": "힌트 언어",
|
||||
|
||||
@@ -1698,17 +1698,17 @@
|
||||
"Resume audio": "Sambung ikut audio",
|
||||
"Following audio": "Mengikut audio",
|
||||
" · estimated": " · anggaran",
|
||||
"Downloading Word Wise data…": "Memuat turun data Word Wise…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Memuat turun data Word Lens…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "Tunjukkan petunjuk ringkas dalam bahasa ibunda di atas perkataan sukar.",
|
||||
"Word Wise data downloaded": "Data Word Wise telah dimuat turun",
|
||||
"Failed to download Word Wise data": "Gagal memuat turun data Word Wise",
|
||||
"Word Wise data removed": "Data Word Wise dialih keluar",
|
||||
"Word Lens data downloaded": "Data Word Lens telah dimuat turun",
|
||||
"Failed to download Word Lens data": "Gagal memuat turun data Word Lens",
|
||||
"Word Lens data removed": "Data Word Lens dialih keluar",
|
||||
"Data pack": "Pakej data",
|
||||
"Open a book to manage its data pack.": "Buka buku untuk mengurus pakej datanya.",
|
||||
"No data available for this language pair yet.": "Belum ada data tersedia untuk pasangan bahasa ini.",
|
||||
"Download {{size}}": "Muat turun {{size}}",
|
||||
"Enable Word Wise": "Dayakan Word Wise",
|
||||
"Enable Word Lens": "Dayakan Word Lens",
|
||||
"Vocabulary level": "Tahap perbendaharaan kata",
|
||||
"Words above your level get a hint": "Perkataan melebihi tahap anda akan diberi petunjuk",
|
||||
"Hint Language": "Bahasa Petunjuk",
|
||||
|
||||
@@ -1731,17 +1731,17 @@
|
||||
"Resume audio": "Audio hervatten",
|
||||
"Following audio": "Volgt audio",
|
||||
" · estimated": " · geschat",
|
||||
"Downloading Word Wise data…": "Word Wise-gegevens downloaden…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Word Lens-gegevens downloaden…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "Toon een korte hint in je moedertaal boven moeilijke woorden.",
|
||||
"Word Wise data downloaded": "Word Wise-gegevens gedownload",
|
||||
"Failed to download Word Wise data": "Downloaden van Word Wise-gegevens mislukt",
|
||||
"Word Wise data removed": "Word Wise-gegevens verwijderd",
|
||||
"Word Lens data downloaded": "Word Lens-gegevens gedownload",
|
||||
"Failed to download Word Lens data": "Downloaden van Word Lens-gegevens mislukt",
|
||||
"Word Lens data removed": "Word Lens-gegevens verwijderd",
|
||||
"Data pack": "Gegevenspakket",
|
||||
"Open a book to manage its data pack.": "Open een boek om het gegevenspakket te beheren.",
|
||||
"No data available for this language pair yet.": "Nog geen gegevens beschikbaar voor deze taalcombinatie.",
|
||||
"Download {{size}}": "Download {{size}}",
|
||||
"Enable Word Wise": "Word Wise inschakelen",
|
||||
"Enable Word Lens": "Word Lens inschakelen",
|
||||
"Vocabulary level": "Woordenschatniveau",
|
||||
"Words above your level get a hint": "Woorden boven je niveau krijgen een hint",
|
||||
"Hint Language": "Hinttaal",
|
||||
|
||||
@@ -1797,17 +1797,17 @@
|
||||
"Resume audio": "Wznów podążanie",
|
||||
"Following audio": "Podąża za dźwiękiem",
|
||||
" · estimated": " · szacowane",
|
||||
"Downloading Word Wise data…": "Pobieranie danych Word Wise…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Pobieranie danych Word Lens…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "Pokazuj krótką podpowiedź w języku ojczystym nad trudnymi słowami.",
|
||||
"Word Wise data downloaded": "Pobrano dane Word Wise",
|
||||
"Failed to download Word Wise data": "Nie udało się pobrać danych Word Wise",
|
||||
"Word Wise data removed": "Usunięto dane Word Wise",
|
||||
"Word Lens data downloaded": "Pobrano dane Word Lens",
|
||||
"Failed to download Word Lens data": "Nie udało się pobrać danych Word Lens",
|
||||
"Word Lens data removed": "Usunięto dane Word Lens",
|
||||
"Data pack": "Pakiet danych",
|
||||
"Open a book to manage its data pack.": "Otwórz książkę, aby zarządzać jej pakietem danych.",
|
||||
"No data available for this language pair yet.": "Brak danych dla tej pary języków.",
|
||||
"Download {{size}}": "Pobierz {{size}}",
|
||||
"Enable Word Wise": "Włącz Word Wise",
|
||||
"Enable Word Lens": "Włącz Word Lens",
|
||||
"Vocabulary level": "Poziom słownictwa",
|
||||
"Words above your level get a hint": "Słowa powyżej Twojego poziomu otrzymują podpowiedź",
|
||||
"Hint Language": "Język podpowiedzi",
|
||||
|
||||
@@ -1764,17 +1764,17 @@
|
||||
"Resume audio": "Retomar áudio",
|
||||
"Following audio": "Seguindo o áudio",
|
||||
" · estimated": " · estimado",
|
||||
"Downloading Word Wise data…": "Baixando dados do Word Wise…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Baixando dados do Word Lens…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "Mostra uma breve dica no seu idioma acima das palavras difíceis.",
|
||||
"Word Wise data downloaded": "Dados do Word Wise baixados",
|
||||
"Failed to download Word Wise data": "Falha ao baixar os dados do Word Wise",
|
||||
"Word Wise data removed": "Dados do Word Wise removidos",
|
||||
"Word Lens data downloaded": "Dados do Word Lens baixados",
|
||||
"Failed to download Word Lens data": "Falha ao baixar os dados do Word Lens",
|
||||
"Word Lens data removed": "Dados do Word Lens removidos",
|
||||
"Data pack": "Pacote de dados",
|
||||
"Open a book to manage its data pack.": "Abra um livro para gerenciar seu pacote de dados.",
|
||||
"No data available for this language pair yet.": "Ainda não há dados disponíveis para este par de idiomas.",
|
||||
"Download {{size}}": "Baixar {{size}}",
|
||||
"Enable Word Wise": "Ativar o Word Wise",
|
||||
"Enable Word Lens": "Ativar o Word Lens",
|
||||
"Vocabulary level": "Nível de vocabulário",
|
||||
"Words above your level get a hint": "As palavras acima do seu nível recebem uma dica",
|
||||
"Hint Language": "Idioma das dicas",
|
||||
|
||||
@@ -1764,17 +1764,17 @@
|
||||
"Resume audio": "Retomar áudio",
|
||||
"Following audio": "A seguir o áudio",
|
||||
" · estimated": " · estimado",
|
||||
"Downloading Word Wise data…": "A transferir dados do Word Wise…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "A transferir dados do Word Lens…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "Mostra uma breve dica na sua língua por cima das palavras difíceis.",
|
||||
"Word Wise data downloaded": "Dados do Word Wise transferidos",
|
||||
"Failed to download Word Wise data": "Falha ao transferir os dados do Word Wise",
|
||||
"Word Wise data removed": "Dados do Word Wise removidos",
|
||||
"Word Lens data downloaded": "Dados do Word Lens transferidos",
|
||||
"Failed to download Word Lens data": "Falha ao transferir os dados do Word Lens",
|
||||
"Word Lens data removed": "Dados do Word Lens removidos",
|
||||
"Data pack": "Pacote de dados",
|
||||
"Open a book to manage its data pack.": "Abra um livro para gerir o respetivo pacote de dados.",
|
||||
"No data available for this language pair yet.": "Ainda não há dados disponíveis para este par de idiomas.",
|
||||
"Download {{size}}": "Transferir {{size}}",
|
||||
"Enable Word Wise": "Ativar o Word Wise",
|
||||
"Enable Word Lens": "Ativar o Word Lens",
|
||||
"Vocabulary level": "Nível de vocabulário",
|
||||
"Words above your level get a hint": "As palavras acima do seu nível recebem uma dica",
|
||||
"Hint Language": "Idioma das dicas",
|
||||
|
||||
@@ -1764,17 +1764,17 @@
|
||||
"Resume audio": "Reia urmărirea",
|
||||
"Following audio": "Urmărește audio",
|
||||
" · estimated": " · estimat",
|
||||
"Downloading Word Wise data…": "Se descarcă datele Word Wise…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Se descarcă datele Word Lens…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "Afișează un scurt indiciu în limba maternă deasupra cuvintelor dificile.",
|
||||
"Word Wise data downloaded": "Datele Word Wise au fost descărcate",
|
||||
"Failed to download Word Wise data": "Descărcarea datelor Word Wise a eșuat",
|
||||
"Word Wise data removed": "Datele Word Wise au fost eliminate",
|
||||
"Word Lens data downloaded": "Datele Word Lens au fost descărcate",
|
||||
"Failed to download Word Lens data": "Descărcarea datelor Word Lens a eșuat",
|
||||
"Word Lens data removed": "Datele Word Lens au fost eliminate",
|
||||
"Data pack": "Pachet de date",
|
||||
"Open a book to manage its data pack.": "Deschide o carte pentru a gestiona pachetul ei de date.",
|
||||
"No data available for this language pair yet.": "Încă nu există date pentru această pereche de limbi.",
|
||||
"Download {{size}}": "Descarcă {{size}}",
|
||||
"Enable Word Wise": "Activează Word Wise",
|
||||
"Enable Word Lens": "Activează Word Lens",
|
||||
"Vocabulary level": "Nivel de vocabular",
|
||||
"Words above your level get a hint": "Cuvintele peste nivelul tău primesc un indiciu",
|
||||
"Hint Language": "Limba indiciilor",
|
||||
|
||||
@@ -1797,17 +1797,17 @@
|
||||
"Resume audio": "Возобновить слежение",
|
||||
"Following audio": "Следует за аудио",
|
||||
" · estimated": " · примерно",
|
||||
"Downloading Word Wise data…": "Загрузка данных Word Wise…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Загрузка данных Word Lens…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "Показывать краткую подсказку на родном языке над сложными словами.",
|
||||
"Word Wise data downloaded": "Данные Word Wise загружены",
|
||||
"Failed to download Word Wise data": "Не удалось загрузить данные Word Wise",
|
||||
"Word Wise data removed": "Данные Word Wise удалены",
|
||||
"Word Lens data downloaded": "Данные Word Lens загружены",
|
||||
"Failed to download Word Lens data": "Не удалось загрузить данные Word Lens",
|
||||
"Word Lens data removed": "Данные Word Lens удалены",
|
||||
"Data pack": "Пакет данных",
|
||||
"Open a book to manage its data pack.": "Откройте книгу, чтобы управлять её пакетом данных.",
|
||||
"No data available for this language pair yet.": "Для этой языковой пары пока нет данных.",
|
||||
"Download {{size}}": "Скачать {{size}}",
|
||||
"Enable Word Wise": "Включить Word Wise",
|
||||
"Enable Word Lens": "Включить Word Lens",
|
||||
"Vocabulary level": "Уровень словарного запаса",
|
||||
"Words above your level get a hint": "Слова выше вашего уровня получают подсказку",
|
||||
"Hint Language": "Язык подсказок",
|
||||
|
||||
@@ -1731,17 +1731,17 @@
|
||||
"Resume audio": "නැවත අනුගමනය කරන්න",
|
||||
"Following audio": "ශ්රව්යය අනුගමනය කරයි",
|
||||
" · estimated": " · ඇස්තමේන්තුගත",
|
||||
"Downloading Word Wise data…": "Word Wise දත්ත බාගත වෙමින්…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Word Lens දත්ත බාගත වෙමින්…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "අසීරු වචන ඉහළින් මව්බසින් කෙටි ඉඟියක් පෙන්වන්න.",
|
||||
"Word Wise data downloaded": "Word Wise දත්ත බාගත කරන ලදී",
|
||||
"Failed to download Word Wise data": "Word Wise දත්ත බාගත කිරීමට අසමත් විය",
|
||||
"Word Wise data removed": "Word Wise දත්ත ඉවත් කරන ලදී",
|
||||
"Word Lens data downloaded": "Word Lens දත්ත බාගත කරන ලදී",
|
||||
"Failed to download Word Lens data": "Word Lens දත්ත බාගත කිරීමට අසමත් විය",
|
||||
"Word Lens data removed": "Word Lens දත්ත ඉවත් කරන ලදී",
|
||||
"Data pack": "දත්ත පැකේජය",
|
||||
"Open a book to manage its data pack.": "එහි දත්ත පැකේජය කළමනාකරණය කිරීමට පොතක් විවෘත කරන්න.",
|
||||
"No data available for this language pair yet.": "මෙම භාෂා යුගලය සඳහා තවම දත්ත නොමැත.",
|
||||
"Download {{size}}": "{{size}} බාගන්න",
|
||||
"Enable Word Wise": "Word Wise සක්රීය කරන්න",
|
||||
"Enable Word Lens": "Word Lens සක්රීය කරන්න",
|
||||
"Vocabulary level": "වචන මාලා මට්ටම",
|
||||
"Words above your level get a hint": "ඔබේ මට්ටමට වඩා ඉහළ වචන සඳහා ඉඟියක් ලැබේ",
|
||||
"Hint Language": "ඉඟි භාෂාව",
|
||||
|
||||
@@ -1797,17 +1797,17 @@
|
||||
"Resume audio": "Nadaljuj sledenje",
|
||||
"Following audio": "Sledi zvoku",
|
||||
" · estimated": " · ocenjeno",
|
||||
"Downloading Word Wise data…": "Prenašanje podatkov Word Wise…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Prenašanje podatkov Word Lens…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "Nad težkimi besedami prikaži kratek namig v maternem jeziku.",
|
||||
"Word Wise data downloaded": "Podatki Word Wise so preneseni",
|
||||
"Failed to download Word Wise data": "Podatkov Word Wise ni bilo mogoče prenesti",
|
||||
"Word Wise data removed": "Podatki Word Wise so odstranjeni",
|
||||
"Word Lens data downloaded": "Podatki Word Lens so preneseni",
|
||||
"Failed to download Word Lens data": "Podatkov Word Lens ni bilo mogoče prenesti",
|
||||
"Word Lens data removed": "Podatki Word Lens so odstranjeni",
|
||||
"Data pack": "Paket podatkov",
|
||||
"Open a book to manage its data pack.": "Odprite knjigo za upravljanje njenega paketa podatkov.",
|
||||
"No data available for this language pair yet.": "Za ta jezikovni par še ni podatkov.",
|
||||
"Download {{size}}": "Prenesi {{size}}",
|
||||
"Enable Word Wise": "Omogoči Word Wise",
|
||||
"Enable Word Lens": "Omogoči Word Lens",
|
||||
"Vocabulary level": "Raven besedišča",
|
||||
"Words above your level get a hint": "Besede nad vašo ravnjo dobijo namig",
|
||||
"Hint Language": "Jezik namigov",
|
||||
|
||||
@@ -1731,17 +1731,17 @@
|
||||
"Resume audio": "Återuppta ljud",
|
||||
"Following audio": "Följer ljudet",
|
||||
" · estimated": " · uppskattat",
|
||||
"Downloading Word Wise data…": "Laddar ner Word Wise-data…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Laddar ner Word Lens-data…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "Visa en kort ledtråd på ditt modersmål ovanför svåra ord.",
|
||||
"Word Wise data downloaded": "Word Wise-data nedladdad",
|
||||
"Failed to download Word Wise data": "Det gick inte att ladda ner Word Wise-data",
|
||||
"Word Wise data removed": "Word Wise-data borttagen",
|
||||
"Word Lens data downloaded": "Word Lens-data nedladdad",
|
||||
"Failed to download Word Lens data": "Det gick inte att ladda ner Word Lens-data",
|
||||
"Word Lens data removed": "Word Lens-data borttagen",
|
||||
"Data pack": "Datapaket",
|
||||
"Open a book to manage its data pack.": "Öppna en bok för att hantera dess datapaket.",
|
||||
"No data available for this language pair yet.": "Inga data tillgängliga för det här språkparet ännu.",
|
||||
"Download {{size}}": "Ladda ner {{size}}",
|
||||
"Enable Word Wise": "Aktivera Word Wise",
|
||||
"Enable Word Lens": "Aktivera Word Lens",
|
||||
"Vocabulary level": "Ordförrådsnivå",
|
||||
"Words above your level get a hint": "Ord över din nivå får en ledtråd",
|
||||
"Hint Language": "Språk för ledtrådar",
|
||||
|
||||
@@ -1731,17 +1731,17 @@
|
||||
"Resume audio": "மீண்டும் பின்தொடர்",
|
||||
"Following audio": "ஆடியோவைப் பின்தொடர்கிறது",
|
||||
" · estimated": " · தோராயமாக",
|
||||
"Downloading Word Wise data…": "Word Wise தரவு பதிவிறக்கப்படுகிறது…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Word Lens தரவு பதிவிறக்கப்படுகிறது…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "கடினமான சொற்களுக்கு மேல் தாய்மொழியில் ஒரு சிறிய குறிப்பைக் காட்டு.",
|
||||
"Word Wise data downloaded": "Word Wise தரவு பதிவிறக்கப்பட்டது",
|
||||
"Failed to download Word Wise data": "Word Wise தரவைப் பதிவிறக்க முடியவில்லை",
|
||||
"Word Wise data removed": "Word Wise தரவு அகற்றப்பட்டது",
|
||||
"Word Lens data downloaded": "Word Lens தரவு பதிவிறக்கப்பட்டது",
|
||||
"Failed to download Word Lens data": "Word Lens தரவைப் பதிவிறக்க முடியவில்லை",
|
||||
"Word Lens data removed": "Word Lens தரவு அகற்றப்பட்டது",
|
||||
"Data pack": "தரவுத் தொகுப்பு",
|
||||
"Open a book to manage its data pack.": "அதன் தரவுத் தொகுப்பை நிர்வகிக்க ஒரு புத்தகத்தைத் திற.",
|
||||
"No data available for this language pair yet.": "இந்த மொழி இணைக்கு இன்னும் தரவு எதுவும் கிடைக்கவில்லை.",
|
||||
"Download {{size}}": "{{size}} பதிவிறக்கு",
|
||||
"Enable Word Wise": "Word Wise-ஐ இயக்கு",
|
||||
"Enable Word Lens": "Word Lens-ஐ இயக்கு",
|
||||
"Vocabulary level": "சொல்வளம் நிலை",
|
||||
"Words above your level get a hint": "உங்கள் நிலைக்கு மேலான சொற்களுக்கு ஒரு குறிப்பு கிடைக்கும்",
|
||||
"Hint Language": "குறிப்பு மொழி",
|
||||
|
||||
@@ -1698,17 +1698,17 @@
|
||||
"Resume audio": "ติดตามเสียงต่อ",
|
||||
"Following audio": "กำลังติดตามเสียง",
|
||||
" · estimated": " · โดยประมาณ",
|
||||
"Downloading Word Wise data…": "กำลังดาวน์โหลดข้อมูล Word Wise…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "กำลังดาวน์โหลดข้อมูล Word Lens…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "แสดงคำใบ้สั้น ๆ ในภาษาแม่เหนือคำที่ยาก",
|
||||
"Word Wise data downloaded": "ดาวน์โหลดข้อมูล Word Wise แล้ว",
|
||||
"Failed to download Word Wise data": "ดาวน์โหลดข้อมูล Word Wise ไม่สำเร็จ",
|
||||
"Word Wise data removed": "ลบข้อมูล Word Wise แล้ว",
|
||||
"Word Lens data downloaded": "ดาวน์โหลดข้อมูล Word Lens แล้ว",
|
||||
"Failed to download Word Lens data": "ดาวน์โหลดข้อมูล Word Lens ไม่สำเร็จ",
|
||||
"Word Lens data removed": "ลบข้อมูล Word Lens แล้ว",
|
||||
"Data pack": "ชุดข้อมูล",
|
||||
"Open a book to manage its data pack.": "เปิดหนังสือเพื่อจัดการชุดข้อมูลของหนังสือ",
|
||||
"No data available for this language pair yet.": "ยังไม่มีข้อมูลสำหรับคู่ภาษานี้",
|
||||
"Download {{size}}": "ดาวน์โหลด {{size}}",
|
||||
"Enable Word Wise": "เปิดใช้งาน Word Wise",
|
||||
"Enable Word Lens": "เปิดใช้งาน Word Lens",
|
||||
"Vocabulary level": "ระดับคำศัพท์",
|
||||
"Words above your level get a hint": "คำที่อยู่เหนือระดับของคุณจะมีคำใบ้",
|
||||
"Hint Language": "ภาษาของคำใบ้",
|
||||
|
||||
@@ -1731,17 +1731,17 @@
|
||||
"Resume audio": "Sesi takibe devam et",
|
||||
"Following audio": "Sesi takip ediyor",
|
||||
" · estimated": " · tahmini",
|
||||
"Downloading Word Wise data…": "Word Wise verileri indiriliyor…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Word Lens verileri indiriliyor…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "Zor sözcüklerin üzerinde ana dilde kısa bir ipucu göster.",
|
||||
"Word Wise data downloaded": "Word Wise verileri indirildi",
|
||||
"Failed to download Word Wise data": "Word Wise verileri indirilemedi",
|
||||
"Word Wise data removed": "Word Wise verileri kaldırıldı",
|
||||
"Word Lens data downloaded": "Word Lens verileri indirildi",
|
||||
"Failed to download Word Lens data": "Word Lens verileri indirilemedi",
|
||||
"Word Lens data removed": "Word Lens verileri kaldırıldı",
|
||||
"Data pack": "Veri paketi",
|
||||
"Open a book to manage its data pack.": "Veri paketini yönetmek için bir kitap açın.",
|
||||
"No data available for this language pair yet.": "Bu dil çifti için henüz veri yok.",
|
||||
"Download {{size}}": "İndir {{size}}",
|
||||
"Enable Word Wise": "Word Wise'ı etkinleştir",
|
||||
"Enable Word Lens": "Word Lens'ı etkinleştir",
|
||||
"Vocabulary level": "Kelime düzeyi",
|
||||
"Words above your level get a hint": "Düzeyinizin üzerindeki sözcükler bir ipucu alır",
|
||||
"Hint Language": "İpucu Dili",
|
||||
|
||||
@@ -1797,17 +1797,17 @@
|
||||
"Resume audio": "Відновити стеження",
|
||||
"Following audio": "Стежить за аудіо",
|
||||
" · estimated": " · приблизно",
|
||||
"Downloading Word Wise data…": "Завантаження даних Word Wise…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Завантаження даних Word Lens…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "Показувати коротку підказку рідною мовою над складними словами.",
|
||||
"Word Wise data downloaded": "Дані Word Wise завантажено",
|
||||
"Failed to download Word Wise data": "Не вдалося завантажити дані Word Wise",
|
||||
"Word Wise data removed": "Дані Word Wise видалено",
|
||||
"Word Lens data downloaded": "Дані Word Lens завантажено",
|
||||
"Failed to download Word Lens data": "Не вдалося завантажити дані Word Lens",
|
||||
"Word Lens data removed": "Дані Word Lens видалено",
|
||||
"Data pack": "Пакет даних",
|
||||
"Open a book to manage its data pack.": "Відкрийте книгу, щоб керувати її пакетом даних.",
|
||||
"No data available for this language pair yet.": "Для цієї мовної пари ще немає даних.",
|
||||
"Download {{size}}": "Завантажити {{size}}",
|
||||
"Enable Word Wise": "Увімкнути Word Wise",
|
||||
"Enable Word Lens": "Увімкнути Word Lens",
|
||||
"Vocabulary level": "Рівень словникового запасу",
|
||||
"Words above your level get a hint": "Слова, вищі за ваш рівень, отримують підказку",
|
||||
"Hint Language": "Мова підказок",
|
||||
|
||||
@@ -1731,17 +1731,17 @@
|
||||
"Resume audio": "Ergashishni davom ettirish",
|
||||
"Following audio": "Audioga ergashmoqda",
|
||||
" · estimated": " · taxminiy",
|
||||
"Downloading Word Wise data…": "Word Wise ma'lumotlari yuklab olinmoqda…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Word Lens ma'lumotlari yuklab olinmoqda…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "Qiyin so'zlar ustida ona tilida qisqa maslahat ko'rsatish.",
|
||||
"Word Wise data downloaded": "Word Wise ma'lumotlari yuklab olindi",
|
||||
"Failed to download Word Wise data": "Word Wise ma'lumotlarini yuklab olib bo'lmadi",
|
||||
"Word Wise data removed": "Word Wise ma'lumotlari o'chirildi",
|
||||
"Word Lens data downloaded": "Word Lens ma'lumotlari yuklab olindi",
|
||||
"Failed to download Word Lens data": "Word Lens ma'lumotlarini yuklab olib bo'lmadi",
|
||||
"Word Lens data removed": "Word Lens ma'lumotlari o'chirildi",
|
||||
"Data pack": "Ma'lumotlar to'plami",
|
||||
"Open a book to manage its data pack.": "Ma'lumotlar to'plamini boshqarish uchun kitob oching.",
|
||||
"No data available for this language pair yet.": "Bu til juftligi uchun hali ma'lumot yo'q.",
|
||||
"Download {{size}}": "Yuklab olish {{size}}",
|
||||
"Enable Word Wise": "Word Wise'ni yoqish",
|
||||
"Enable Word Lens": "Word Lens'ni yoqish",
|
||||
"Vocabulary level": "Lug'at darajasi",
|
||||
"Words above your level get a hint": "Darajangizdan yuqori so'zlar uchun maslahat beriladi",
|
||||
"Hint Language": "Maslahat tili",
|
||||
|
||||
@@ -1698,17 +1698,17 @@
|
||||
"Resume audio": "Tiếp tục theo âm thanh",
|
||||
"Following audio": "Đang theo âm thanh",
|
||||
" · estimated": " · ước tính",
|
||||
"Downloading Word Wise data…": "Đang tải dữ liệu Word Wise…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "Đang tải dữ liệu Word Lens…",
|
||||
"Word Lens": "Word Lens",
|
||||
"Show a short native-language hint above difficult words.": "Hiển thị gợi ý ngắn bằng tiếng mẹ đẻ phía trên các từ khó.",
|
||||
"Word Wise data downloaded": "Đã tải xong dữ liệu Word Wise",
|
||||
"Failed to download Word Wise data": "Không tải được dữ liệu Word Wise",
|
||||
"Word Wise data removed": "Đã xóa dữ liệu Word Wise",
|
||||
"Word Lens data downloaded": "Đã tải xong dữ liệu Word Lens",
|
||||
"Failed to download Word Lens data": "Không tải được dữ liệu Word Lens",
|
||||
"Word Lens data removed": "Đã xóa dữ liệu Word Lens",
|
||||
"Data pack": "Gói dữ liệu",
|
||||
"Open a book to manage its data pack.": "Mở một cuốn sách để quản lý gói dữ liệu của nó.",
|
||||
"No data available for this language pair yet.": "Chưa có dữ liệu cho cặp ngôn ngữ này.",
|
||||
"Download {{size}}": "Tải xuống {{size}}",
|
||||
"Enable Word Wise": "Bật Word Wise",
|
||||
"Enable Word Lens": "Bật Word Lens",
|
||||
"Vocabulary level": "Trình độ từ vựng",
|
||||
"Words above your level get a hint": "Các từ vượt trình độ của bạn sẽ có gợi ý",
|
||||
"Hint Language": "Ngôn ngữ gợi ý",
|
||||
|
||||
@@ -1698,17 +1698,17 @@
|
||||
"Resume audio": "恢复跟随",
|
||||
"Following audio": "正在跟随音频",
|
||||
" · estimated": " · 估算",
|
||||
"Downloading Word Wise data…": "正在下载 Word Wise 数据…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "正在下载单词透镜数据…",
|
||||
"Word Lens": "单词透镜",
|
||||
"Show a short native-language hint above difficult words.": "在生词上方显示简短的母语提示。",
|
||||
"Word Wise data downloaded": "Word Wise 数据已下载",
|
||||
"Failed to download Word Wise data": "下载 Word Wise 数据失败",
|
||||
"Word Wise data removed": "Word Wise 数据已删除",
|
||||
"Word Lens data downloaded": "单词透镜数据已下载",
|
||||
"Failed to download Word Lens data": "下载单词透镜数据失败",
|
||||
"Word Lens data removed": "单词透镜数据已删除",
|
||||
"Data pack": "数据包",
|
||||
"Open a book to manage its data pack.": "打开一本书以管理其数据包。",
|
||||
"No data available for this language pair yet.": "暂无适用于此语言对的数据。",
|
||||
"Download {{size}}": "下载 {{size}}",
|
||||
"Enable Word Wise": "启用 Word Wise",
|
||||
"Enable Word Lens": "启用单词透镜",
|
||||
"Vocabulary level": "词汇水平",
|
||||
"Words above your level get a hint": "超出你水平的词会显示提示",
|
||||
"Hint Language": "提示语言",
|
||||
|
||||
@@ -1698,17 +1698,17 @@
|
||||
"Resume audio": "恢復跟隨",
|
||||
"Following audio": "正在跟隨音訊",
|
||||
" · estimated": " · 估算",
|
||||
"Downloading Word Wise data…": "正在下載 Word Wise 資料…",
|
||||
"Word Wise": "Word Wise",
|
||||
"Downloading Word Lens data…": "正在下載單詞透鏡資料…",
|
||||
"Word Lens": "單詞透鏡",
|
||||
"Show a short native-language hint above difficult words.": "在生難字上方顯示簡短的母語提示。",
|
||||
"Word Wise data downloaded": "Word Wise 資料已下載",
|
||||
"Failed to download Word Wise data": "下載 Word Wise 資料失敗",
|
||||
"Word Wise data removed": "Word Wise 資料已移除",
|
||||
"Word Lens data downloaded": "單詞透鏡資料已下載",
|
||||
"Failed to download Word Lens data": "下載單詞透鏡資料失敗",
|
||||
"Word Lens data removed": "單詞透鏡資料已移除",
|
||||
"Data pack": "資料包",
|
||||
"Open a book to manage its data pack.": "開啟一本書以管理其資料包。",
|
||||
"No data available for this language pair yet.": "尚無適用於此語言配對的資料。",
|
||||
"Download {{size}}": "下載 {{size}}",
|
||||
"Enable Word Wise": "啟用 Word Wise",
|
||||
"Enable Word Lens": "啟用單詞透鏡",
|
||||
"Vocabulary level": "詞彙程度",
|
||||
"Words above your level get a hint": "超出你程度的字會顯示提示",
|
||||
"Hint Language": "提示語言",
|
||||
|
||||
+13
-13
@@ -1,8 +1,8 @@
|
||||
// Build trimmed Word Wise gloss indices from open datasets.
|
||||
// Build trimmed Word Lens gloss indices from open datasets.
|
||||
//
|
||||
// node scripts/build-wordwise-data.mjs en-zh path/to/ecdict.csv [topN]
|
||||
// node scripts/build-wordwise-data.mjs zh-en path/to/cedict.txt path/to/hsk.json [topN]
|
||||
// node scripts/build-wordwise-data.mjs build <src> <tgt> <freq.txt> <gloss.jsonl> [topN]
|
||||
// node scripts/build-wordlens-data.mjs en-zh path/to/ecdict.csv [topN]
|
||||
// node scripts/build-wordlens-data.mjs zh-en path/to/cedict.txt path/to/hsk.json [topN]
|
||||
// node scripts/build-wordlens-data.mjs build <src> <tgt> <freq.txt> <gloss.jsonl> [topN]
|
||||
//
|
||||
// The generalized `build` mode assembles a pack for any (src→tgt) pair where one
|
||||
// side is English, from two open datasets:
|
||||
@@ -11,9 +11,9 @@
|
||||
// tgt === 'en' → foreign headword → English glosses (extractXToEn).
|
||||
// src === 'en' → English headword → target-language words (extractEnToX).
|
||||
//
|
||||
// Outputs data/wordwise/<pair>.json in the GlossIndexData shape:
|
||||
// Outputs data/wordlens/<pair>.json in the GlossIndexData shape:
|
||||
// { meta, entries: { word: { r, g } }, inflections: { form: lemma } }
|
||||
// plus data/wordwise/manifest.json indexing the available packs.
|
||||
// plus data/wordlens/manifest.json indexing the available packs.
|
||||
//
|
||||
// ECDICT (MIT): columns word,phonetic,definition,translation,pos,collins,
|
||||
// oxford,tag,bnc,frq,exchange,detail,audio. We keep word, frq (rank),
|
||||
@@ -33,7 +33,7 @@ import { resolve } from 'node:path';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const OUT_DIR = resolve('data/wordwise');
|
||||
const OUT_DIR = resolve('data/wordlens');
|
||||
const TOP_DEFAULT = 30000;
|
||||
|
||||
// Keep a hint short + clean: drop bracket annotations ([医], [网络], [ge4]),
|
||||
@@ -505,7 +505,7 @@ async function main() {
|
||||
const [src, tgt, freqPath, glossPath, topN] = rest;
|
||||
if (!src || !tgt || !freqPath || !glossPath)
|
||||
throw new Error(
|
||||
'usage: build-wordwise-data.mjs build <src> <tgt> <freq.txt> <gloss.jsonl> [topN]',
|
||||
'usage: build-wordlens-data.mjs build <src> <tgt> <freq.txt> <gloss.jsonl> [topN]',
|
||||
);
|
||||
const freqList = parseFrequencyWords(readFileSync(freqPath, 'utf8'));
|
||||
let glossMap;
|
||||
@@ -532,7 +532,7 @@ async function main() {
|
||||
const [src, tgt, freqPath, dbPath, topN, lemmaFile] = rest;
|
||||
if (!src || !tgt || !freqPath || !dbPath)
|
||||
throw new Error(
|
||||
'usage: build-wordwise-data.mjs build-wikdict <src> <tgt> <freq.txt> <wikdict.sqlite3> [topN] [lemma.txt]',
|
||||
'usage: build-wordlens-data.mjs build-wikdict <src> <tgt> <freq.txt> <wikdict.sqlite3> [topN] [lemma.txt]',
|
||||
);
|
||||
let rows;
|
||||
try {
|
||||
@@ -574,7 +574,7 @@ async function main() {
|
||||
writeManifest();
|
||||
} else if (pair === 'en-zh') {
|
||||
const [csv, topN] = rest;
|
||||
if (!csv) throw new Error('usage: build-wordwise-data.mjs en-zh <ecdict.csv> [topN]');
|
||||
if (!csv) throw new Error('usage: build-wordlens-data.mjs en-zh <ecdict.csv> [topN]');
|
||||
const data = buildEnZh(readFileSync(csv, 'utf8'), Number(topN) || TOP_DEFAULT);
|
||||
writeFileSync(resolve(OUT_DIR, 'en-zh.json'), JSON.stringify(data));
|
||||
console.log(
|
||||
@@ -584,7 +584,7 @@ async function main() {
|
||||
} else if (pair === 'zh-en') {
|
||||
const [cedict, hsk, topN] = rest;
|
||||
if (!cedict || !hsk)
|
||||
throw new Error('usage: build-wordwise-data.mjs zh-en <cedict.txt> <hsk.json> [topN]');
|
||||
throw new Error('usage: build-wordlens-data.mjs zh-en <cedict.txt> <hsk.json> [topN]');
|
||||
const data = buildZhEn(
|
||||
readFileSync(cedict, 'utf8'),
|
||||
JSON.parse(readFileSync(hsk, 'utf8')),
|
||||
@@ -598,12 +598,12 @@ async function main() {
|
||||
console.log('manifest.json:', m.packs.length, 'packs');
|
||||
} else {
|
||||
throw new Error(
|
||||
'usage: build-wordwise-data.mjs <en-zh|zh-en|build|build-wikdict|manifest> <sources...> [topN]',
|
||||
'usage: build-wordlens-data.mjs <en-zh|zh-en|build|build-wikdict|manifest> <sources...> [topN]',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Only run the CLI when executed directly (`node build-wordwise-data.mjs ...`),
|
||||
// Only run the CLI when executed directly (`node build-wordlens-data.mjs ...`),
|
||||
// not when imported by the unit tests.
|
||||
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((err) => {
|
||||
+9
-9
@@ -1,7 +1,7 @@
|
||||
// Upload the committed Word Wise gloss packs + manifest to the cdn.readest.com
|
||||
// R2 bucket under /wordwise/. Maintainer/CI tool — run after regenerating data.
|
||||
// Upload the committed Word Lens gloss packs + manifest to the cdn.readest.com
|
||||
// R2 bucket under /wordlens/. Maintainer/CI tool — run after regenerating data.
|
||||
//
|
||||
// WORDWISE_R2_BUCKET=<bucket> node scripts/sync-wordwise-r2.mjs
|
||||
// WORDLENS_R2_BUCKET=<bucket> node scripts/sync-wordlens-r2.mjs
|
||||
//
|
||||
// Pack files get a one-year immutable cache (the app cache-busts via a ?v=<sha8>
|
||||
// query); manifest.json gets a short max-age so new packs surface quickly. Packs
|
||||
@@ -18,7 +18,7 @@ import { readdirSync } from 'node:fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const SRC_DIR = resolve('data/wordwise');
|
||||
const SRC_DIR = resolve('data/wordlens');
|
||||
const PACK_CACHE = 'public, max-age=31536000, immutable';
|
||||
const MANIFEST_CACHE = 'public, max-age=300';
|
||||
const SUCCESS_RE = /Upload complete/i;
|
||||
@@ -28,7 +28,7 @@ const PER_FILE_TIMEOUT_MS = 120_000; // hard backstop per file
|
||||
const uploadOne = (bucket, file) =>
|
||||
new Promise((resolveP) => {
|
||||
const cacheControl = file === 'manifest.json' ? MANIFEST_CACHE : PACK_CACHE;
|
||||
const key = `${bucket}/wordwise/${file}`;
|
||||
const key = `${bucket}/wordlens/${file}`;
|
||||
console.log(`Uploading ${file} -> ${key}`);
|
||||
const child = spawn(
|
||||
'wrangler',
|
||||
@@ -82,13 +82,13 @@ const uploadOne = (bucket, file) =>
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const bucket = process.env.WORDWISE_R2_BUCKET;
|
||||
const bucket = process.env.WORDLENS_R2_BUCKET;
|
||||
if (!bucket) {
|
||||
throw new Error('WORDWISE_R2_BUCKET env var is required (the cdn.readest.com R2 bucket name)');
|
||||
throw new Error('WORDLENS_R2_BUCKET env var is required (the cdn.readest.com R2 bucket name)');
|
||||
}
|
||||
const all = readdirSync(SRC_DIR).filter((f) => f.endsWith('.json'));
|
||||
if (!all.includes('manifest.json')) {
|
||||
throw new Error('manifest.json missing — run `pnpm wordwise:manifest` first');
|
||||
throw new Error('manifest.json missing — run `pnpm wordlens:manifest` first');
|
||||
}
|
||||
const ordered = [...all.filter((f) => f !== 'manifest.json').sort(), 'manifest.json'];
|
||||
|
||||
@@ -101,7 +101,7 @@ async function main() {
|
||||
else failed.push(file);
|
||||
}
|
||||
|
||||
console.log(`\nSynced ${ok}/${ordered.length} files to ${bucket}/wordwise/`);
|
||||
console.log(`\nSynced ${ok}/${ordered.length} files to ${bucket}/wordlens/`);
|
||||
if (failed.length) {
|
||||
console.error(`Failed: ${failed.join(', ')}`);
|
||||
process.exit(1);
|
||||
+2
-2
@@ -4,13 +4,13 @@ import {
|
||||
applyGlosses,
|
||||
clearGlosses,
|
||||
findGlossWord,
|
||||
} from '@/app/reader/utils/wordwiseRuby';
|
||||
} from '@/app/reader/utils/wordlensRuby';
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('wordwiseRuby', () => {
|
||||
describe('wordlensRuby', () => {
|
||||
it('builds a text model whose string matches visible text and locates offsets', () => {
|
||||
document.body.innerHTML = `<p id="p">The quick fox</p>`;
|
||||
const model = buildSectionTextModel(document);
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { buildSectionTextModel, applyGlosses, clearGlosses } from '@/app/reader/utils/wordwiseRuby';
|
||||
import { buildSectionTextModel, applyGlosses, clearGlosses } from '@/app/reader/utils/wordlensRuby';
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('Word Wise rendering (browser)', () => {
|
||||
describe('Word Lens rendering (browser)', () => {
|
||||
it('renders a gloss above a word and grows line height, then clears cleanly', () => {
|
||||
document.body.innerHTML = `<div id="root" style="font-size:16px;line-height:1.2"><p>A cryptic note appears.</p></div>`;
|
||||
const root = document.getElementById('root')!;
|
||||
+2
-2
@@ -17,8 +17,8 @@ import {
|
||||
inflectionMapFromPack as inflectionMapFromPackUntyped,
|
||||
parseLemmatizationList as parseLemmatizationListUntyped,
|
||||
buildPack as buildPackUntyped,
|
||||
} from '../../../scripts/build-wordwise-data.mjs';
|
||||
import type { GlossIndexData } from '@/services/wordwise/types';
|
||||
} from '../../../scripts/build-wordlens-data.mjs';
|
||||
import type { GlossIndexData } from '@/services/wordlens/types';
|
||||
|
||||
// The .mjs script has no type annotations; pin the builders' returns to the
|
||||
// real GlossIndexData shape so the assertions are type-checked.
|
||||
+5
-5
@@ -1,18 +1,18 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
WORD_WISE_MIN_LEVEL,
|
||||
WORD_WISE_MAX_LEVEL,
|
||||
WORD_LENS_MIN_LEVEL,
|
||||
WORD_LENS_MAX_LEVEL,
|
||||
CEFR_LEVELS,
|
||||
getRankCutoff,
|
||||
cefrLabel,
|
||||
isDifficult,
|
||||
canTokenizeSource,
|
||||
} from '@/services/wordwise/difficulty';
|
||||
} from '@/services/wordlens/difficulty';
|
||||
|
||||
describe('difficulty', () => {
|
||||
it('exposes a 1..6 (A1..C2) level range', () => {
|
||||
expect(WORD_WISE_MIN_LEVEL).toBe(1);
|
||||
expect(WORD_WISE_MAX_LEVEL).toBe(6);
|
||||
expect(WORD_LENS_MIN_LEVEL).toBe(1);
|
||||
expect(WORD_LENS_MAX_LEVEL).toBe(6);
|
||||
expect(CEFR_LEVELS).toEqual(['A1', 'A2', 'B1', 'B2', 'C1', 'C2']);
|
||||
});
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { GlossIndex } from '@/services/wordwise/glossIndex';
|
||||
import fixture from '../../fixtures/wordwise/en-zh.fixture.json';
|
||||
import type { GlossIndexData } from '@/services/wordwise/types';
|
||||
import { GlossIndex } from '@/services/wordlens/glossIndex';
|
||||
import fixture from '../../fixtures/wordlens/en-zh.fixture.json';
|
||||
import type { GlossIndexData } from '@/services/wordlens/types';
|
||||
|
||||
const index = GlossIndex.fromData(fixture as GlossIndexData);
|
||||
|
||||
+36
-36
@@ -1,16 +1,16 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import type { AppService, BaseDir } from '@/types/system';
|
||||
import type { GlossIndexData } from '@/services/wordwise/types';
|
||||
import type { GlossIndexData } from '@/services/wordlens/types';
|
||||
import type {
|
||||
BytesDownloader,
|
||||
WordWiseManifest,
|
||||
WordWisePack,
|
||||
} from '@/services/wordwise/glossPacks';
|
||||
WordLensManifest,
|
||||
WordLensPack,
|
||||
} from '@/services/wordlens/glossPacks';
|
||||
|
||||
// Fresh module instance per test so the in-session memos (manifestPromise,
|
||||
// indexCache, ensureFlights, storeDirReady) start clean — via vi.resetModules()
|
||||
// instead of a production-side reset helper.
|
||||
const importGlossPacks = () => import('@/services/wordwise/glossPacks');
|
||||
const importGlossPacks = () => import('@/services/wordlens/glossPacks');
|
||||
|
||||
// ---- in-memory fake AppService (only the file IO glossPacks touches) ----
|
||||
// Mirrors the REAL web appService: writeFile stores content verbatim and a 'text'
|
||||
@@ -69,7 +69,7 @@ const packData: GlossIndexData = {
|
||||
const packBytes = (): ArrayBuffer =>
|
||||
new TextEncoder().encode(JSON.stringify(packData)).buffer as ArrayBuffer;
|
||||
|
||||
const makePack = (overrides: Partial<WordWisePack>, sha: string): WordWisePack => ({
|
||||
const makePack = (overrides: Partial<WordLensPack>, sha: string): WordLensPack => ({
|
||||
pair: 'en-zh',
|
||||
source: 'en',
|
||||
target: 'zh',
|
||||
@@ -130,7 +130,7 @@ describe('glossPacks', () => {
|
||||
// Fake Rust downloader: write the bytes into the map at the exact `dst`
|
||||
// it was handed (the absolute path). Read must use that same path.
|
||||
const downloadFileFn = vi.fn(async ({ dst }: { dst: string }) => {
|
||||
expect(dst.startsWith('/abs/Data/wordwise/.dl-')).toBe(true);
|
||||
expect(dst.startsWith('/abs/Data/wordlens/.dl-')).toBe(true);
|
||||
map.set(dst, bytes);
|
||||
return new Headers();
|
||||
});
|
||||
@@ -148,7 +148,7 @@ describe('glossPacks', () => {
|
||||
describe('resolvePack', () => {
|
||||
it('picks the pack matching (source, target)', async () => {
|
||||
const { resolvePack } = await importGlossPacks();
|
||||
const manifest: WordWiseManifest = {
|
||||
const manifest: WordLensManifest = {
|
||||
schemaVersion: 1,
|
||||
packs: [
|
||||
makePack({ pair: 'en-zh', source: 'en', target: 'zh' }, 'a'),
|
||||
@@ -161,7 +161,7 @@ describe('glossPacks', () => {
|
||||
|
||||
it('returns null when no pack matches', async () => {
|
||||
const { resolvePack } = await importGlossPacks();
|
||||
const manifest: WordWiseManifest = {
|
||||
const manifest: WordLensManifest = {
|
||||
schemaVersion: 1,
|
||||
packs: [makePack({ source: 'en', target: 'zh' }, 'a')],
|
||||
};
|
||||
@@ -180,10 +180,10 @@ describe('glossPacks', () => {
|
||||
|
||||
const path = await ensurePack(appService, pack, { download });
|
||||
|
||||
expect(path).toBe('wordwise/en-zh.json');
|
||||
expect(path).toBe('wordlens/en-zh.json');
|
||||
expect(download).toHaveBeenCalledTimes(1);
|
||||
expect(await appService.exists('wordwise/en-zh.json', 'Data')).toBe(true);
|
||||
expect(await appService.readFile('wordwise/en-zh.json.sha', 'Data', 'text')).toBe(sha);
|
||||
expect(await appService.exists('wordlens/en-zh.json', 'Data')).toBe(true);
|
||||
expect(await appService.readFile('wordlens/en-zh.json.sha', 'Data', 'text')).toBe(sha);
|
||||
expect(store.size).toBe(2);
|
||||
});
|
||||
|
||||
@@ -193,13 +193,13 @@ describe('glossPacks', () => {
|
||||
const sha = await sha256Hex(packBytes());
|
||||
const pack = makePack({}, sha);
|
||||
// Seed local file + sidecar.
|
||||
await appService.writeFile('wordwise/en-zh.json', 'Data', packBytes());
|
||||
await appService.writeFile('wordwise/en-zh.json.sha', 'Data', sha);
|
||||
await appService.writeFile('wordlens/en-zh.json', 'Data', packBytes());
|
||||
await appService.writeFile('wordlens/en-zh.json.sha', 'Data', sha);
|
||||
const download: BytesDownloader = vi.fn(async () => packBytes());
|
||||
|
||||
const path = await ensurePack(appService, pack, { download });
|
||||
|
||||
expect(path).toBe('wordwise/en-zh.json');
|
||||
expect(path).toBe('wordlens/en-zh.json');
|
||||
expect(download).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -212,7 +212,7 @@ describe('glossPacks', () => {
|
||||
const path = await ensurePack(appService, pack, { download });
|
||||
|
||||
expect(path).toBeNull();
|
||||
expect(await appService.exists('wordwise/en-zh.json', 'Data')).toBe(false);
|
||||
expect(await appService.exists('wordlens/en-zh.json', 'Data')).toBe(false);
|
||||
expect(store.size).toBe(0);
|
||||
});
|
||||
|
||||
@@ -228,8 +228,8 @@ describe('glossPacks', () => {
|
||||
ensurePack(appService, pack, { download }),
|
||||
]);
|
||||
|
||||
expect(a).toBe('wordwise/en-zh.json');
|
||||
expect(b).toBe('wordwise/en-zh.json');
|
||||
expect(a).toBe('wordlens/en-zh.json');
|
||||
expect(b).toBe('wordlens/en-zh.json');
|
||||
expect(download).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -240,7 +240,7 @@ describe('glossPacks', () => {
|
||||
const { appService } = createFakeAppService();
|
||||
const sha = await sha256Hex(packBytes());
|
||||
const pack = makePack({}, sha);
|
||||
const manifest: WordWiseManifest = { schemaVersion: 1, packs: [pack] };
|
||||
const manifest: WordLensManifest = { schemaVersion: 1, packs: [pack] };
|
||||
|
||||
const manifestBytes = new TextEncoder().encode(JSON.stringify(manifest))
|
||||
.buffer as ArrayBuffer;
|
||||
@@ -261,9 +261,9 @@ describe('glossPacks', () => {
|
||||
// Seed the cache the OLD way: pack stored as a raw ArrayBuffer (+ sidecar).
|
||||
// A 'text' read returns the ArrayBuffer verbatim, so loadGlossIndex must
|
||||
// decode it rather than JSON.parse("[object ArrayBuffer]").
|
||||
await appService.writeFile('wordwise/en-zh.json', 'Data', packBytes());
|
||||
await appService.writeFile('wordwise/en-zh.json.sha', 'Data', sha);
|
||||
const manifest: WordWiseManifest = { schemaVersion: 1, packs: [pack] };
|
||||
await appService.writeFile('wordlens/en-zh.json', 'Data', packBytes());
|
||||
await appService.writeFile('wordlens/en-zh.json.sha', 'Data', sha);
|
||||
const manifest: WordLensManifest = { schemaVersion: 1, packs: [pack] };
|
||||
const manifestBytes = new TextEncoder().encode(JSON.stringify(manifest))
|
||||
.buffer as ArrayBuffer;
|
||||
// Only the manifest is fetched; the pack is already cached.
|
||||
@@ -280,7 +280,7 @@ describe('glossPacks', () => {
|
||||
it('returns null when the manifest has no pack for the pair', async () => {
|
||||
const { loadGlossIndex } = await importGlossPacks();
|
||||
const { appService } = createFakeAppService();
|
||||
const manifest: WordWiseManifest = { schemaVersion: 1, packs: [] };
|
||||
const manifest: WordLensManifest = { schemaVersion: 1, packs: [] };
|
||||
const manifestBytes = new TextEncoder().encode(JSON.stringify(manifest))
|
||||
.buffer as ArrayBuffer;
|
||||
const download: BytesDownloader = vi.fn(async () => manifestBytes);
|
||||
@@ -310,13 +310,13 @@ describe('glossPacks', () => {
|
||||
const { appService } = createFakeAppService();
|
||||
const sha = await sha256Hex(packBytes());
|
||||
const pack = makePack({}, sha);
|
||||
await appService.writeFile('wordwise/en-zh.json', 'Data', packBytes());
|
||||
await appService.writeFile('wordwise/en-zh.json.sha', 'Data', sha);
|
||||
await appService.writeFile('wordlens/en-zh.json', 'Data', packBytes());
|
||||
await appService.writeFile('wordlens/en-zh.json.sha', 'Data', sha);
|
||||
const download: BytesDownloader = vi.fn(async () => packBytes());
|
||||
|
||||
const path = await ensurePack(appService, pack, { download, allowDownload: false });
|
||||
|
||||
expect(path).toBe('wordwise/en-zh.json');
|
||||
expect(path).toBe('wordlens/en-zh.json');
|
||||
expect(download).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -325,7 +325,7 @@ describe('glossPacks', () => {
|
||||
const { appService } = createFakeAppService();
|
||||
const sha = await sha256Hex(packBytes());
|
||||
const pack = makePack({}, sha);
|
||||
const manifest: WordWiseManifest = { schemaVersion: 1, packs: [pack] };
|
||||
const manifest: WordLensManifest = { schemaVersion: 1, packs: [pack] };
|
||||
const manifestBytes = new TextEncoder().encode(JSON.stringify(manifest))
|
||||
.buffer as ArrayBuffer;
|
||||
// Manifest still downloads; the *pack* must not.
|
||||
@@ -343,7 +343,7 @@ describe('glossPacks', () => {
|
||||
});
|
||||
|
||||
describe('getPackStatus', () => {
|
||||
const buildManifest = (pack: WordWisePack): WordWiseManifest => ({
|
||||
const buildManifest = (pack: WordLensPack): WordLensManifest => ({
|
||||
schemaVersion: 1,
|
||||
packs: [pack],
|
||||
});
|
||||
@@ -374,7 +374,7 @@ describe('glossPacks', () => {
|
||||
it('returns null when the manifest has no pack for the pair', async () => {
|
||||
const { getPackStatus } = await importGlossPacks();
|
||||
const { appService } = createFakeAppService();
|
||||
const manifest: WordWiseManifest = { schemaVersion: 1, packs: [] };
|
||||
const manifest: WordLensManifest = { schemaVersion: 1, packs: [] };
|
||||
const manifestBytes = new TextEncoder().encode(JSON.stringify(manifest))
|
||||
.buffer as ArrayBuffer;
|
||||
const download: BytesDownloader = vi.fn(async () => manifestBytes);
|
||||
@@ -390,7 +390,7 @@ describe('glossPacks', () => {
|
||||
const { appService, store } = createFakeAppService();
|
||||
const sha = await sha256Hex(packBytes());
|
||||
const pack = makePack({}, sha);
|
||||
const manifest: WordWiseManifest = { schemaVersion: 1, packs: [pack] };
|
||||
const manifest: WordLensManifest = { schemaVersion: 1, packs: [pack] };
|
||||
const manifestBytes = new TextEncoder().encode(JSON.stringify(manifest))
|
||||
.buffer as ArrayBuffer;
|
||||
const download: BytesDownloader = vi.fn(async (url: string) =>
|
||||
@@ -402,10 +402,10 @@ describe('glossPacks', () => {
|
||||
|
||||
await deletePack(appService, pack);
|
||||
|
||||
expect(await appService.exists('wordwise/en-zh.json', 'Data')).toBe(false);
|
||||
expect(await appService.exists('wordwise/en-zh.json.sha', 'Data')).toBe(false);
|
||||
expect(await appService.exists('wordlens/en-zh.json', 'Data')).toBe(false);
|
||||
expect(await appService.exists('wordlens/en-zh.json.sha', 'Data')).toBe(false);
|
||||
// Only the persisted manifest remains.
|
||||
expect(store.has('Data:wordwise/manifest.json')).toBe(true);
|
||||
expect(store.has('Data:wordlens/manifest.json')).toBe(true);
|
||||
expect((await getPackStatus(appService, 'en', 'zh', { download }))!.downloaded).toBe(false);
|
||||
});
|
||||
|
||||
@@ -420,7 +420,7 @@ describe('glossPacks', () => {
|
||||
describe('listAvailableTargets', () => {
|
||||
it('returns the target codes the manifest offers for a source', async () => {
|
||||
const { listAvailableTargets } = await importGlossPacks();
|
||||
const manifest: WordWiseManifest = {
|
||||
const manifest: WordLensManifest = {
|
||||
schemaVersion: 1,
|
||||
packs: [
|
||||
makePack({ pair: 'en-zh', source: 'en', target: 'zh' }, 'a'),
|
||||
@@ -439,7 +439,7 @@ describe('glossPacks', () => {
|
||||
it('downloads, persists to Data, then serves the persisted copy when offline', async () => {
|
||||
const { fetchManifest } = await importGlossPacks();
|
||||
const { appService } = createFakeAppService();
|
||||
const manifest: WordWiseManifest = {
|
||||
const manifest: WordLensManifest = {
|
||||
schemaVersion: 1,
|
||||
packs: [makePack({}, 'abc')],
|
||||
};
|
||||
@@ -449,7 +449,7 @@ describe('glossPacks', () => {
|
||||
|
||||
const first = await fetchManifest(appService, { download });
|
||||
expect(first?.packs[0]?.pair).toBe('en-zh');
|
||||
expect(await appService.exists('wordwise/manifest.json', 'Data')).toBe(true);
|
||||
expect(await appService.exists('wordlens/manifest.json', 'Data')).toBe(true);
|
||||
|
||||
// force: true bypasses the in-session memo, so we re-attempt the (now
|
||||
// offline) download and fall back to the persisted copy.
|
||||
+6
-6
@@ -1,9 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { planGlosses } from '@/services/wordwise/planner';
|
||||
import { GlossIndex } from '@/services/wordwise/glossIndex';
|
||||
import { getRankCutoff } from '@/services/wordwise/difficulty';
|
||||
import type { GlossSource, GlossEntry, GlossIndexData } from '@/services/wordwise/types';
|
||||
import zhEnFixture from '../../fixtures/wordwise/zh-en.fixture.json';
|
||||
import { planGlosses } from '@/services/wordlens/planner';
|
||||
import { GlossIndex } from '@/services/wordlens/glossIndex';
|
||||
import { getRankCutoff } from '@/services/wordlens/difficulty';
|
||||
import type { GlossSource, GlossEntry, GlossIndexData } from '@/services/wordlens/types';
|
||||
import zhEnFixture from '../../fixtures/wordlens/zh-en.fixture.json';
|
||||
|
||||
const source: GlossSource = {
|
||||
lookup(word) {
|
||||
@@ -53,7 +53,7 @@ describe('planGlosses (Chinese)', () => {
|
||||
});
|
||||
|
||||
describe('planGlosses against a zh-en fixture', () => {
|
||||
// Decoupled from the shipping data/wordwise/zh-en.json: the committed pack is
|
||||
// Decoupled from the shipping data/wordlens/zh-en.json: the committed pack is
|
||||
// (re)generated from real corpora, so the test owns its ranks via a fixture.
|
||||
const data = zhEnFixture as GlossIndexData;
|
||||
const index = GlossIndex.fromData(data);
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
import { applyScrollableStyle, applyTableTouchScroll } from '@/utils/scrollable';
|
||||
import { mountAdditionalFonts, mountCustomFont } from '@/styles/fonts';
|
||||
import { layoutWarichu, relayoutWarichu } from '@/utils/warichu';
|
||||
import { refreshSectionGlosses } from '@/app/reader/utils/wordwiseSection';
|
||||
import { refreshSectionGlosses } from '@/app/reader/utils/wordlensSection';
|
||||
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
|
||||
import { getIndexFromCfi } from '@/utils/cfi';
|
||||
import { useUICSS } from '@/hooks/useUICSS';
|
||||
@@ -411,30 +411,30 @@ const FoliateViewer: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
// Build the Word Wise refresh context: gate silent auto-download on the global
|
||||
// Build the Word Lens refresh context: gate silent auto-download on the global
|
||||
// toggle AND a best-effort metered-connection check, and show a single
|
||||
// "Downloading…" toast on the first progress tick (the per-percent progress
|
||||
// lives in the Word Wise settings panel). `wordWiseToastShownRef` de-dupes the
|
||||
// lives in the Word Lens settings panel). `wordLensToastShownRef` de-dupes the
|
||||
// toast across the multiple section docs a refresh pass touches.
|
||||
const wordWiseToastShownRef = useRef(false);
|
||||
const buildWordWiseCtx = (bookLang?: string | null) => {
|
||||
const wordLensToastShownRef = useRef(false);
|
||||
const buildWordLensCtx = (bookLang?: string | null) => {
|
||||
// Read the live setting (not the first-render `settings` snapshot closed over
|
||||
// by the empty-deps `stabilizedHandler`) so toggling Auto-download mid-session
|
||||
// takes effect on the next section refresh.
|
||||
const liveSettings = useSettingsStore.getState().settings;
|
||||
const allowDownload =
|
||||
(liveSettings.globalReadSettings.wordWiseAutoDownload ?? true) && !isMetered();
|
||||
(liveSettings.globalReadSettings.wordLensAutoDownload ?? true) && !isMetered();
|
||||
return {
|
||||
appService: appService!,
|
||||
bookLang,
|
||||
appLang: getLocale().split('-')[0] || 'en',
|
||||
allowDownload,
|
||||
onProgress: () => {
|
||||
if (wordWiseToastShownRef.current) return;
|
||||
wordWiseToastShownRef.current = true;
|
||||
if (wordLensToastShownRef.current) return;
|
||||
wordLensToastShownRef.current = true;
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
message: _('Downloading Word Wise data…'),
|
||||
message: _('Downloading Word Lens data…'),
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -447,7 +447,7 @@ const FoliateViewer: React.FC<{
|
||||
const vs = getViewSettings(bookKey);
|
||||
const bookLang = getBookData(bookKey)?.book?.primaryLanguage;
|
||||
// Fixed-layout (pre-paginated) books have no reflow room; injecting ruby
|
||||
// would overflow their fixed boxes, so skip Word Wise glosses there.
|
||||
// would overflow their fixed boxes, so skip Word Lens glosses there.
|
||||
const isFixedLayout = bookDoc.rendition?.layout === 'pre-paginated';
|
||||
for (const { doc } of contents) {
|
||||
if (doc) {
|
||||
@@ -459,7 +459,7 @@ const FoliateViewer: React.FC<{
|
||||
relayoutWarichu(doc);
|
||||
}
|
||||
if (vs && appService && !isFixedLayout) {
|
||||
void refreshSectionGlosses(doc, vs, buildWordWiseCtx(bookLang));
|
||||
void refreshSectionGlosses(doc, vs, buildWordLensCtx(bookLang));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -834,12 +834,12 @@ const FoliateViewer: React.FC<{
|
||||
if (isFixedLayout) return;
|
||||
// A settings change is the moment a fresh download may start; let the
|
||||
// one-time "Downloading…" toast fire again for it.
|
||||
wordWiseToastShownRef.current = false;
|
||||
wordLensToastShownRef.current = false;
|
||||
for (const { doc } of contents) {
|
||||
if (doc) void refreshSectionGlosses(doc, vs, buildWordWiseCtx(bookLang));
|
||||
if (doc) void refreshSectionGlosses(doc, vs, buildWordLensCtx(bookLang));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewSettings?.wordWiseEnabled, viewSettings?.wordWiseLevel, viewSettings?.wordWiseHintLang]);
|
||||
}, [viewSettings?.wordLensEnabled, viewSettings?.wordLensLevel, viewSettings?.wordLensHintLang]);
|
||||
|
||||
useEffect(() => {
|
||||
const mountCustomFonts = async () => {
|
||||
|
||||
@@ -174,10 +174,10 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
|
||||
// (Android long-press selects text via selectionchange before touchend). The
|
||||
// pending action runs on touchend so popups don't open under an active touch.
|
||||
const deferredQuickActionRef = useRef(createDeferredActionState());
|
||||
// Set when a Word Wise gloss tap synthesizes a selection so the
|
||||
// Set when a Word Lens gloss tap synthesizes a selection so the
|
||||
// selection-change effect opens the dictionary popup instead of the
|
||||
// annotation toolbar. Cleared as soon as it's consumed.
|
||||
const pendingWordWiseDictRef = useRef(false);
|
||||
const pendingWordLensDictRef = useRef(false);
|
||||
|
||||
const showingPopup =
|
||||
showAnnotPopup || showDictionaryPopup || showDeepLPopup || showProofreadPopup;
|
||||
@@ -560,12 +560,12 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
|
||||
|
||||
useFoliateEvents(view, { onLoad, onCreateOverlay, onDrawAnnotation, onShowAnnotation });
|
||||
|
||||
// Word Wise: open the dictionary popup for a tapped glossed word. The tap is
|
||||
// Word Lens: open the dictionary popup for a tapped glossed word. The tap is
|
||||
// detected in the iframe click handler (iframeEventHandlers.ts), which sends
|
||||
// the gloss <ruby> element here. We synthesize a selection over the base word
|
||||
// (excluding the <rt> hint) so the existing dictionary popup positions itself.
|
||||
useEffect(() => {
|
||||
const handleWordWiseDictionary = (event: CustomEvent) => {
|
||||
const handleWordLensDictionary = (event: CustomEvent) => {
|
||||
const { element, word } = event.detail as { element: Element | null; word: string };
|
||||
if (event.detail?.bookKey !== bookKey || !element || !word) return;
|
||||
// Read the view fresh: this handler is registered once (deps [bookKey]) and
|
||||
@@ -584,7 +584,7 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
|
||||
return;
|
||||
}
|
||||
const text = range.toString().trim() || word;
|
||||
pendingWordWiseDictRef.current = true;
|
||||
pendingWordLensDictRef.current = true;
|
||||
setSelection({
|
||||
key: bookKey,
|
||||
text,
|
||||
@@ -594,9 +594,9 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
|
||||
page: index + 1,
|
||||
});
|
||||
};
|
||||
eventDispatcher.on('wordwise-dictionary', handleWordWiseDictionary);
|
||||
eventDispatcher.on('wordlens-dictionary', handleWordLensDictionary);
|
||||
return () => {
|
||||
eventDispatcher.off('wordwise-dictionary', handleWordWiseDictionary);
|
||||
eventDispatcher.off('wordlens-dictionary', handleWordLensDictionary);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bookKey]);
|
||||
@@ -817,11 +817,11 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
|
||||
useEffect(() => {
|
||||
setHighlightOptionsVisible(!!(selection && selection.annotated));
|
||||
if (selection && selection.text.trim().length > 0) {
|
||||
// Read-and-reset the Word Wise dictionary flag up front so it can never
|
||||
// Read-and-reset the Word Lens dictionary flag up front so it can never
|
||||
// stick to a later selection if an early return below fires (e.g. a gloss
|
||||
// tap whose synthesized range yields an off-frame/zero position).
|
||||
const wantWordWiseDict = pendingWordWiseDictRef.current;
|
||||
pendingWordWiseDictRef.current = false;
|
||||
const wantWordLensDict = pendingWordLensDictRef.current;
|
||||
pendingWordLensDictRef.current = false;
|
||||
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
|
||||
if (!gridFrame) return;
|
||||
const rect = gridFrame.getBoundingClientRect();
|
||||
@@ -866,7 +866,7 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
|
||||
setTrianglePosition(triangPos);
|
||||
|
||||
const { enableAnnotationQuickActions, annotationQuickAction } = viewSettings;
|
||||
if (wantWordWiseDict) {
|
||||
if (wantWordLensDict) {
|
||||
setShowAnnotPopup(false);
|
||||
setShowDictionaryPopup(true);
|
||||
} else if (enableAnnotationQuickActions && annotationQuickAction && isTextSelected.current) {
|
||||
|
||||
@@ -247,7 +247,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ isVisible, bookKey, onHideSearchB
|
||||
query: term,
|
||||
acceptNode: createRejectFilter({
|
||||
tags: primaryLang.startsWith('ja') ? ['rt'] : [],
|
||||
// Word Wise gloss text (<rt cfi-inert>) is injected, non-book content.
|
||||
// Word Lens gloss text (<rt cfi-inert>) is injected, non-book content.
|
||||
attributes: ['cfi-inert'],
|
||||
}),
|
||||
results: cachedResults,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DOUBLE_CLICK_INTERVAL_THRESHOLD_MS, LONG_HOLD_THRESHOLD } from '@/services/constants';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { findGlossWord } from '@/app/reader/utils/wordwiseRuby';
|
||||
import { findGlossWord } from '@/app/reader/utils/wordlensRuby';
|
||||
|
||||
let lastClickTime = 0;
|
||||
let longHoldTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -248,12 +248,12 @@ export const handleClick = (
|
||||
return;
|
||||
}
|
||||
|
||||
// Word Wise: tapping a glossed word looks it up in the dictionary. Checked
|
||||
// Word Lens: tapping a glossed word looks it up in the dictionary. Checked
|
||||
// after the drag/long-hold guards so only a clean single tap triggers it.
|
||||
const glossWord = findGlossWord(element);
|
||||
if (glossWord) {
|
||||
const ruby = element?.closest('ruby.ww-gloss') ?? null;
|
||||
eventDispatcher.dispatch('wordwise-dictionary', { bookKey, element: ruby, word: glossWord });
|
||||
eventDispatcher.dispatch('wordlens-dictionary', { bookKey, element: ruby, word: glossWord });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { GlossOccurrence } from '@/services/wordwise/types';
|
||||
import type { GlossOccurrence } from '@/services/wordlens/types';
|
||||
|
||||
const GLOSS_CLASS = 'ww-gloss';
|
||||
|
||||
+11
-11
@@ -1,14 +1,14 @@
|
||||
import type { ViewSettings } from '@/types/book';
|
||||
import type { AppService } from '@/types/system';
|
||||
import type { ProgressHandler } from '@/utils/transfer';
|
||||
import { canTokenizeSource, getRankCutoff } from '@/services/wordwise/difficulty';
|
||||
import { loadGlossIndex } from '@/services/wordwise/glossPacks';
|
||||
import { planGlosses } from '@/services/wordwise/planner';
|
||||
import { buildSectionTextModel, applyGlosses, clearGlosses } from '@/app/reader/utils/wordwiseRuby';
|
||||
import { canTokenizeSource, getRankCutoff } from '@/services/wordlens/difficulty';
|
||||
import { loadGlossIndex } from '@/services/wordlens/glossPacks';
|
||||
import { planGlosses } from '@/services/wordlens/planner';
|
||||
import { buildSectionTextModel, applyGlosses, clearGlosses } from '@/app/reader/utils/wordlensRuby';
|
||||
import { cutZh, isJiebaReady, initJieba } from '@/utils/jieba';
|
||||
|
||||
/** Normalize a book language tag to its 2-letter base source code, or null. */
|
||||
export const toWordWiseSource = (lang?: string | null): string | null => {
|
||||
export const toWordLensSource = (lang?: string | null): string | null => {
|
||||
if (!lang) return null;
|
||||
const base = lang.toLowerCase().split('-')[0];
|
||||
return base || null;
|
||||
@@ -22,7 +22,7 @@ interface RefreshContext {
|
||||
/**
|
||||
* Whether the reader may silently download an uncached pack. Threaded to
|
||||
* loadGlossIndex → ensurePack; when false an uncached pack yields no glosses
|
||||
* (the user downloads it explicitly from the Word Wise sub-page).
|
||||
* (the user downloads it explicitly from the Word Lens sub-page).
|
||||
*/
|
||||
allowDownload?: boolean;
|
||||
onProgress?: ProgressHandler;
|
||||
@@ -49,10 +49,10 @@ export const refreshSectionGlosses = async (
|
||||
const myGen = (refreshGen.get(doc) ?? 0) + 1;
|
||||
refreshGen.set(doc, myGen);
|
||||
clearGlosses(doc);
|
||||
if (!viewSettings.wordWiseEnabled) return;
|
||||
const source = toWordWiseSource(ctx.bookLang);
|
||||
if (!viewSettings.wordLensEnabled) return;
|
||||
const source = toWordLensSource(ctx.bookLang);
|
||||
if (!source || !canTokenizeSource(source)) return;
|
||||
const hint = (viewSettings.wordWiseHintLang || ctx.appLang).toLowerCase().split('-')[0] || '';
|
||||
const hint = (viewSettings.wordLensHintLang || ctx.appLang).toLowerCase().split('-')[0] || '';
|
||||
if (!hint || hint === source) return; // no self-gloss
|
||||
const index = await loadGlossIndex(ctx.appService, source, hint, {
|
||||
onProgress: ctx.onProgress,
|
||||
@@ -67,11 +67,11 @@ export const refreshSectionGlosses = async (
|
||||
const model = buildSectionTextModel(doc);
|
||||
const occ = planGlosses(model.text, index, {
|
||||
sourceLang: source,
|
||||
rankCutoff: getRankCutoff(source, viewSettings.wordWiseLevel),
|
||||
rankCutoff: getRankCutoff(source, viewSettings.wordLensLevel),
|
||||
cutZh: source === 'zh' ? cutZh : undefined,
|
||||
});
|
||||
if (occ.length) applyGlosses(doc, model, occ);
|
||||
} catch (err) {
|
||||
console.warn('[wordwise] refresh failed', err);
|
||||
console.warn('[wordlens] refresh failed', err);
|
||||
}
|
||||
};
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
SettingsSwitchRow,
|
||||
} from './primitives';
|
||||
import CustomDictionaries from './CustomDictionaries';
|
||||
import WordWisePanel from './WordWisePanel';
|
||||
import WordLensPanel from './WordLensPanel';
|
||||
import { PiTranslate } from 'react-icons/pi';
|
||||
|
||||
const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
|
||||
@@ -52,7 +52,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
viewSettings.convertChineseVariant,
|
||||
);
|
||||
const [showCustomDictionaries, setShowCustomDictionaries] = useState(false);
|
||||
const [showWordWise, setShowWordWise] = useState(false);
|
||||
const [showWordLens, setShowWordLens] = useState(false);
|
||||
|
||||
// Android Back / Esc: when a sub-page is open, intercept and step back to the
|
||||
// language list instead of letting <Dialog>'s listener close the whole
|
||||
@@ -63,8 +63,8 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
onCancel: () => setShowCustomDictionaries(false),
|
||||
});
|
||||
useKeyDownActions({
|
||||
enabled: showWordWise,
|
||||
onCancel: () => setShowWordWise(false),
|
||||
enabled: showWordLens,
|
||||
onCancel: () => setShowWordLens(false),
|
||||
});
|
||||
|
||||
// Deep-link: callers (e.g. the dictionary popup's manage icon) can set
|
||||
@@ -288,8 +288,8 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
);
|
||||
}
|
||||
|
||||
if (showWordWise) {
|
||||
return <WordWisePanel bookKey={bookKey} onBack={() => setShowWordWise(false)} />;
|
||||
if (showWordLens) {
|
||||
return <WordLensPanel bookKey={bookKey} onBack={() => setShowWordLens(false)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -318,15 +318,15 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
</BoxedList>
|
||||
|
||||
<BoxedList
|
||||
title={_('Word Wise')}
|
||||
data-setting-id='settings.language.wordwise'
|
||||
title={_('Word Lens')}
|
||||
data-setting-id='settings.language.wordlens'
|
||||
cardClassName='overflow-hidden'
|
||||
>
|
||||
<NavigationRow
|
||||
icon={PiTranslate}
|
||||
title={_('Word Wise')}
|
||||
title={_('Word Lens')}
|
||||
status={_('Show a short native-language hint above difficult words.')}
|
||||
onClick={() => setShowWordWise(true)}
|
||||
onClick={() => setShowWordLens(true)}
|
||||
/>
|
||||
</BoxedList>
|
||||
|
||||
|
||||
+51
-54
@@ -11,31 +11,31 @@ import { formatBytes } from '@/utils/book';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { TRANSLATED_LANGS } from '@/services/constants';
|
||||
import {
|
||||
WORD_WISE_MIN_LEVEL,
|
||||
WORD_WISE_MAX_LEVEL,
|
||||
WORD_LENS_MIN_LEVEL,
|
||||
WORD_LENS_MAX_LEVEL,
|
||||
cefrLabel,
|
||||
} from '@/services/wordwise/difficulty';
|
||||
import { toWordWiseSource } from '@/app/reader/utils/wordwiseSection';
|
||||
} from '@/services/wordlens/difficulty';
|
||||
import { toWordLensSource } from '@/app/reader/utils/wordlensSection';
|
||||
import {
|
||||
deletePack,
|
||||
ensurePack,
|
||||
fetchManifest,
|
||||
getPackStatus,
|
||||
listAvailableTargets,
|
||||
type WordWiseManifest,
|
||||
type WordWisePack,
|
||||
} from '@/services/wordwise/glossPacks';
|
||||
type WordLensManifest,
|
||||
type WordLensPack,
|
||||
} from '@/services/wordlens/glossPacks';
|
||||
import SubPageHeader from './SubPageHeader';
|
||||
import { BoxedList, SettingsRow, SettingsSelect, SettingsSwitchRow } from './primitives';
|
||||
|
||||
interface WordWisePanelProps {
|
||||
interface WordLensPanelProps {
|
||||
bookKey: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const baseCode = (lang?: string | null): string => (lang || '').toLowerCase().split('-')[0] || '';
|
||||
|
||||
const WordWisePanel: React.FC<WordWisePanelProps> = ({ bookKey, onBack }) => {
|
||||
const WordLensPanel: React.FC<WordLensPanelProps> = ({ bookKey, onBack }) => {
|
||||
const _ = useTranslation();
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { getViewSettings, setViewSettings } = useReaderStore();
|
||||
@@ -45,17 +45,17 @@ const WordWisePanel: React.FC<WordWisePanelProps> = ({ bookKey, onBack }) => {
|
||||
const bookData = getBookData(bookKey);
|
||||
|
||||
const appLang = baseCode(getLocale());
|
||||
const bookSource = toWordWiseSource(bookData?.book?.primaryLanguage);
|
||||
const bookSource = toWordLensSource(bookData?.book?.primaryLanguage);
|
||||
|
||||
const [wordWiseEnabled, setWordWiseEnabled] = useState(viewSettings.wordWiseEnabled ?? false);
|
||||
const [wordWiseLevel, setWordWiseLevel] = useState(viewSettings.wordWiseLevel ?? 3);
|
||||
const [hintLang, setHintLang] = useState(viewSettings.wordWiseHintLang || appLang);
|
||||
const [wordLensEnabled, setWordLensEnabled] = useState(viewSettings.wordLensEnabled ?? false);
|
||||
const [wordLensLevel, setWordLensLevel] = useState(viewSettings.wordLensLevel ?? 3);
|
||||
const [hintLang, setHintLang] = useState(viewSettings.wordLensHintLang || appLang);
|
||||
const [autoDownload, setAutoDownload] = useState(
|
||||
settings.globalReadSettings.wordWiseAutoDownload ?? true,
|
||||
settings.globalReadSettings.wordLensAutoDownload ?? true,
|
||||
);
|
||||
|
||||
const [manifest, setManifest] = useState<WordWiseManifest | null>(null);
|
||||
const [packStatus, setPackStatus] = useState<{ pack: WordWisePack; downloaded: boolean } | null>(
|
||||
const [manifest, setManifest] = useState<WordLensManifest | null>(null);
|
||||
const [packStatus, setPackStatus] = useState<{ pack: WordLensPack; downloaded: boolean } | null>(
|
||||
null,
|
||||
);
|
||||
const [resolving, setResolving] = useState(false);
|
||||
@@ -77,16 +77,16 @@ const WordWisePanel: React.FC<WordWisePanelProps> = ({ bookKey, onBack }) => {
|
||||
}, [appService]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wordWiseEnabled === viewSettings.wordWiseEnabled) return;
|
||||
saveViewSettings(envConfig, bookKey, 'wordWiseEnabled', wordWiseEnabled, false, false);
|
||||
if (wordLensEnabled === viewSettings.wordLensEnabled) return;
|
||||
saveViewSettings(envConfig, bookKey, 'wordLensEnabled', wordLensEnabled, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wordWiseEnabled]);
|
||||
}, [wordLensEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wordWiseLevel === viewSettings.wordWiseLevel) return;
|
||||
saveViewSettings(envConfig, bookKey, 'wordWiseLevel', wordWiseLevel, false, false);
|
||||
if (wordLensLevel === viewSettings.wordLensLevel) return;
|
||||
saveViewSettings(envConfig, bookKey, 'wordLensLevel', wordLensLevel, false, false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wordWiseLevel]);
|
||||
}, [wordLensLevel]);
|
||||
|
||||
// Re-resolve the data-pack row whenever the (source → hint) pair changes.
|
||||
useEffect(() => {
|
||||
@@ -138,7 +138,7 @@ const WordWisePanel: React.FC<WordWisePanelProps> = ({ bookKey, onBack }) => {
|
||||
// option that should appear selected. '' shows Auto; a base code resolves to
|
||||
// the first option whose base code matches (e.g. 'zh' → 'zh-CN').
|
||||
const selectedHintValue = (() => {
|
||||
const stored = viewSettings.wordWiseHintLang;
|
||||
const stored = viewSettings.wordLensHintLang;
|
||||
if (!stored) return '';
|
||||
if (hintLangOptions.some((o) => o.value === stored)) return stored;
|
||||
const byBase = hintLangOptions.find((o) => baseCode(o.value) === baseCode(stored));
|
||||
@@ -148,15 +148,15 @@ const WordWisePanel: React.FC<WordWisePanelProps> = ({ bookKey, onBack }) => {
|
||||
const handleSelectHintLang = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const option = event.target.value;
|
||||
setHintLang(option || appLang);
|
||||
saveViewSettings(envConfig, bookKey, 'wordWiseHintLang', option, false, false);
|
||||
viewSettings.wordWiseHintLang = option;
|
||||
saveViewSettings(envConfig, bookKey, 'wordLensHintLang', option, false, false);
|
||||
viewSettings.wordLensHintLang = option;
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
};
|
||||
|
||||
const handleToggleAutoDownload = () => {
|
||||
const next = !autoDownload;
|
||||
setAutoDownload(next);
|
||||
settings.globalReadSettings.wordWiseAutoDownload = next;
|
||||
settings.globalReadSettings.wordLensAutoDownload = next;
|
||||
setSettings(settings);
|
||||
saveSettings(envConfig, settings);
|
||||
};
|
||||
@@ -176,18 +176,18 @@ const WordWisePanel: React.FC<WordWisePanelProps> = ({ bookKey, onBack }) => {
|
||||
setPackStatus({ ...packStatus, downloaded: true });
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'success',
|
||||
message: _('Word Wise data downloaded'),
|
||||
message: _('Word Lens data downloaded'),
|
||||
});
|
||||
} else {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('Failed to download Word Wise data'),
|
||||
message: _('Failed to download Word Lens data'),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('Failed to download Word Wise data'),
|
||||
message: _('Failed to download Word Lens data'),
|
||||
});
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
@@ -199,7 +199,7 @@ const WordWisePanel: React.FC<WordWisePanelProps> = ({ bookKey, onBack }) => {
|
||||
if (!appService || !packStatus) return;
|
||||
await deletePack(appService, packStatus.pack);
|
||||
setPackStatus({ ...packStatus, downloaded: false });
|
||||
eventDispatcher.dispatch('toast', { type: 'info', message: _('Word Wise data removed') });
|
||||
eventDispatcher.dispatch('toast', { type: 'info', message: _('Word Lens data removed') });
|
||||
};
|
||||
|
||||
const renderDataPackRow = () => {
|
||||
@@ -266,7 +266,7 @@ const WordWisePanel: React.FC<WordWisePanelProps> = ({ bookKey, onBack }) => {
|
||||
type='button'
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
className='btn btn-primary btn-sm shrink-0'
|
||||
className='btn btn-primary btn-contrast btn-sm shrink-0'
|
||||
>
|
||||
{_('Download {{size}}', { size })}
|
||||
</button>
|
||||
@@ -279,46 +279,44 @@ const WordWisePanel: React.FC<WordWisePanelProps> = ({ bookKey, onBack }) => {
|
||||
<div className={clsx('my-4 w-full space-y-6')}>
|
||||
<SubPageHeader
|
||||
parentLabel={_('Language')}
|
||||
currentLabel={_('Word Wise')}
|
||||
description={_('Show a short native-language hint above difficult words.')}
|
||||
currentLabel={_('Word Lens')}
|
||||
description={_(
|
||||
'Show a short native-language hint above difficult words. Words above your level get a hint.',
|
||||
)}
|
||||
onBack={onBack}
|
||||
/>
|
||||
|
||||
<BoxedList title={_('Word Wise')} data-setting-id='settings.wordwise.main'>
|
||||
<BoxedList title={_('Word Lens')} data-setting-id='settings.wordlens.main'>
|
||||
<SettingsSwitchRow
|
||||
label={_('Enable Word Wise')}
|
||||
checked={wordWiseEnabled}
|
||||
onChange={() => setWordWiseEnabled(!wordWiseEnabled)}
|
||||
data-setting-id='settings.wordwise.enabled'
|
||||
label={_('Enable Word Lens')}
|
||||
checked={wordLensEnabled}
|
||||
onChange={() => setWordLensEnabled(!wordLensEnabled)}
|
||||
data-setting-id='settings.wordlens.enabled'
|
||||
/>
|
||||
<SettingsRow
|
||||
label={_('Vocabulary level')}
|
||||
description={_('Words above your level get a hint')}
|
||||
disabled={!wordWiseEnabled}
|
||||
>
|
||||
<SettingsRow label={_('Level')} disabled={!wordLensEnabled}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<input
|
||||
type='range'
|
||||
className='range range-sm eink-bordered'
|
||||
min={WORD_WISE_MIN_LEVEL}
|
||||
max={WORD_WISE_MAX_LEVEL}
|
||||
min={WORD_LENS_MIN_LEVEL}
|
||||
max={WORD_LENS_MAX_LEVEL}
|
||||
step={1}
|
||||
value={wordWiseLevel}
|
||||
disabled={!wordWiseEnabled}
|
||||
value={wordLensLevel}
|
||||
disabled={!wordLensEnabled}
|
||||
aria-label={_('Vocabulary level')}
|
||||
onChange={(e) => setWordWiseLevel(Number(e.target.value))}
|
||||
data-setting-id='settings.wordwise.level'
|
||||
onChange={(e) => setWordLensLevel(Number(e.target.value))}
|
||||
data-setting-id='settings.wordlens.level'
|
||||
/>
|
||||
<span className='text-base-content/70 w-6 text-end text-sm tabular-nums'>
|
||||
{cefrLabel(wordWiseLevel)}
|
||||
{cefrLabel(wordLensLevel)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={_('Hint Language')}>
|
||||
<SettingsRow label={_('Language')}>
|
||||
<SettingsSelect
|
||||
value={selectedHintValue}
|
||||
onChange={handleSelectHintLang}
|
||||
ariaLabel={_('Hint Language')}
|
||||
ariaLabel={_('Language')}
|
||||
options={hintLangOptions}
|
||||
/>
|
||||
</SettingsRow>
|
||||
@@ -328,7 +326,6 @@ const WordWisePanel: React.FC<WordWisePanelProps> = ({ bookKey, onBack }) => {
|
||||
{renderDataPackRow()}
|
||||
<SettingsSwitchRow
|
||||
label={_('Auto-download')}
|
||||
description={_('Download data packs automatically when needed.')}
|
||||
checked={autoDownload}
|
||||
onChange={handleToggleAutoDownload}
|
||||
/>
|
||||
@@ -337,4 +334,4 @@ const WordWisePanel: React.FC<WordWisePanelProps> = ({ bookKey, onBack }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default WordWisePanel;
|
||||
export default WordLensPanel;
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
ViewConfig,
|
||||
ViewSettings,
|
||||
ViewSettingsConfig,
|
||||
WordWiseConfig,
|
||||
WordLensConfig,
|
||||
} from '@/types/book';
|
||||
import {
|
||||
HardcoverSettings,
|
||||
@@ -199,7 +199,7 @@ export const DEFAULT_READSETTINGS: ReadSettings = {
|
||||
autohideCursor: true,
|
||||
translationProvider: 'deepl',
|
||||
translateTargetLang: 'EN',
|
||||
wordWiseAutoDownload: true,
|
||||
wordLensAutoDownload: true,
|
||||
|
||||
customThemes: [],
|
||||
highlightStyle: 'highlight',
|
||||
@@ -405,10 +405,10 @@ export const DEFAULT_ANNOTATOR_CONFIG: AnnotatorConfig = {
|
||||
noteExportConfig: DEFAULT_NOTE_EXPORT_CONFIG,
|
||||
};
|
||||
|
||||
export const DEFAULT_WORD_WISE_CONFIG: WordWiseConfig = {
|
||||
wordWiseEnabled: false,
|
||||
wordWiseLevel: 3,
|
||||
wordWiseHintLang: '',
|
||||
export const DEFAULT_WORD_LENS_CONFIG: WordLensConfig = {
|
||||
wordLensEnabled: false,
|
||||
wordLensLevel: 3,
|
||||
wordLensHintLang: '',
|
||||
};
|
||||
|
||||
export const DEFAULT_SCREEN_CONFIG: ScreenConfig = {
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
SETTINGS_FILENAME,
|
||||
DEFAULT_MOBILE_SYSTEM_SETTINGS,
|
||||
DEFAULT_ANNOTATOR_CONFIG,
|
||||
DEFAULT_WORD_WISE_CONFIG,
|
||||
DEFAULT_WORD_LENS_CONFIG,
|
||||
DEFAULT_EINK_VIEW_SETTINGS,
|
||||
DEFAULT_VIEW_SETTINGS_CONFIG,
|
||||
} from './constants';
|
||||
@@ -45,7 +45,7 @@ export function getDefaultViewSettings(ctx: Context): ViewSettings {
|
||||
...DEFAULT_TTS_CONFIG,
|
||||
...DEFAULT_SCREEN_CONFIG,
|
||||
...DEFAULT_ANNOTATOR_CONFIG,
|
||||
...DEFAULT_WORD_WISE_CONFIG,
|
||||
...DEFAULT_WORD_LENS_CONFIG,
|
||||
...DEFAULT_VIEW_SETTINGS_CONFIG,
|
||||
...(ctx.isMobile ? DEFAULT_MOBILE_VIEW_SETTINGS : {}),
|
||||
...(ctx.isEink ? DEFAULT_EINK_VIEW_SETTINGS : {}),
|
||||
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
import type { WordWiseSourceLang } from './types';
|
||||
import type { WordLensSourceLang } from './types';
|
||||
|
||||
// CEFR proficiency levels. The slider picks the reader's level; a word is glossed
|
||||
// when it's ABOVE that level (rarer than the vocabulary a learner at that level
|
||||
@@ -8,8 +8,8 @@ import type { WordWiseSourceLang } from './types';
|
||||
export const CEFR_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] as const;
|
||||
export type CefrLevel = (typeof CEFR_LEVELS)[number];
|
||||
|
||||
export const WORD_WISE_MIN_LEVEL = 1; // A1
|
||||
export const WORD_WISE_MAX_LEVEL = CEFR_LEVELS.length; // 6 (C2)
|
||||
export const WORD_LENS_MIN_LEVEL = 1; // A1
|
||||
export const WORD_LENS_MAX_LEVEL = CEFR_LEVELS.length; // 6 (C2)
|
||||
|
||||
// Level (1=A1 … 6=C2) -> rank cutoff = the vocabulary a learner at that level
|
||||
// knows. A word is glossed when its rank >= cutoff, so a LOWER level (A1) => LOWER
|
||||
@@ -25,9 +25,9 @@ const FREQUENCY: readonly number[] = [1000, 2000, 4000, 8000, 14000, 24000];
|
||||
const HSK: readonly number[] = [6000, 9000, 12000, 15000, 18000, 24000];
|
||||
|
||||
const clampLevel = (level: number): number =>
|
||||
Math.min(WORD_WISE_MAX_LEVEL, Math.max(WORD_WISE_MIN_LEVEL, Math.round(level)));
|
||||
Math.min(WORD_LENS_MAX_LEVEL, Math.max(WORD_LENS_MIN_LEVEL, Math.round(level)));
|
||||
|
||||
export const getRankCutoff = (lang: WordWiseSourceLang, level: number): number =>
|
||||
export const getRankCutoff = (lang: WordLensSourceLang, level: number): number =>
|
||||
(lang === 'zh' ? HSK : FREQUENCY)[clampLevel(level) - 1]!;
|
||||
|
||||
/** CEFR label ('A1'…'C2') for a 1..6 slider level. */
|
||||
+23
-23
@@ -6,11 +6,11 @@ import { webDownload } from '@/utils/transfer';
|
||||
import { GlossIndex } from './glossIndex';
|
||||
import type { GlossIndexData } from './types';
|
||||
|
||||
export const WORDWISE_CDN_BASE = 'https://cdn.readest.com/wordwise';
|
||||
const STORE_DIR = 'wordwise'; // relative dir under BaseDir 'Data'
|
||||
export const WORDLENS_CDN_BASE = 'https://cdn.readest.com/wordlens';
|
||||
const STORE_DIR = 'wordlens'; // relative dir under BaseDir 'Data'
|
||||
const MANIFEST_FILE = 'manifest.json';
|
||||
|
||||
export interface WordWisePack {
|
||||
export interface WordLensPack {
|
||||
pair: string;
|
||||
source: string;
|
||||
target: string;
|
||||
@@ -20,9 +20,9 @@ export interface WordWisePack {
|
||||
entries: number;
|
||||
}
|
||||
|
||||
export interface WordWiseManifest {
|
||||
export interface WordLensManifest {
|
||||
schemaVersion: number;
|
||||
packs: WordWisePack[];
|
||||
packs: WordLensPack[];
|
||||
}
|
||||
|
||||
/** Injectable byte-getter so the loader stays cross-platform AND unit-testable. */
|
||||
@@ -114,27 +114,27 @@ const getDownloader =
|
||||
override ? override(url, onProgress) : defaultDownloader(appService, url, onProgress);
|
||||
|
||||
export const resolvePack = (
|
||||
manifest: WordWiseManifest | null,
|
||||
manifest: WordLensManifest | null,
|
||||
source: string,
|
||||
hint: string,
|
||||
): WordWisePack | null =>
|
||||
): WordLensPack | null =>
|
||||
manifest?.packs.find((p) => p.source === source && p.target === hint) ?? null;
|
||||
|
||||
/** The `target` codes the manifest offers for `source` (for the hint selector). */
|
||||
export const listAvailableTargets = (manifest: WordWiseManifest | null, source: string): string[] =>
|
||||
export const listAvailableTargets = (manifest: WordLensManifest | null, source: string): string[] =>
|
||||
manifest?.packs.filter((p) => p.source === source).map((p) => p.target) ?? [];
|
||||
|
||||
// In-session memoized manifest (one network attempt per session unless forced).
|
||||
let manifestPromise: Promise<WordWiseManifest | null> | null = null;
|
||||
let manifestPromise: Promise<WordLensManifest | null> | null = null;
|
||||
|
||||
// Module-level lazy cache: one resolved GlossIndex per pair per session.
|
||||
const indexCache = new Map<string, Promise<GlossIndex | null>>();
|
||||
|
||||
const readPersistedManifest = async (appService: AppService): Promise<WordWiseManifest | null> => {
|
||||
const readPersistedManifest = async (appService: AppService): Promise<WordLensManifest | null> => {
|
||||
try {
|
||||
if (!(await appService.exists(storePath(MANIFEST_FILE), 'Data'))) return null;
|
||||
const text = (await appService.readFile(storePath(MANIFEST_FILE), 'Data', 'text')) as string;
|
||||
return JSON.parse(text) as WordWiseManifest;
|
||||
return JSON.parse(text) as WordLensManifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -143,19 +143,19 @@ const readPersistedManifest = async (appService: AppService): Promise<WordWiseMa
|
||||
export const fetchManifest = async (
|
||||
appService: AppService,
|
||||
opts?: { download?: BytesDownloader; force?: boolean },
|
||||
): Promise<WordWiseManifest | null> => {
|
||||
): Promise<WordLensManifest | null> => {
|
||||
if (manifestPromise && !opts?.force) return manifestPromise;
|
||||
const download = getDownloader(appService, opts?.download);
|
||||
manifestPromise = (async () => {
|
||||
try {
|
||||
const bytes = await download(`${WORDWISE_CDN_BASE}/${MANIFEST_FILE}`);
|
||||
const bytes = await download(`${WORDLENS_CDN_BASE}/${MANIFEST_FILE}`);
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
const manifest = JSON.parse(text) as WordWiseManifest;
|
||||
const manifest = JSON.parse(text) as WordLensManifest;
|
||||
await ensureStoreDir(appService);
|
||||
await appService.writeFile(storePath(MANIFEST_FILE), 'Data', text);
|
||||
return manifest;
|
||||
} catch (err) {
|
||||
console.warn('[wordwise] manifest fetch failed; trying persisted copy', err);
|
||||
console.warn('[wordlens] manifest fetch failed; trying persisted copy', err);
|
||||
return readPersistedManifest(appService);
|
||||
}
|
||||
})();
|
||||
@@ -167,7 +167,7 @@ const ensureFlights = new Map<string, Promise<string | null>>();
|
||||
|
||||
const ensurePackUncached = async (
|
||||
appService: AppService,
|
||||
pack: WordWisePack,
|
||||
pack: WordLensPack,
|
||||
opts?: { onProgress?: ProgressHandler; download?: BytesDownloader; allowDownload?: boolean },
|
||||
): Promise<string | null> => {
|
||||
const dst = storePath(pack.file);
|
||||
@@ -184,18 +184,18 @@ const ensurePackUncached = async (
|
||||
if (opts?.allowDownload === false) return null;
|
||||
|
||||
const download = getDownloader(appService, opts?.download);
|
||||
const url = `${WORDWISE_CDN_BASE}/${pack.file}?v=${pack.sha256.slice(0, 8)}`;
|
||||
const url = `${WORDLENS_CDN_BASE}/${pack.file}?v=${pack.sha256.slice(0, 8)}`;
|
||||
let bytes: ArrayBuffer;
|
||||
try {
|
||||
bytes = await download(url, opts?.onProgress);
|
||||
} catch (err) {
|
||||
console.warn('[wordwise] pack download failed', pack.pair, err);
|
||||
console.warn('[wordlens] pack download failed', pack.pair, err);
|
||||
return null;
|
||||
}
|
||||
|
||||
const actual = await sha256OfBytes(bytes);
|
||||
if (actual !== pack.sha256) {
|
||||
console.warn('[wordwise] pack sha mismatch; discarding', pack.pair, {
|
||||
console.warn('[wordlens] pack sha mismatch; discarding', pack.pair, {
|
||||
actual,
|
||||
expected: pack.sha256,
|
||||
});
|
||||
@@ -214,7 +214,7 @@ const ensurePackUncached = async (
|
||||
|
||||
export const ensurePack = async (
|
||||
appService: AppService,
|
||||
pack: WordWisePack,
|
||||
pack: WordLensPack,
|
||||
opts?: { onProgress?: ProgressHandler; download?: BytesDownloader; allowDownload?: boolean },
|
||||
): Promise<string | null> => {
|
||||
const existing = ensureFlights.get(pack.pair);
|
||||
@@ -236,7 +236,7 @@ export const getPackStatus = async (
|
||||
source: string,
|
||||
hint: string,
|
||||
opts?: { download?: BytesDownloader },
|
||||
): Promise<{ pack: WordWisePack; downloaded: boolean } | null> => {
|
||||
): Promise<{ pack: WordLensPack; downloaded: boolean } | null> => {
|
||||
const manifest = await fetchManifest(appService, { download: opts?.download });
|
||||
const pack = resolvePack(manifest, source, hint);
|
||||
if (!pack) return null;
|
||||
@@ -259,7 +259,7 @@ export const getPackStatus = async (
|
||||
* not-found), and evict the in-session GlossIndex memo so a later re-enable
|
||||
* reloads from a fresh download.
|
||||
*/
|
||||
export const deletePack = async (appService: AppService, pack: WordWisePack): Promise<void> => {
|
||||
export const deletePack = async (appService: AppService, pack: WordLensPack): Promise<void> => {
|
||||
for (const path of [storePath(pack.file), sidecarPath(pack.file)]) {
|
||||
try {
|
||||
await appService.deleteFile(path, 'Data');
|
||||
@@ -296,7 +296,7 @@ export const loadGlossIndex = async (
|
||||
const text = typeof raw === 'string' ? raw : new TextDecoder().decode(raw);
|
||||
return GlossIndex.fromData(JSON.parse(text) as GlossIndexData);
|
||||
} catch (err) {
|
||||
console.warn('[wordwise] loadGlossIndex failed', key, err);
|
||||
console.warn('[wordlens] loadGlossIndex failed', key, err);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
import type { GlossOccurrence, GlossSource, WordWiseSourceLang } from './types';
|
||||
import type { GlossOccurrence, GlossSource, WordLensSourceLang } from './types';
|
||||
import { isDifficult } from './difficulty';
|
||||
|
||||
export interface PlanOptions {
|
||||
sourceLang: WordWiseSourceLang;
|
||||
sourceLang: WordLensSourceLang;
|
||||
/** A word is glossed when its rank >= rankCutoff. */
|
||||
rankCutoff: number;
|
||||
/** Hard cap on occurrences per call (default 2000). Logged when hit. */
|
||||
@@ -69,7 +69,7 @@ export const planGlosses = (
|
||||
if (!entry || !isDifficult(entry.rank, opts.rankCutoff)) continue;
|
||||
occurrences.push({ start: t.start, end: t.end, word: t.word, gloss: entry.gloss });
|
||||
if (occurrences.length >= cap) {
|
||||
console.warn(`[wordwise] occurrence cap (${cap}) hit; some hints omitted`);
|
||||
console.warn(`[wordlens] occurrence cap (${cap}) hit; some hints omitted`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
// Word Wise: inline native-language hints above difficult words.
|
||||
// See docs/superpowers/specs/2026-06-14-word-wise-design.md
|
||||
// Word Lens: inline native-language hints above difficult words.
|
||||
// See docs/superpowers/specs/2026-06-14-word-lens-design.md
|
||||
|
||||
/** Book source language as an ISO-639-1 base code (en, zh, es, …). */
|
||||
export type WordWiseSourceLang = string;
|
||||
export type WordLensSourceLang = string;
|
||||
|
||||
/** A difficulty rank + native-language gloss for one headword. */
|
||||
export interface GlossEntry {
|
||||
@@ -30,7 +30,7 @@ export interface GlossOccurrence {
|
||||
gloss: string;
|
||||
}
|
||||
|
||||
/** On-disk shape of a downloaded gloss pack (data/wordwise/<pair>.json, served from R2). */
|
||||
/** On-disk shape of a downloaded gloss pack (data/wordlens/<pair>.json, served from R2). */
|
||||
export interface GlossIndexData {
|
||||
meta: { source: string; target: string; metric: string; version: number; count: number };
|
||||
/** headword -> { r: rank, g: gloss }. Compact keys to shrink the asset. */
|
||||
@@ -339,12 +339,12 @@ export interface AnnotatorConfig {
|
||||
noteExportConfig: NoteExportConfig;
|
||||
}
|
||||
|
||||
export interface WordWiseConfig {
|
||||
wordWiseEnabled: boolean;
|
||||
export interface WordLensConfig {
|
||||
wordLensEnabled: boolean;
|
||||
/** Difficulty slider, 1 (fewest hints) .. 5 (most hints). */
|
||||
wordWiseLevel: number;
|
||||
wordLensLevel: number;
|
||||
/** Hint (target) language; '' = auto (app UI language). */
|
||||
wordWiseHintLang: string;
|
||||
wordLensHintLang: string;
|
||||
}
|
||||
|
||||
export interface ScreenConfig {
|
||||
@@ -387,7 +387,7 @@ export interface ViewSettings
|
||||
ScreenConfig,
|
||||
ProofreadRulesConfig,
|
||||
AnnotatorConfig,
|
||||
WordWiseConfig,
|
||||
WordLensConfig,
|
||||
ViewSettingsConfig {}
|
||||
|
||||
export interface BookProgress {
|
||||
|
||||
@@ -55,11 +55,11 @@ export interface ReadSettings {
|
||||
translationProvider: string;
|
||||
translateTargetLang: string;
|
||||
/**
|
||||
* Global Word Wise toggle: auto-download a gloss pack on demand when the
|
||||
* Global Word Lens toggle: auto-download a gloss pack on demand when the
|
||||
* pair isn't cached locally. When off, the reader never fetches packs
|
||||
* silently; users download them explicitly from the Word Wise sub-page.
|
||||
* silently; users download them explicitly from the Word Lens sub-page.
|
||||
*/
|
||||
wordWiseAutoDownload: boolean;
|
||||
wordLensAutoDownload: boolean;
|
||||
highlightStyle: HighlightStyle;
|
||||
highlightStyles: Record<HighlightStyle, HighlightColor>;
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ type NavWithConnection = Navigator & {
|
||||
* Best-effort metered-connection detection. Returns `true` only when the
|
||||
* Network Information API positively reports a cellular connection or the
|
||||
* user's data-saver preference; returns `false` when the API is unavailable or
|
||||
* inconclusive. Used to gate silent Word Wise pack auto-downloads.
|
||||
* inconclusive. Used to gate silent Word Lens pack auto-downloads.
|
||||
*/
|
||||
export const isMetered = (): boolean => {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
"!**/gen/**",
|
||||
"!**/autogenerated/**",
|
||||
"!**/schemas/**",
|
||||
"!**/data/wordwise/**"
|
||||
"!**/data/wordlens/**"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
|
||||
Reference in New Issue
Block a user