* feat(reader): add save/share button to image gallery toolbar
Add a button to the top-right toolbar of the fullscreen image viewer
that saves the currently viewed image to the device. It uses the native
or web Share flow where available (iOS/Android/macOS, navigator.share)
and falls back to a save dialog or browser download otherwise, reusing
the existing export path via appService.saveFile.
The button icon and label reflect the active flow (share vs save).
Adds dataUrlToBytes/imageExtensionFromMime helpers, unit and component
tests, and translations for the new strings across all locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(share): write shareable file to a Temp subdirectory to avoid 0-byte share
On Android, Tauri's Temp dir is the app cache dir, and the sharekit plugin
copies the shared file to <cacheDir>/<name> before firing the share intent.
When saveFile wrote the shareable file to the Temp root, that copy became a
copy onto itself whose output stream truncated the source to 0 bytes, so the
shared image (and any shared export) arrived as a 0 KB file. Write the file
to a Temp subdirectory instead so the plugin's copy has a distinct source.
Verified on a Xiaomi device: sharing a file in the Temp root truncated it to
0 bytes, while sharing from the subdirectory produced a real, non-empty copy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): save image to system gallery on Android
The Android share sheet cannot save an image to a file (no file manager
registers as an ACTION_SEND target), so the Save Image button now writes
the image straight into the system photo gallery via MediaStore. It lands
in Pictures/Readest, visible in Gallery and the Files app, with no picker
and no storage permission on Android 10+.
Adds a save_image_to_gallery command to the native-bridge plugin (Rust +
Kotlin MediaStore insert) and an appService.saveImageToGallery method. On
Android the Save button uses it; iOS/macOS/desktop/web keep the existing
share/export flow, and the button label/icon reflect the actual action.
Also includes local agent memory notes that were staged alongside.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump foliate-js so paginated Duokan full-page covers
(data-duokan-page-fullscreen) render with object-fit: fill instead of
contain. The cover now fills the whole page, distorting to fit when the
aspect ratio differs, matching Duokan's native full-page render. Adds a
browser test asserting the fullscreen cover computes object-fit: fill.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sync POST handler rewrote books.updated_at = now() whenever a pushed
book's resolved reading_status differed from the server row's. A book that
was imported locally and never given a status sends reading_status:
undefined, while the server stores null, so `undefined !== null` reported a
spurious status change. The 1-day re-sync window re-pushes every recently
touched book on each sync, so the server stamped those books with a fresh,
batch-identical timestamp every cycle, floating them to the top of the
date-sorted library (and above a book the user had just read).
Normalize nullish reading_status values before comparing so a statusless
book never registers as a status change. Verified on-device via CDP:
PUSH_SENT carried the old timestamp while PUSH_RETURNED came back with a
fresh now() for exactly the statusless books.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump foliate-js to the textureAwareBackground fix and add a regression
test. A cover page that paints its image via a body background-image
leaves background-color transparent, so the computed background shorthand
starts with "rgba(0, 0, 0, 0)" even though a real image follows. The
paginator misclassified it as transparent and, with a background texture
active (e.g. parchment), dropped the page background so the texture showed
on the first page instead of the cover. Verified on Android WebView.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The saved catalog cards used hover:bg-base-200/40. Since base-200 is only
~5% off base-100, applying it at 40% alpha shifted the background by roughly
2%, and with the dialog itself sitting at base-200 the hover collapsed into
the dialog color, making the hovered card blend in.
Use hover:bg-base-300 (~12% off base-100) so the hover state is clearly
separated from both the resting card (base-100) and the dialog (base-200).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(rsvp): restore in-flow control bar layout reverted by #4589
PR #4585 fixed the mobile RSVP control bar overlap by laying the audio
toggle and settings gear in a single in-flow flex row flanking the
centered transport. PR #4589 branched from main about five minutes
before #4585 merged and merged about ten hours later without rebasing,
so its squash carried the stale pre-#4585 file and reverted the entire
fix, including the regression test #4585 had added.
On narrow phones (360px) the audio and settings icons again overlapped
the right end of the transport, hiding the "skip forward 15" control.
Restore the #4585 layout and re-add a structural guard test asserting
the audio toggle and settings share the transport row and live in no
absolutely positioned cluster. Verified on a Xiaomi 13 (360px) via
on-device CDP: no overlap, play button stays centered.
Also stage the project-memory note for this regression.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(rsvp): hide Faster/Slower buttons at 350px or below
On very narrow phones (width 350px or less) the control row has no room
for every control. Collapse the Faster/Slower speed buttons via a
max-[350px]:hidden variant (matching the existing 350px tightening tier)
so the transport, audio toggle and settings never overflow. Speed stays
adjustable from the WPM dropdown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Large PDFs (50 MB+) crashed on import/open. pdf.js requests hundreds of
byte ranges in a burst while parsing the document structure, and
foliate-js makePDF dispatched them all concurrently. On Android each read
is served through the WebView's rangefile custom scheme
(shouldInterceptRequest); the flood of simultaneous native requests
exhausts the WebView's Java heap (OutOfMemoryError in handleRequest).
Bump foliate-js to cap in-flight range reads at 6 (the implicit per-host
limit a real HTTP transport already gets), and add a regression test
asserting makePDF keeps at most 6 range reads in flight.
Verified live on a Xiaomi 13 (Android 16 / WebView 147) via CDP: max
concurrent range reads drop from 753 to 6 with no change in open time.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Add OPDS Catalog" dialog (a ModalPortal opened from inside
Settings > Integrations > OPDS Catalogs) rendered behind the Settings
sheet on mobile, so the form could not be reached or filled in.
Root cause: PR #3235 raised the Settings dialog to z-[10050] to clear
the full-screen RSVP overlay (z-[10000]) for in-overlay dictionary
management. That also jumped Settings above the ModalPortal layer
(z-[100]), so any modal opened from inside Settings was buried. The bug
is mobile-only because on desktop the rounded-window frame
(.window-border, z-99) traps the inline-rendered Settings dialog in its
own stacking context, while ModalPortal escapes to document.body and
wins there.
Redesign the overlay z-index into a compact scale (no four-digit
values), each layer clearing the z-99 page frame:
100 RSVP overlay
101 RSVP controls (start dialog, lookup chip)
110 Settings dialog
120 modal / command palette
130 toast / alert
200 app lock
Lock the ordering with a static test that reads the values from source
and would have caught the #3235 regression. Documented in DESIGN.md.
Verified on a Xiaomi device via CDP: elementFromPoint at the dialog
center now resolves inside the Add Catalog form instead of Settings.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(koplugin): repair reading-stats sync push/pull
Reading statistics (statistics.sqlite3 page events) never reached the
Readest sync server. Three stacked defects in the koplugin stats path:
1. push/pull called settings:readSetting/saveSetting on self.settings,
which is the plain readest_sync data table (not a LuaSettings object),
so auto-sync crashed on every book open
("attempt to call method 'readSetting' (a nil value)").
2. pushChanges declares books/notes/configs as required_params, but the
stats push sent only statBooks/statPages, so Spore rejected the request
client-side ("books is required for method pushChanges").
3. statBooks/statPages were listed only under the spec's payload, not as
optional_params. Spore's expected-param set is
required_params + optional_params, so it rejected them too
("statBooks is not expected for method pushChanges").
Fixes:
- Read/persist the cursor as a plain field and save the whole table via
G_reader_settings:saveSetting, mirroring readest_syncauth.
- Send empty books/notes/configs alongside statBooks/statPages; the server
defaults each to [] and processes the stat arrays independently.
- Declare statBooks/statPages as optional_params in readest-sync-api.json.
Also add ReadestStats debug logging across pushBookStats/pullBookStats and
push/pull (cursor, collected counts, dispatch, response status, cursor
advance), and surface the push failure status/body that was previously
swallowed on non-interactive syncs.
Tests: cover push/pull cursor handling, the pushChanges required_params
contract, and a spec-level check that every stats payload key is an
expected pushChanges param.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(koplugin): add interactive Push/Pull stats now menu items
Add "Push stats now" and "Pull stats now" entries to the Readest sync
menu, placed after "Readest library" and before "Push books now" and
gated on being signed in (access_token + user_id) like the other
account-level items. They call pushBookStats(true) / pullBookStats(true)
for a manual interactive sync of reading statistics (the whole
statistics.sqlite3 delta), complementing the automatic pull-on-open /
push-on-close.
Interactive push/pull now also confirm their result (pushed / pulled /
up to date) to match the sibling "now" items, since a silent success
would look like a no-op.
Extract the five new strings into the koplugin .po catalogs (empty
msgstr, English fallback at runtime).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(koplugin): translate reading-stats sync strings (33 locales)
Fill the previously empty msgstr entries for the reading-statistics sync
strings across all koplugin locale catalogs: the two new "Push/Pull stats
now" menu items, the pushed / pulled / up-to-date confirmations, and the
push/pull failure messages. Each locale mirrors its existing
Push/Pull/Failed-to verbs and "reading" terminology for consistency.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds store/ with a reproducible generator for the Web Store listing
screenshots: composites the popup capture onto an on-brand (readest.com)
background with a headline, renders via headless Chromium, and writes 24-bit
no-alpha PNGs at 1280x800 and 640x400 into store/out/ (gitignored).
- store/generate.mjs - config + compositing + render (Playwright + ImageMagick)
- store/popup.png - raw popup capture (700x508); its status strip is blanked
and re-lettered from CONFIG so it stays in sync with code
- store/README.md - usage + requirements
- package.json - adds `pnpm store:screenshots`
- .gitignore - ignores store/out/
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the user-facing popup strings em-dash-free and tighten the success copy
(which now matches the Chrome Web Store screenshots).
Source strings:
- "Sent — it will appear in your library shortly." -> "Saved to your library."
- "Sent — N images could not be fetched." -> "Sent. N images could not be fetched."
- "Could not reach {host} — {reason}" -> "Could not reach {host}: {reason}"
Key-as-content i18n means each English change re-keys the string. All 33 locale
bundles were re-keyed and every affected translation refreshed to match the new
wording, with no em dashes in the translated values either (language-appropriate
punctuation; full-width colon/period for CJK). Extractor reports 0 untranslated,
0 orphan. Updated popup.test.ts and the i18n.ts JSDoc example.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>