Prep for the Chrome Web Store submission of the Send to Readest browser
extension:
- Bump manifest + package version 0.2.0 -> 0.2.1.
- Add a `pnpm zip` script (scripts/zip.mjs) that builds and packages dist/
into a versioned, Web-Store-ready archive: manifest.json at the zip root,
excluding webpack's `.LICENSE.txt` banners and `.DS_Store`. Ignore the
produced `*.zip` in the extension's .gitignore.
- Add STORE-SUBMISSION.md: copy-paste dashboard answers (single purpose,
per-permission justifications, remote-code answer, data-use disclosures,
listing copy) pointing at the canonical privacy policy hosted at
https://www.readest.com/send-to-readest/privacy-policy.
Verified: pnpm test:extension (51 passed), pnpm build-browser-ext, pnpm zip
(valid archive), pnpm lint clean.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Chinese chapter-detection regex treated certain measure words (the
classifiers for "letter" and "book") as chapter units and let a title
attach directly after the unit. As a result, ordinary prose such as
"the first letter" or "the fourth book records..." was split out as
bogus TOC entries when importing Chinese TXT novels.
Split the unit characters into two explicit tiers that share the same
number prefix and stay in one alternation (a single split pass, so a
segment mixing chapter and volume headings is handled together):
- Chapter units may carry a title attached directly, unchanged.
- Volume/measure-word units only start a heading when the title is
introduced by a separator (colon, comma, space, or parentheses) or
the line ends, never a bare noun directly after the unit.
Real volume and chapter headings still match. Adds regex-level and
end-to-end tests covering both reported cases.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cherry-picked and re-verified the applicable subset of
julianshen/readest@fa1b74a0 (its "address PR #15 bot reviews" commit). The
fork-only AI-annotation-tool change was dropped — readest has no 'ai' toolbar
tool. Each logic fix is covered by a failing-first test.
- TTS position sequence is now an app-wide monotonic counter, so a fresh
TTSController (constructed per `tts-speak`) isn't dropped by consumers holding
`lastSequenceSeen` from a prior session.
- share.ts only swallows AbortError (user cancel); other failures — e.g.
NotAllowedError when a quick action fires without a user gesture — fall back to
the clipboard so the text still reaches the user.
- document.isTxt tolerates MIME params (text/plain;charset=utf-8), uppercase
extensions (BOOK.TXT), and a nameless Blob, so a TXT can't slip onto the
non-text path and yield a null book.
- updater getNightlyPlatformKey matches x86_64/aarch64 explicitly; a 32-bit or
otherwise unknown arch yields no nightly instead of mis-routing to aarch64.
- UpdaterWindow downloadWithProgress resolves on tauriDownload completion even
when Content-Length is absent (no more hang on portable/AppImage/Android).
- nightly_update.rs uses async tokio::fs::read in the async command.
- nightly.yml: serialize runs via a concurrency group (no cancel) and
persist-credentials:false on checkouts.
- edge TTS route only emits the word-boundary header when it fits under ~8KB;
oversized values get dropped by proxies, and the client falls back to [].
- RSVPOverlay drops the contradictory aria-disabled on the functional rate
button (it opens the pace picker).
- nightly verify harness handles artifact stream errors instead of crashing.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Switching into Paragraph or RSVP mode while TTS is already playing now
syncs to the live session instead of forcing a stop + restart inside the
mode. Bundles several related TTS fixes uncovered along the way.
Session reuse (enter from normal mode):
- TTSController.redispatchPosition() re-emits the current position on the
canonical tts-position signal with a fresh sequence.
- useTTSControl answers a new tts-sync-request by replaying the current
position then playback state (position-first so RSVP's paused handler
can't discard it).
- Paragraph & RSVP engage following on entry and dispatch the request;
no-op when no session exists.
RSVP refinements:
- Reusing a session skips the start dialog and the get-ready countdown
(starts externally driven); gated on a live tts-playback-state signal
so the countdown can't flash.
- Stopping TTS now pauses RSVP instead of resuming its own pacing.
Word-sync fixes:
- rangeTextExcludingInert honours the range offsets inside a single text
node, fixing word-highlight drift on middle sentences of single-<span>
paragraphs (Edge word highlighting).
- foliate-js TTS.from() starts at the sentence containing the selection,
not the next one (submodule bump).
- Selecting a word and starting TTS now clears the selection.
- Dev-only [TTS] word-sync trace (stripped from production builds).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(sync): design spec for syncing reading status (#4634)
Field-level LWW for reading_status (dedicated reading_status_updated_at),
a new 'abandoned' status in the Readest UI, and a koplugin bridge to
KOReader's native summary.status (whole-library apply + capture).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(sync): implementation plan for syncing reading status (#4634)
Bite-sized TDD tasks across 3 parts: A) cloud field-level LWW
(reading_status_updated_at on server upsert + client pull-merge),
B) 'abandoned'/On-hold status in the Readest UI, C) koplugin bridge
to KOReader summary.status (mapping + reconcile + whole-library
apply/capture).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): add reading_status_updated_at for field-level status LWW (#4634)
* feat(sync): stamp readingStatusUpdatedAt on status change in updateBookProgress
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): stamp status timestamp on explicit library status edits
* feat(sync): resolve reading status by its own timestamp in client pull-merge
* feat(sync): resolve reading status by its own timestamp in server upsert (#4634)
* fix(sync): tighten reading-status merge typing + strengthen test (A5 review)
Replace as-unknown-as double-casts at read sites with typed locals
(clientBook/serverBook); retain a single as-unknown-as only at the
server-wins construction site where the static type is too narrow.
Strengthen test 3 to assert both fields with toEqual.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(library): render the 'On hold' (abandoned) status badge
* feat(library): add 'Mark as On hold' actions + i18n for abandoned status
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* i18n: translate 'On hold' and 'Mark as On hold' for the abandoned status (#4634)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(koplugin): add reading-status mapping + reconcile between Readest and KOReader
* feat(koplugin): persist + sync reading_status_updated_at in LibraryStore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(koplugin): bridge reading status to KOReader summary.status on library sync (#4634)
* test(sync): cover koplugin v1->v2 migration + tighten status-sync tests (final review)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(sync): redesign KOReader first-sync (decisive-only + bootstrap) (#4634)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(koplugin): safe first-sync of reading status (decisive-only + bootstrap) (#4634)
KOReader auto-sets summary.status='reading' on first open, and legacy Readest
statuses have reading_status_updated_at=0, so pure timestamp LWW let opening a
finished book downgrade it. Restrict sync to deliberate statuses (finished/
complete, abandoned/on-hold, unread->clear); never capture KO 'reading'/'New'.
On the unsynced baseline (Readest ts=0) conflicts resolve Readest-authoritative,
then stamp now_ms to exit bootstrap into steady-state LWW. reconcile now returns
write_ko/write_store flags; statussync captures now_ms once and equalizes both
sides (convergent, idempotent, resumable).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(koplugin): cover remaining first-sync graph cells + document sort effect (review)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the informational Data pack hints ("Open a book…" / "No data available…")
from the inline trailing slot into the row description so they wrap under the
label instead of stretching the row wide on mobile. Drop the size from the
Download button label and surface it as the row description (matching the
"Downloaded · size" state). Add a "CEFR level" description under the Level row.
Includes translations for the three new i18n keys across all locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(library): count only uploaded, non-deleted books in synced toast
The "N book(s) synced" toast shown on pull-to-refresh and the
last-synced menu counted every non-deleted book record a pull
returned, including books indexed in the cloud as metadata only
whose file blob was never uploaded. Those books are never added to
the library (updateLibrary requires uploadedAt && !deletedAt), so
the toast over-reported.
Extract the count into a pure countSyncedRecords(type, records)
helper that excludes deleted records and, for books only, requires
uploaded_at — matching what actually lands in the library.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(memory): record agent notes for recent fixes
Persisted project-memory docs accumulated alongside recently merged
work (biometric app-lock, download-file scope regression, inline-block
column overflow, iOS instant-dict double popup, RSVP RTL words, web
security advisories) plus MEMORY.md/share-feature index updates.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Series sort comparator compared only seriesIndex, ignoring the series
name. When used as the within-group order for "group by Author → sort by
Series", this ranked every book #1 across all series as a block, then every
#2, etc., scattering each series instead of keeping it consecutive.
Compare series name first, then index, so all books of one series appear
together in series order before the next series begins.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#4639 added a strict `app.fs_scope().is_allowed()` check to download_file/
upload_file. On Android that returns false for the app's own storage, so every
download into the app dir (book covers, dictionaries, books, gloss packs, OPDS
books in the cache dir) failed with "permission denied: path not in filesystem
scope".
Root cause: download_file/upload_file are plain app commands using raw tokio::fs,
so Tauri does not scope file_path. The capability scope patterns that cover the
app's storage ($APPDATA/Readest/**, $APPCACHE/**, **/Readest/**/*) are
command-scoped and absent from the global fs_scope() FsExt exposes (it is
initialized FsScope::default() and only ever gains runtime dialog/persisted-scope
grants), so is_allowed() returns false for the app's own files.
Interim fix mirroring dir_scanner::read_dir: keep rejecting relative and `..`
paths, then accept the path if the fs scope allows it (persisted dialog grants
for custom/external roots) OR it lives inside the app's own storage — matched by
the `Readest` data folder or the app's bundle identifier (app.config().
identifier), which the Android sandbox (/data/user/0/<id>/…, cache dir included)
and the desktop identifier dirs always carry. The `..` rejection keeps the
GHSA-55vr-pvq5-6fmg hardening: foreign targets like ~/.ssh/id_rsa carry neither
segment and stay blocked.
Follow-up (tracked separately): replace the substring fallback with a
BaseDirectory + relative path resolved via app.path(), so targets are in-scope by
construction with no string markers.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FB2 stores series info as `<sequence name="…" number="…"/>` inside
`<title-info>`, but the foliate-js FB2 parser never read it, so
`belongsTo.series` was always empty and the series name/index never
surfaced in the library or book details. Refresh-metadata and
re-import didn't help since they share the same parser path.
Bumps the foliate-js submodule to pull in the `<sequence>` parsing
(readest/foliate-js#28) and adds a regression test + fixture.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The RSVP word window split each word into before/orp/after spans at the
ORP index and laid them out left-to-right. Slicing an Arabic/Hebrew word
by character index breaks letter shaping (letters stop connecting, some
slices render as notdef boxes) and the LTR layout reverses the visual
order, so e.g. علم showed as disconnected, out-of-order letters.
Detect RTL text and render the word as a single centered span — reusing
the existing CJK Highlight Word path — with dir="rtl" so the browser
shapes and orders it correctly, matching the context panel. ORP anchoring
is meaningless for unsplittable shaped scripts, so RTL always renders
whole; no new toggle.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A highlight and its note are now a single BookNote. Adding a note attaches
it to the highlight at that CFI (or creates one with the current global
style) instead of creating a second record, and a unified record renders as
both a highlight overlay and a note bubble.
- onDrawAnnotation chooses the draw kind from the overlay value prefix
(cfi -> highlight, NOTE_PREFIX -> bubble) instead of annotation.note, so a
record with both a style and a note draws both. Fixes notes synced from
KOReader losing their highlight (#4511).
- handleSaveNote updates the existing annotation at the CFI rather than
pushing a new record (#3870); re-styling preserves the note.
- unifyAnnotations migration (book config schema v1 -> v2, run in
deserializeConfig) collapses existing split highlight+note records into one
survivor and tombstones the redundant record (deletedAt) so the merge syncs
to the cloud and KOReader.
- Sidebar: a note's quoted highlight text uses the theme foreground so it
stays legible on the highlight background.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On iOS a single long-press emits several selectionchange events, so the
instant quick action fired the system dictionary 2-3 times, stacking
UIReferenceLibraryViewController sheets. Add a once-per-gesture latch to
deferredAction (re-armed by beginGesture on touchstart/pointerdown) so the
action runs at most once per gesture, mirroring the Android
defer-to-touchend coalescing.
Also fix two related cases:
- Tapping outside to deselect after dismissing the dictionary occasionally
re-opened it (~1/3): the deselect tap re-armed the latch and a racy
lingering selectionchange re-fired. Gate the instant action on a
long-press hold (isLongPressHold, 300ms, touch only) so a quick tap can't
trigger it.
- A Word Lens gloss tap ignored the system-dictionary setting (always
opened the in-app popup); route it through handleDictionary so it honors
the system dictionary like the toolbar and instant-quick-action paths.
Verified on Android (Xiaomi) that the instant dictionary still fires once
per long-press and re-arms for the next gesture; iOS double-popup and
tap-to-deselect re-fire confirmed fixed.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The next-section accessibility skip link is injected nested inside each
section's last content element (findSectionEndHost in a11y.ts, added for
#4126). The paragraph-layout rule in getParagraphLayoutStyles() targets
`div:not(:has(*:not(b,a,em,i,strong,u,span)))`, so nesting a <div> made the
enclosing paragraph fail the :has() test and silently lose its line-spacing,
word/letter-spacing, text-indent, and hyphenation overrides — but only for the
last paragraph of every section, and only in <div>-based EPUBs (common in
Chinese-source books). <p>-based books were unaffected because the bare `p`
clause matches regardless of children.
Create the next-section skip link as a <span> instead. <span> is in the
selector's allow-list, so the enclosing paragraph keeps matching. The link is
still position:absolute (an out-of-flow 1x1px box) and focusable, so layout and
NVDA focus behavior are unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): paginate inline-block-wrapped chapters instead of clipping them (readest/foliate-js#27)
Some EPUBs wrap a large chunk of chapter content in a div the stylesheet
declares as `display: inline-block`. Atomic inline-level boxes can't fragment
across CSS columns, so in paginated mode the tall box overflows the page
vertically and every column past the first is clipped — the chapter jumps
straight to its "Reference materials", silently skipping a large middle
section, while the counter reads "1 page left in chapter".
Bumps the foliate-js submodule with #demoteUnfragmentableBoxes (demotes
over-tall atomic-inline boxes to their fragmentable block equivalents in
column mode) and adds a browser test + repro EPUB fixture.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(deps): bump foliate-js submodule to merged main (#27)
Re-point packages/foliate-js from the PR-branch commit to the squash-merged
main SHA now that readest/foliate-js#27 has landed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Some EPUBs ship an OPF that isn't well-formed XML — a bare, unescaped
`&` in a hand-built manifest id, e.g.:
<item id="Chapter_1213_Search_&_Rescue_153" .../>
A strict XML parser rejects it (`EntityRef: expecting ';'`) and the book
fails to import on every platform: the web/desktop/Android reader-open
path (foliate `EPUB.#loadXML`) and the Android/desktop native-import
bridge (`parseEpubMetadataFromXML`, which parsed unsanitized).
Bump foliate-js to escape any `&` that doesn't begin a valid character
or entity reference, applied at both parse sites (readest/foliate-js#26).
Valid and numeric references are preserved.
Verified end-to-end on the real "Shadow Slave - Vol. 6" EPUB: imports in
the web app (Chrome) and on a physical Xiaomi device via the native path,
both of which previously failed. New unit test covers both
`parseEpubMetadataFromXML` cases.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`download_file` and `upload_file` passed a webview-supplied `file_path`
straight to `File::create`/`File::open` with no validation, so any JS in the
privileged Tauri origin could write or read arbitrary local paths (e.g.
~/.ssh/id_rsa, shell rc files, autostart entries). (GHSA-55vr-pvq5-6fmg)
Validate the path before any file open/create: reject relative paths and `..`
traversal, then require it to be inside the app's filesystem scope
(`fs_scope().is_allowed`), the same mechanism `dir_scanner::read_dir` uses.
Legitimate destinations stay covered — the static capability globs ($APPDATA
/Readest, $APPCACHE, $TEMP) plus persisted dialog grants for custom roots and
external library folders.
AppHandle is injected by Tauri, so the JS invoke surface is unchanged. Adds a
unit test for the traversal/relative-path rejection.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Server-side hardening for three reported web advisories:
- OPDS proxy (/api/opds/proxy): add http(s) scheme allowlist, internal/loopback/
link-local host blocklist, and manual per-hop redirect re-validation so a
public URL can't redirect into an internal address. Move isBlockedHost into
the shared src/utils/network.ts as the canonical blocklist and reimplement
isLanAddress to delegate to it (also tightens the /api/kosync LAN check);
fetch-url.ts re-exports it. (GHSA-c7mm-g2j2-98cx, GHSA-5g3f-mq2c-j65v)
- Storage upload (/api/storage/upload): validate the client-supplied fileName
with a new isSafeObjectKeyName helper before building the object key, so a
name can't escape the caller's own prefix. (GHSA-mfmj-2frf-vhgw)
- Stripe (/api/stripe/check): bind the entitlement to the session owner —
reject a Checkout Session whose metadata.userId differs from the authenticated
caller. (GHSA-pv88-3727-j7v8)
Unit tests added for each path; full suite + lint green. The Tauri-native
advisory (GHSA-55vr-pvq5-6fmg) is handled in a separate change.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three issues found while debugging shared-book links:
- /s cover was a broken <img>: the page runs under COEP: require-corp (for
Turso SharedArrayBuffer), and the cover redirects to a cross-origin R2
presigned URL that can't carry a Cross-Origin-Resource-Policy header, so the
browser blocked it. R2 already has CORS, but that's a different header — a
plain no-cors <img> needs CORP, which presigned URLs can't set. Serve /s with
COEP: credentialless, which keeps the page cross-origin isolated (the Turso
replica still boots there) while allowing the image. Scoped to /s; every other
route keeps require-corp.
- Android share links (https://web.readest.com/s/{token}) were run through the
article clipper: useClipUrlIngress excluded annotation links but not share
links, so they fell through to clip_url/readability. Skip parseShareDeepLink
URLs — useOpenShareLink owns that path.
- In-app book import failed with "Origin null is not allowed": the importer
fetched /share/{token}/download with the renderer's fetch, and on the app
(tauri.localhost -> web -> R2) the second cross-origin redirect nulls the
request Origin, which R2's CORS rejects. Use the native HTTP client
(tauriFetch) on the app — it follows the redirect and ignores CORS, needs no
server change, and works against the deployed server. Web is unaffected: its
fetch's redirect to R2 is the first cross-origin hop, so the Origin is
preserved and R2 allows it.
Adds unit tests (middleware COEP per route, clipper skips share links, download
route 302, importer uses native HTTP on app and the renderer fetch on web).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(wordlens): rename ww-gloss CSS class to wl-gloss
Completes the Word Wise → Word Lens rename (#4633) for the gloss ruby class —
the 'ww' shorthand was missed. Renamed consistently across the apply site
(GLOSS_CLASS), the CSS rules in style.ts, the tap hit-test in
iframeEventHandlers, and the browser tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(wordlens): trim hints to first sense + suppress known derivations
Runtime, best-effort gloss-quality pass over the shipped en-zh pack (no
regeneration):
- cleanGloss: strip leading POS tags (now incl. 6-letter `interj.`) and keep
only the first sense, so hints stay short — "Ahem" shows 呃哼, not
"interj. 呃哼"; multi-sense entries collapse to their first sense.
- Derivational reduction (English source only): a would-be-glossed word inherits
a known base form's lower rank when the base exists in the pack AND their
glosses share meaning, so lazily/shyly/sorrowful/downwards/inwards stop being
hinted once lazy/shy/sorrow/… are known. Drifted forms keep their own rank
because their gloss doesn't overlap the base (hardly≠hard, lately≠late).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(wordlens): note hint-quality layer + wl-gloss in agent memory
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"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>
Deleting a highlight or bookmark in the koplugin never reached the
server. SyncAnnotations:push builds its payload from getAnnotations,
which walks only the live ui.annotation.annotations list, so a deleted
note can never appear in a push — the server kept the row and the next
pull resurrected it. The pull direction (server deletions → koplugin)
was already handled by removeDeletedAnnotations (#4119); this is the
missing push direction.
Capture a tombstone at deletion time instead. onAnnotationsModified
detects a removal (negative index_modified, with the deleted item at
items[1]) and records a deletedAt-stamped descriptor in the per-book
sidecar (readest_sync.deleted_notes). push folds those tombstones into
the payload and clears them only once the server accepts them, so a
failed push retries. Extracted buildNoteDescriptor so the deletion path
derives a note's id identically to the push walk.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fulltext search showed only the matched word with no surrounding context
when the match fell inside inline-styled text (e.g. <i>/<em>). The root
cause and fix live in the foliate-js submodule's makeExcerpt
(readest/foliate-js#25); bump the submodule pointer to pick it up and add
a regression test covering both the simpleSearch and segmenterSearch paths.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inside a Series/Author library folder, the back arrow was a no-op after a
cold start. `GroupHeader.handleBack` deleted the `group` query param, leaving
an empty search string; `router.replace('/library')` with an empty search
silently no-ops under the Next.js 16.2 static export (every non-web build).
This is the same root cause as #3782, which was fixed for the breadcrumb
"All" button in #3832 — but the series/author back button never got the
workaround.
It only reproduces after a cold start, when `groupBy` comes from settings
(not the URL) and sort/order/view are at defaults, so `group` is the only
query param; that is why it could not be reproduced within a session.
Fix: set `group=''` instead of deleting it (mirroring
`handleLibraryNavigation`). The resulting `/library?group=` commits, and the
existing cleanup effect in page.tsx strips the trailing empty `group=`.
Verified on-device (Android, WebView 148, static export): tapping back inside
an author folder now returns to the main list.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backports the Android ANR/stability fixes from the julianshen fork,
adapted to upstream — notably preserving the cold-start shared-intent
replay queue and the #4559 dictionary-dispatch logic, neither of which
the fork kept.
- NativeBridgePlugin: run blocking @Command I/O (copy_uri_to_path,
install_package, get_sys_fonts_list, and show_lookup_popover's
queryIntentActivities) on Dispatchers.IO via a Main-dispatched
pluginScope; startActivity hops back to Main; resolves are
isActive-guarded; onDestroy cancels the scope and clears the static
instance; the system-font scan is cached (@Volatile). The cold-start
shared-intent queue (emitOrQueue / registerListener) is left intact.
- MediaPlaybackService: unmarshal the artwork Bitmap off the main thread
(serviceScope + Dispatchers.Default), isActive-guarded; cancel the
scope in onDestroy.
- ClipUrlController: hold the Activity via WeakReference and check
isFinishing/isDestroyed before presenting, to avoid leaking the
Activity/WebView during the up-to-30s clip window.
- MainActivity (#3297): on Android 14+ the window can gain focus before
the WebView paints its first frame, leaving a blank screen. Force one
repaint when both the window has focus and the WebView exists
(whichever happens last).
Kotlin-only; not exercised by the JS/Rust test suites. Verified via
ktlint parse + a release `tauri android build` and on-device smoke test
(Xiaomi). The touch-event throttle and intent-handling rewrite from the
fork are intentionally NOT backported (they dropped touchmove forwarding
and the cold-start queue).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opening a book via Android "Open with" on a cold start could clear the
entire library. `openTransient` built an ephemeral entry on top of the
not-yet-loaded (empty) store; the library page's `length > 0` cached-skip
then skipped loading the real library from disk; and a later
`saveLibraryBooks` persisted the empty/partial set — overwriting both
library.json and its .bak. Introduced by #4407 (transient "Open with"),
made reliably reproducible by #4527 (reliable cold-start delivery) — so
released v0.11.4, which lacks #4527, does not reproduce it.
Two layers of defense:
- saveLibraryBooks now merges with the on-disk library (union by hash,
incoming wins), so a routine save is monotonic: it can add or modify
rows (including `deletedAt` tombstones) but can never drop a book.
Deliberate, authoritative rewrites (restore, tombstone GC, account
reset) opt in via the new `{ replace: true }`. This layer alone makes
the wipe impossible.
- openTransient loads the real library from disk before importing a
transient book — also fixing the cold-start hash-match miss that
re-imported already-imported books — and the library page's load-skip
now gates on the store's `libraryLoaded` flag instead of `length > 0`.
Tests cover the merge floor (no-drop / no-wipe-on-empty / tombstone
preserved / incoming-wins) and both `{ replace: true }` paths.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolves#4615. Re-importing updated serials left the app-generated
Books/<hash>/ folder (config.json reading progress/notes, nav.json, cover)
on disk after a normal delete, forcing a manual cleanup. "Purge Data" now
does a Cloud & Device delete AND wipes the whole directory in one action.
The book detail action row is redesigned to Edit · Download · Upload ·
Delete · More (hamburger):
- Goodreads, Share, and Export move into the hamburger "More" menu.
- Share is enabled only when signed in and the local file exists; Export
is enabled when the local file exists (kept on every platform since the
bottom-bar Send is mobile/macOS-only).
- Purge Data is the red entry in the Delete menu, behind a strong confirm.
Implementation:
- DeleteAction gains 'purge'; cloudService.deleteBook('purge') removes the
in-place source file and removeDir's the whole Books/<hash>/ folder,
clearing downloadedAt and leaving the tombstone + queued cloud delete to
the page (mirrors 'both'/'local').
- The library page wires handleBookDelete('purge'); BookDetailModal adds
the purge confirm config + share/export handlers and gates Share on auth.
Tests: cloud-service purge cases, BookDetailView More-menu + Purge tests.
i18n: 9 new keys translated across all 33 locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Docker production-stage opts into Next.js `output: 'standalone'` via a
BUILD_STANDALONE env flag, so it ships only the traced runtime (server.js +
hoisted node_modules + static/public) and runs `node server.js` instead of
pnpm over the full source tree. The flag — and `outputFileTracingRoot`,
which traces from the monorepo root so workspace packages are included — is
set only in the Dockerfile build stage. Every other path keeps its original
output: Tauri `export`, local `build-web`, dev, and the Cloudflare/OpenNext
deploy (which forces standalone itself via NEXT_PRIVATE_STANDALONE).
Disable the experimental `turbopackFileSystemCacheForBuild`: a build
interrupted mid-compile leaves a partial cache that the next build
mishandles, fanning out workers until it exhausts host RAM. Remove the
pull-request CI step that cached `.next/cache` for it, now unused.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve a batch of transitive Dependabot alerts on the web lockfile via
pnpm-workspace overrides.
Bumped existing overrides:
- vite >=7.3.5 (#244 high, #245 med; GHSA path within 7.3.x) <- was 7.3.2
- dompurify >=3.4.9 (#249-#255; clears #252 <=3.4.6 by leaving the range)
- protobufjs >=7.6.3 <8 (#233, #247, #248); bounded <8 to stay on the
patched 7.x line (a bare floor let pnpm jump to the 8.x major)
- ws >=8.21.0 (#241 high) <- was pinned 8.20.1
Added overrides:
- form-data >=4.0.6 (#246 high)
- js-yaml >=4.2.0 (#243 med)
- '@babel/core' >=7.29.6 (#242 low)
- '@opentelemetry/core' >=2.8.0 (#256 med)
Not auto-fixable here: #236 @ai-sdk/provider-utils (<=3.0.97, no recorded
fix; the installed 3.0.25 is pinned via a local patchedDependencies patch).
Verified: pnpm test (5685 passed), pnpm lint, pnpm build-web (exit 0).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve transitive Dependabot alerts on the web lockfile.
esbuild >= 0.28.1 (GHSA-gv7w-rqvm-qjhr #239, GHSA-g7r4-m6w7-qqqr #238):
Deno-module RCE via NPM_CONFIG_REGISTRY and Windows dev-server arbitrary
file read. Forced via a pnpm-workspace override (esbuild is a regular dep
of vite, so the override applies cleanly); bounded to <0.29 to stay on the
verified line. vite 7.3.3 declares ^0.27.0, but the 0.28 JS API is
unchanged for vite's usage -- verified by the full test run and web build.
@vitest/browser >= 4.1.8 (GHSA-g8mr-85jm-7xhm #240): Browser Mode CDP
bridge bypasses allowWrite/allowExec, enabling config overwrite -> RCE.
Bumped the vitest devDep family (vitest, @vitest/browser-*, coverage-v8)
from ^4.0.18 to ^4.1.8; resolves to 4.1.9.
Verified: pnpm test (5685 passed), pnpm lint, pnpm build-web (exit 0).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(library): add Clear Pending action to transfer queue
Adds a "Clear Pending" button to the transfer queue panel that removes
only pending (including retry-pending) transfers, leaving in-progress,
completed, failed, and cancelled items intact. Wired through the store,
manager (with queue persistence), and useTransferQueue hook.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* i18n: translate Clear Pending and reading-statistics strings across locales
Adds translations for "Clear Pending" (transfer queue) and the two
reading-statistics sync-category strings ("Reading statistics" and its
description) across all 33 locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hardcover sync previously only ran when the user opened the reader menu
and tapped "Push Progress" / "Push Notes". Add an opt-in Auto Sync toggle
(default OFF) to the Hardcover settings so progress and notes are pushed
automatically while reading.
- useHardcoverSync: silent debounced (10s) auto-push of progress on page
turns and of notes on annotation/excerpt changes, gated on
enabled && autoSync === true; pending pushes flush on the existing
sync-book-progress close event and cancel on unmount. Manual menu
actions are unchanged (still loud).
- HardcoverSettings.autoSync flag (default false); existing connected
users stay manual until they opt in.
- HardcoverForm: new "Auto Sync" toggle row.
Also backfills two untranslated strings surfaced by i18n:extract from the
reading-stats feature (#4606) across all locales, plus the new
"Auto Sync" key.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In the library list view, surface each book's series and series number on
their own line, in addition to the description. Previously series info was
only visible by grouping by series or opening a book's details.
- Add `formatSeries(series, seriesIndex)` helper ("Series #N", trims the
name, omits a zero/NaN/negative index) with unit tests.
- In list mode, render a dedicated single-line "Series #N" line above the
description when the book has series metadata.
- Clamp every list line (incl. title) to one line and tighten the row gap
to `gap-1` so the extra line fits the fixed-height row without clipping.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An OPDS entry with full metadata and a cover image but no acquisition
link — e.g. a Calibre book whose file was removed but kept for tracking
borrowed/loaned titles — was classified by foliate-js as a navigation
item whose href fell back to the cover image link. Tapping it loaded the
image, which is neither XML nor JSON, so the OPDS browser crashed with a
JSON parse error.
- Bump foliate-js to include the getFeed fix that classifies such
metadata-only entries as publications instead of navigation.
- PublicationView: show "No downloadable format available" when an entry
has no acquisition or stream links.
- loadOPDS: defense-in-depth — surface a clear message instead of a raw
JSON.parse SyntaxError when a response is neither XML nor JSON.
- Add tests covering the Calibre no-format entry and a regression guard
that a true navigation entry still classifies as navigation; add the
two new UI strings across all locales.
Closes#4599
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Supersedes #3156. Adds a reading-statistics system whose canonical data model
is KOReader's own (book + page_stat_data), so stats round-trip losslessly
between Readest and KOReader.
- Storage: a cross-platform Turso statistics.db in KOReader's exact schema
(web/Workers, desktop, iOS, Android) — replacing #3156's Node-only
better-sqlite3 + statistics.json.
- Tracking: per-page reading events (time-on-page, idle-capped) flushed on
page-change/idle/hide/close — the KOReader model — not session aggregates.
- Sync: legacy /api/sync extended with a stats type backed by self-contained
Supabase tables (stat_books, stat_pages); union/longer-duration-wins merge
keyed on book_hash. apps/readest.koplugin syncs through the same endpoint.
- Scale & robustness: per-tab singleton connection (avoids OPFS lock
conflicts) + explicit WAL checkpoint; transactional bulk apply; chunked
resumable push; client-driven paged pull with trailing-ms completion;
paginated/scoped server merge.
Verified: 5668 unit tests, 155 koplugin busted tests, biome+tsgo + luacheck
all green; web OPFS DB verified live.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The license, release, and last-commit badges all query GitHub's API
through shields.io's shared instance, which intermittently fails with
"Unable to select next GitHub token from pool" when shields.io's own
token pool is rate-limited. A README author can't supply a token to the
hosted badges.
- License is fixed at AGPL-3.0, so use a static badge that makes no
GitHub API call and can never hit the token-pool error.
- Switch the release badge from the deprecated `github/release`
endpoint (which 301-redirects) to `github/v/release`.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the Sponsors subsection and its lone TestMu AI logo from the
Support section.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): open image gallery & table zoom on single tap
In reflowable EPUBs, a single tap on an image or table now opens the same
viewer a long-press opens, so the image gallery / table zoom is reachable by
both gestures. Fixed-layout books (PDF/comics/manga) keep tap-to-turn, and
long-press is unchanged everywhere.
Reuses the existing iframe-long-press -> handleImagePress/handleTablePress
flow via a new shared detectMediaTarget() helper (also adopted by the
long-press path so the two entry points can't drift). handleClick now takes
an isFixedLayout flag; the tap branch sits after the link/footnote/drag/
long-hold/Word-Wise guards so linked images still follow links and a
long-hold or double-tap won't double-trigger.
Context: #4584 (single taps stop registering after picture zoom on some
WebView builds) - this adds a second, independent way into the viewer rather
than fixing that root cause.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(reader): rename iframe-long-press message to iframe-open-media
The message is now posted for both a long-press (any book) and a single tap on
an image/table (reflowable books), so the long-press-specific name was
misleading. Rename the message type to `iframe-open-media` and the consumer
hook `useLongPressEvent` -> `useOpenMediaEvent`. The long-press detector
(`addLongPressListeners`/`handleLongPress`) keeps its name since it still
detects a long-press specifically.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-importing a folder via the in-place option used to reopen, parse,
and partial-MD5 every file before its byHash entry could short-circuit
the import. Worse, the byHash short-circuit treated every hit as "user
dropped a fresh file matching a known book", so it refreshed
createdAt/updatedAt/downloadedAt and cleared filePath/deletedAt. For a
user re-scanning the same external folder, that quietly rewrote sort
order and wiped soft-delete state. And once the import returned, the
ingest pipeline still ran group / tag / upload work — including a
path-derived empty groupId that silently clobbered manual
GroupingModal assignments on every re-scan.
This change adds an explicit byFilePath fast path at the top of
`ingestFile` so a re-scan returns the existing library entry verbatim,
before any I/O AND before any downstream side effect:
byFilePath hit -> the same on-disk source is being re-scanned in
place; the right answer is no-op. Don't open the
file, don't touch any timestamps, don't re-cover,
don't run the group / tag / upload steps.
byHash hit -> a different source path resolves to a known book
(e.g. the user dropped a copy from elsewhere, or
a soft-deleted book is being revived); the
existing "refresh timestamps + clear deletedAt"
behavior in importBook is correct here and is
left intact.
Implementation:
- BookLookupIndex carries byHash / byMetaKey / byFilePath, with
byFilePath built only from non-deleted books that have an absolute
filePath. normalizeFilePathForIndex is the shared key function so
shouldImportInPlace and the index agree on case-insensitive
filesystems (macOS / iOS / Windows). osPlatform threads through
buildBookLookupIndex and BaseAppService.importBook so the renderer
and the importer compute the same key for the same file.
- ingestService.ingestFile gains a byFilePath fast path before its
importBook call: when `inPlace` was decided positive, the source
is a real on-disk path (not a PSE stream / URL / content URI),
and lookupIndex.byFilePath has a hit, return the existing Book
directly. Returning here — rather than inside importBook — is
deliberate: it skips the downstream group / tag / upload steps so
a re-scan can never silently overwrite a manual GroupingModal
assignment via a path-derived empty groupId.
- ingest-service.test.ts covers both halves: an in-place re-import
short-circuits importBook entirely (no call, existing object
returned, createdAt / updatedAt / groupId / groupName all
untouched); a copy-mode import (no external library folders) with
the same byFilePath entry still goes through importBook so dedup
falls back to byHash. import-metahash.test.ts retains the
BookLookupIndex builder test that deleted and url-backed books
are excluded from byFilePath.
Net effect: re-importing a folder of N already-imported books does
zero file opens, zero parses, zero MD5 passes, and leaves every book's
groupId / createdAt / deletedAt / cover untouched.
donate.readest.com now lists every donation method, so the Support
section links there instead of enumerating GitHub Sponsors, Stripe,
and crypto separately.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Relocate the update and telemetry toggles out of the library settings
menu into the Behavior (Control) panel, where global app settings live:
- New "Update" boxed-list (gated on hasUpdater): Check Updates on Start
+ Nightly Builds.
- New "Privacy" boxed-list: Help improve Readest (telemetry).
- Behavior section order: Update → Security → Privacy.
- Rename "Nightly Builds (Unstable)" → "Nightly Builds" and drop the
"; may be unstable" note (the channel stays off by default).
- Updater dialog now shows the full version name (e.g.
0.11.4-2026061506) instead of a parsed date.
- Extract + translate the new strings (Update, Privacy, Nightly Builds,
Early daily builds) across all 33 locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): Word Wise — inline native-language vocabulary hints
Kindle-style Word Wise: a short native-language gloss renders above difficult words
as you read (always-on ruby), gated by a CEFR vocabulary-level slider (A1–C2);
tapping a glossed word opens the existing dictionary.
- Pipeline: CEFR→frequency-rank difficulty, inflection-aware gloss index, pure
offset-aware planner (EN regex + jieba for CJK).
- Rendering: <ruby cfi-skip>…<rt cfi-inert> injected per occurrence — CFI-transparent
(verified), so highlights/bookmarks/progress are unaffected; kept out of TTS word
offsets and find-in-book.
- Delivery: gloss packs are version-controlled in data/wordwise/, mirrored to R2, and
downloaded on demand into local storage (sha-verified, single-flight) when enabled.
- Settings: a Word Wise sub-page under Settings → Language (enable, level, hint
language, per-pack download/manage, auto-download toggle).
- Build tooling: scripts/build-wordwise-data.mjs (ECDICT / CC-CEDICT+HSK / WikDict +
FrequencyWords, with lemmatization) and scripts/sync-wordwise-r2.mjs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* data(wordwise): bundled gloss packs + manifest + attribution
13 frequency-trimmed gloss packs (en↔中文 + es/fr/de/pt/it/ru↔en, ~19 MB) generated
by build-wordwise-data.mjs from ECDICT (MIT), CC-CEDICT + HSK, and WikDict +
FrequencyWords (CC-BY-SA). Source of truth, mirrored to the CDN via `pnpm wordwise:sync`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The read-along audio toggle + settings gear sat in an `absolute end-0`
cluster overlaid on the centered transport row. After the #3235 read-along
feature grew that cluster from a single gear (~36px) to ~81px (audio +
divider + gear), it covered the right end of the transport on narrow
phones, hiding the audio button behind the "skip forward 15" control.
Lay the playback controls out as a single full-width flex row: the audio
toggle moves to the far left and the settings gear stays far right,
symmetrically flanking the centered play button (justify-between on
mobile, justify-center on md+). Tighten the secondary buttons on mobile
(h-8, px-1.5) and add shrink-0 so the row fits without overlap; the
symmetry keeps the play button centered.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The assemble-manifest job promoted the manifest with single-file
`rclone copyto` + `moveto`. Before a single-file upload rclone issues a
CreateBucket probe (PUT /<bucket>), which the object-scoped RELEASE_R2_*
token can't satisfy -> 403 AccessDenied, so nightly/latest.json was
never published (the build legs and the stable release flow were fine
because they use a directory `rclone copy`, which PUTs the object
directly without that probe).
Mirror the release flow (upload-to-r2.yml): copy a one-file directory
into nightly/. R2 PutObject is atomic, so the .tmp + server-side move
added nothing. Verified against the live bucket with the current token:
directory copy -> 200 OK; single-file copyto -> CreateBucket 403.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dictionaries that store only base headwords (e.g. Oxford Dictionary of
English) miss inflected selections like `ran`, `mice`, `children`, or
`analyses` even though the lemma (`run`, `mouse`, `child`, `analysis`) is
present. Add a language-aware lemmatizer whose base-form candidates are
appended to the existing lookup candidate chain, after the exact/case
variants, so an exact/case match always wins and the lemma is only tried
once those miss.
- New pluggable `lemmatize/` registry keyed by primary language subtag;
add a language by registering one lemmatizer, no caller changes.
- English lemmatizer: irregular-form table (suppletive verbs, irregular
plurals/comparatives) + regular suffix rules (plural/past/gerund/
comparative/possessive). Over-generates on purpose — the dictionary
lookup is the validator, so bogus stems simply miss.
- Unknown/missing book language defaults to English (no-op on non-ASCII);
an explicit non-English language with no registered lemmatizer is a
no-op.
- Applies centrally to all definition providers (mdict/stardict/dict/slob
and the online builtins) via `buildLookupCandidates`.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The nightly Linux legs build with `cargo tauri build` WITHOUT `--target`
(no matrix `args`), so cargo emits bundles under `target/release/bundle/`
(host-target default) rather than `target/<triple>/release/bundle/`. The
macOS/Windows legs DO pass `--target`, so they legitimately get the triple
subdir — but the "collect artifacts" step reused `${rust_target}` for the
Linux AppImage path too, looking under
`target/x86_64-unknown-linux-gnu/release/bundle/appimage/` where nothing
exists. The build succeeded; only the collect step failed with
"missing artifact or signature for linux-x86_64-appimage".
Drop the `${rust_target}` subdir from the Linux AppImage path so it points
at the host-target default location where the bundle actually lands.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>