First-run users opening Readest with no books now see a typographic
hero instead of the previous generic "Welcome to your library" hero.
Key UX changes:
- 64px PiBooks glyph at base-content/60 anchors a single-column
composition (max-w-md container, max-w-xs button stack)
- Headline "Start your library" — action-led, not "Welcome to X"
- Platform-aware description:
desktop: "Drop a book anywhere on this window, or pick one from
your computer."
mobile : "Pick a book from your device to add it to your library."
Branched on appService.isMobile so the touch-only flows don't see
drag-and-drop language.
- Auth-aware secondary action: a quiet underlined "Sign in to sync
your library" text link renders only when logged out; signed-in
users get just the Import CTA (sync runs automatically).
- Primary CTA "Import Books" unchanged; routes to existing file
picker. The surrounding hero drop-zone wrapper is preserved so
drag-and-drop import keeps working on desktop.
- TODO marker for a future "Browse free catalogs" entry above the
secondary action slot.
Implementation:
- Extracted as src/app/library/components/LibraryEmptyState.tsx
(~60 lines, single onImport prop) so the empty branch can be
unit-tested without mounting the full LibraryPageContent.
- src/app/library/page.tsx swaps ~17 lines of inline hero JSX for
one <LibraryEmptyState onImport={handleImportBooksFromFiles} />.
- Four unit tests cover desktop render, mobile render, auth-aware
sync-button hide, and import-click callback.
i18n: four new strings translated across 33 locales; en/translation.json
untouched per the project convention (non-plural strings live in
code). Stale "Welcome to your library..." key removed by the scanner.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Popup: hide the inner triangle when its anchor point lands inside
the popup body. Extracted as a generic `isPointInRect` helper in
`sel.ts` (with a default 1px padding so edge cases stay visible).
- style.ts: handle `<p[width][height]><img></p>` (common in some
MOBI conversions) — clear hardcoded width/height and apply
multiply blend for dark themes so the image doesn't sit on a
colored box.
- Annotator: shrink dict popup height from 480 to 360 to fit
smaller screens.
- foliate-js: submodule bump.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- AppLockScreen: pad the lock screen bottom by the on-screen
keyboard height tracked via visualViewport, so the flex-centered
PIN sits above the keyboard on iOS WKWebView where dvh does not
shrink.
- AppLockScreen: skip stickyFocus on mobile. iOS will not pop the
keyboard from a programmatic .focus(), so the cursor would blink
with no input — wait for the user's tap instead.
- PinInput: forward autoFocus to the input when autoFocus or
stickyFocus is set, for more reliable mount-time focus.
- style.ts: give legacy <p><font>...</font></p> its own block
context so iOS Safari applies the inherited line-height.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ui/ux: codify design system and migrate settings to shared primitives
Document Readest's design language in DESIGN.md (Adwaita-aligned, e-ink-first,
RTL-correct) and migrate every settings panel onto a small set of primitives
(BoxedList, SettingsRow, SettingsSwitchRow, SettingsSelect, SettingsInput,
NavigationRow, Tips, SubPageHeader). AGENTS.md links to DESIGN.md so contributors
land there before inventing new chassis classes.
Replace the standalone KOReader/Readwise/Hardcover Config dialogs with a single
Integrations panel (Reading Sync + Content Sources sub-pages). The reader's
BookMenu now hides each provider until it's configured, and Hardcover's per-book
"Enable for This Book" toggle is dropped — there's no auto-sync to gate, so the
flag was just extra clicks.
Refresh highlight colors (two-trigger swatch + label, translatable default
names), background texture / theme color selectors (border-current keeps
selection legible on any backdrop), CustomFonts/CustomDictionaries (quiet
list-extension style + shared Tips primitive), the OPDS catalog manager
(debounced auto-download, right-aligned Browse), Set PIN, and the KOSync
conflict resolver. Translate the ~30 new strings across all 33 locales.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ui/ux: responsive typography, OPDS card polish, deep-link return paths
Restore the .settings-content responsive cascade (14px desktop / 16px
mobile) the legacy panels relied on by dropping hardcoded `text-sm`/`text-xs`
from the new primitives. Secondary text moves to em-relative `text-[0.85em]`
so it scales with the parent. Form controls (`<input>`, `<select>`) re-apply
the cascade explicitly via the `settings-content` class since browsers don't
inherit font-size onto form elements.
Extract `<SectionTitle>` primitive (caseless-language aware via
`isCaselessUILang`/`isCaselessLang`) and route every uppercase tag-style
header through it: BoxedList groups, Reading Sync, Content Sources, Theme
Color, Background Image, integration form labels, KOSyncResolver device
labels, and the OPDS My Catalogs / Popular Catalogs sections. CJK / Arabic
/ Hebrew / Indic / Thai / Tibetan locales bump to `1em` since `uppercase`
is a no-op on those scripts.
Redesign the OPDS My Catalogs cards: whole card becomes the browse trigger
(role='button'), edit/delete collapse into a 3-dot dropdown menu, and the
sync-status moves to a sub-line under Auto-download so the card height stays
constant whether the toggle is on/off or sync data has arrived.
Plumb a `from=settings-integrations` URL marker through the OPDS browser so
both manual close and auto-close-on-failure (preserved as `router.back()`
for transient failures, paired with a new `stashOPDSReturnTarget` helper)
return the user to Settings -> Integrations -> OPDS Catalogs sub-page
rather than the dialog's top level. Backed by new `requestedSubPage`
deep-link store field.
Skip the OPDS catalog passphrase prompt when credentials sync is disabled
-- `replicaPublish` already drops encrypted fields at the wire, so prompting
was both pointless and confusing.
Fix `SettingsDialog` calling `setRequestedPanel(null)` inside a `useState`
lazy initializer (zustand setter during render -> React warning); move the
clear into a one-shot `useEffect`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ui/ux: opt Settings into OverlayScrollbars + caseless typography polish
Add an opt-in `useOverlayScroll` prop to `<Dialog>` that swaps the body's
native `overflow-y-auto` for `<OverlayScrollbarsComponent>` (autohide,
click-scroll, no native overlaid bars). SettingsDialog flips it on so the
long Layout / Color panels keep a visible, theme-aware scroll track on
Android / iOS webviews where native scrollbars auto-hide entirely. Other
short-modal callers stay on the native scrollbar.
Drop the `uppercase tracking-wider` SectionTitle styling for caseless
scripts and pair it with body-weight `font-medium` instead — those
typographic effects are no-ops on Han / Hangul / Devanagari / Thai etc.,
so a plain medium-weight body-size title reads more correctly than a
shrunken pseudo-uppercase one. SettingsRow / NavigationRow primary labels
follow the same rule (drop `font-medium` in caseless locales since the
inherited body weight already carries; CJK fonts bold poorly at body
size).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ui/ux: SettingLabel primitive + KOSyncForm select polish + Tips alignment
Add `<SettingLabel>` primitive — caseless-aware row/field label that pairs
with `<SectionTitle>` (groups) for per-item labels. Cased scripts get
`font-medium`; caseless scripts (CJK / Arabic / Hebrew / Indic / Thai /
Tibetan) drop the weight since Han / Hangul / Devanagari etc. bold poorly
at body size. No font-size class so it inherits the `.settings-content`
14/16 cascade. Routed through `SettingsRow`, `NavigationRow`, and the
~12 ad-hoc inline `text-sm font-medium` callsites in AIPanel / FontPanel
/ ColorPanel / IntegrationsPanel / KOSync / Readwise / Hardcover forms.
Refactor KOSyncForm's Sync Strategy + Checksum Method rows onto the
shared `<SettingsSelect>` primitive — the inline 17-line div/select/
MdArrowDropDown chassis becomes a single SettingsSelect call with an
options array. Drops the unused MdArrowDropDown import and ~25 lines.
Fix Tips list-item alignment: callers traditionally pass `<li>` elements
(semantic) but the primitive was double-wrapping into `<li><span><li>...</li></span></li>` — invalid HTML, and the inner `<li>`'s
`display: list-item` broke line-wrap alignment on multi-line items.
Unwrap caller `<li>` to its content; add `flex-1` on the text span so
wrapped lines align under the first line instead of falling back to the
bullet column. Bullet container switches to `h-[1.4em]` so it tracks the
text line-height and pins to the first line's optical center via
`items-center` regardless of how much the content wraps.
DESIGN.md §5 typography updated to point primary-label callers at
`<SettingLabel>`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): add opt-in Credentials toggle to Manage Sync
Adds a new Credentials category (default OFF) that gates the encrypted
fields (OPDS / KOSync / Readwise / Hardcover usernames, passwords, and
tokens) at both the publish and pull pipelines. When off, sensitive
fields never leave the device, the proactive passphrase prompt never
fires, and the Sync passphrase panel is hidden entirely.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* security: bump keyring to version 4
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(applock): show blinking cursor on PIN input
Empty PIN slots used to render nothing, leaving no cue for which
position is active. Add a thin underscore that blinks under the
next-to-fill slot while the input is focused.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(ui): misc settings polish + i18n refresh
- applock screen: switch fixed positioning to full-height so the lock
screen sits inside the safe area
- applock dialog: split the recovery sentence so it reads cleanly
without an em dash
- settings: rename "Interface Language" to "Language"
- translators: drop the "(Unavailable)" suffix from disabled providers;
the row already greys out
- ruler color picker: keep swatches clickable when ruler is off so
users can still set a color before enabling
- refresh translations across all locales for the changed strings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-sync triggers (boot non-settings, focus, visibilitychange, online,
periodic) used to fan out N parallel `GET /api/sync/replicas?kind=…`
requests, one per replica kind. With 5 kinds today and the focus path
firing on every foreground transition, that's 5x the Cloudflare Worker
invocations of what the work actually requires.
Server: extend POST /api/sync/replicas to accept a batched-pull body
(`{ cursors: [{kind, since}, …] }`) alongside the existing push
(`{ rows: […] }`). Per-kind queries fan out via Promise.all — Supabase
calls inside the Worker aren't billed as Cloudflare requests, so DB
load is unchanged while Worker invocations collapse from N to 1.
Client/manager: add `client.pullBatch` and `manager.pullMany` that
share the existing cursor/HLC machinery. The boot path's `since=null`
override carries over via `pullMany(kinds, { since: null })`.
Orchestrator: `triggerIncrementalPullAll` now does ONE pullMany call
then fans out per-kind apply via Promise.allSettled. Boot does the
same for non-settings kinds (settings stays a single call to preserve
its apply-first ordering invariant).
Foreground triggers: listen to BOTH `focus` and `visibilitychange`,
sharing one throttle. focus is fastest on iOS Tauri WKWebView (~T=0,
~400ms ahead of visibilitychange). visibilitychange is the only
signal that fires on browser tab switching — focus does not. Drops
the Supabase user-ref-change listener (was the slowest of the three
foreground signals; redundant with the DOM events).
Bonus: `useBooksSync` now serializes `handleAutoSync` against
`pullLibrary` via the shared `isPullingRef` gate. The two paths used
to fire two concurrent `/api/sync?type=books` requests on the same
`since` value at startup; now whichever runs first claims the gate
and the other skips (throttle's `emitLast` retries afterwards).
Per session: boot 5→2 Worker calls. Per foreground trigger: 5→1.
Adds a `share` flag and `sharePosition` to `saveFile` across the app
services. On iOS/Android/macOS/Windows the annotation export now calls
the sharekit `shareFile` (writing the markdown/txt to `$TEMP` first when
no `filePath` is provided), so users get the system "Share via…" sheet
that drops the export into Mail, Notes, Messages, etc. Linux desktop
keeps the existing save dialog, since sharekit has no Linux backend.
On the web, `saveFile` now prefers `navigator.share({ files })` when the
browser advertises support via `canShare`. AbortError (user dismissed)
is treated as a deliberate "don't share" choice; any other rejection
(e.g., Chrome desktop's `NotAllowedError` despite a positive `canShare`)
falls through to the `<a download>` fallback so a save still happens.
Also fixes the macOS share popover anchoring: `preferredEdge: 'top'`
maps to `NSMaxYEdge`, which is the rect's bottom edge in WKWebView's
flipped coords, so the picker rendered below the trigger button. The
annotations export only got away with it because its dialog has no room
below — macOS auto-flipped above. Switching to `preferredEdge: 'bottom'`
(`NSMinYEdge` → top edge in flipped coords) anchors the popover above
the button consistently. Adds `$TEMP/**/*` to the Tauri fs capabilities
so the writable temp share file is permitted.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bundled `settings` replica's `dictionarySettings.providerOrder`
and `providerEnabled` repeatedly drifted on multi-device setups: a
fresh-install Device B would overwrite Device A's authoritative
order with its own local default, dict tombstones referenced via
the settings replica left "skipped" gaps in the UI, and providerEnabled
keys missing from providerOrder rendered as silently lost imports.
Six related fixes (mostly orthogonal):
- **Disk-priming** in `initSettingsSync(initialSettings)`: seeds
`lastPublishedFields` from the just-loaded disk settings so the
first `setSettings(disk_default)` at boot diffs against the disk
baseline (no diff → no push), instead of diffing every whitelisted
field against `undefined` and clobbering the server with locals.
- **Settings boot pull is awaited first** in `useReplicaPull` (with
a shared `settingsBootPullPromise`) so the dict/font/texture/opds
pulls' auto-saves see server-primed `lastPublishedFields` rather
than disk defaults — implicit even when the caller didn't request
the `settings` kind.
- **Visibility / online / periodic auto-pull** in `useReplicaPull`:
module-level listeners with a 30s visibility throttle and a 5-min
interval keep long-lived foreground tabs in sync (previously the
hook only did the once-per-session boot pull and `ReplicaSyncManager.startAutoSync`'s
comments lied — it only flushed dirty pushes).
- **Tombstone scrubbing for no-local rows**: `softDeleteByContentId`
scrubs `providerOrder` / `providerEnabled` by contentId regardless
of whether a local dict matches, and `applyRow` always invokes it
on tombstones — so Device B fresh-installs that pulled tombstoned
contentIds via the settings replica without ever having a local row
still get the provider-side entries cleaned.
- **Orphan rescue** in `loadCustomDictionaries`: providerEnabled keys
that have no slot in providerOrder (per-field LWW splits a settings
push) get spliced before the first builtin so user-imported dicts
stay contiguous near the top of the list, not stranded after the
builtins where users miss them.
- **`addDictionary` prepends** to `providerOrder` so a fresh local
import shows up at the top of the list. Reviving a soft-deleted
entry preserves its existing slot.
- **Explicit-publish gate for `providerOrder`**: `markExplicitProviderOrderPublish()`
in `replicaSettingsSync` is the only way for `publishSettingsIfChanged`
to ship `dictionarySettings.providerOrder`. UI handlers that
intentionally reorder (drag-drop, dict import, dict delete,
web-search add) opt in via `saveCustomDictionaries(env, { publishOrderChange: true })`.
Auto-mutations from replica pull / orphan-rescue / tombstone-scrub
no longer ever republish the local view of order.
12 new tests across `replicaSettingsSync`, `replicaPullAndApply`,
`useReplicaPull`, and `customDictionaryStore`.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "Resets in {{duration}}" indicator on the user page formatted its
duration via dayjs with literal "[hr]" / "[min]" tokens, which bypassed
i18n entirely. Non-English locales rendered mixed strings like
"18 hr 6 min后重置".
Move the unit literals into the translation key itself
("Resets in {{hours}} hr {{minutes}} min") so each locale controls word
order and unit abbreviations, and translate the new key for all 33
shipped locales.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Some EPUB writers (e.g., "ebookredo") emit a non-standard local file
header signature on the first entry — bytes like PK\x03\x02 instead of
PK\x03\x04. The archive is still readable via the End of Central
Directory record, and @zip.js/zip.js handles it without complaint, but
DocumentLoader.isZip() rejected the file at the magic-bytes gate before
zip.js ever ran. The user saw "Unsupported or corrupted book file" on
a perfectly readable EPUB.
Drop the strict 4th-byte equality check. PK\x03 alone identifies a
local file header — no other ZIP record signature starts that way — so
loosening the check is safe and aligns with what tolerant ZIP parsers
already do.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When importing a `.txt` file the author field stayed empty unless the
text content itself contained an `作者:…` header, even when the filename
already encoded it. Common Chinese naming patterns like `《书名》作者:张三.txt`,
`《书名》[张三].txt`, or `《书名》张三.txt` now contribute the author when
the file body doesn't.
- Added `extractTxtFilenameMetadata` in `utils/txt.ts` and replaced the
ad-hoc `extractBookTitle` regex used by both convertSmallFile and
convertLargeFile. Content-extracted author still wins; the filename
author is the next fallback before the caller-provided one.
- `BookCover` now reads `book.author || book.metadata?.author` so the
author typed into the metadata edit dialog shows on auto-generated
fallback covers when the original `book.author` was empty.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): add per-category sync gates + Manage Sync UI
The user can now enable / disable each sync category independently
in Settings → Data Sync (User page). The map syncs across devices
via the bundled `settings` replica, defaults to enabled so the
preference is opt-out, and applies on both push (`replicaPublish`,
legacy `useSync`) and pull (`useReplicaPull`, `useSync`) without
backfilling on re-enable.
Categories:
- `book` / `progress` / `note` — gate the legacy `SyncClient` paths
- `dictionary` / `font` / `texture` / `opds_catalog` — gate the
replica-sync pulls + publishes for those kinds
- `settings` — togglable, but force-on while `dictionary` is enabled
because the dictionary's `providerOrder` / `providerEnabled` /
`webSearches` live in the bundled settings replica. The UI
shows the locked toggle as blue (enabled) with a hint instead of
greying it out, since the underlying state IS on.
UI:
- New `SyncCategoriesSection` lists every category with a
description and a daisyUI toggle.
- New `Manage Sync` blue action on the User page (second slot,
right after `Manage Subscription`); also surfaces inside the
library `Advanced Settings` menu, deep-linking via
`/user?section=sync`.
- `SyncPassphraseSection` moved into the Manage Sync panel
alongside the categories list. `Unlock now` button removed —
the gate fires automatically on first encrypted push/pull and
the manual unlock affordance was confusing.
Adjacent cleanups:
- `LangPanel` Dictionaries card gets `overflow-hidden` so the
hover highlight clips to the card's rounded corners.
- `FontPanel` gear icon replaced with a `Manage Fonts` row that
matches the `Manage Dictionaries` pattern.
i18n: extracted + translated 31 in-scope locales for the new
strings (`Manage Sync`, `Data Sync`, `Manage Fonts`, plus the
category copy block).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(i18n): translate new sync-categories strings across 30 locales
Adds translations for the four strings extracted after the latest
SyncCategoriesSection iteration:
- `App settings` — toggle label for the bundled-settings sync gate
- `Theme, highlight colours, integrations (KOSync, Readwise,
Hardcover), and dictionary order` — description under that toggle
- `Required while Dictionaries sync is enabled` — hint shown when
the toggle is locked because dictionary sync depends on settings
- `Unavailable` — `(Unavailable)` suffix on disabled translator
providers; was missing from most locales until i18next-scanner
picked it up this run
Product names (KOSync, Readwise, Hardcover) left in Latin script.
`Dictionaries` references in the third string reuse each locale's
existing translation. `pt-BR` and `uz` deliberately untouched (out
of the in-scope set).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(i18n): fill in pt-BR for the new sync-categories strings
`pt-BR` is a registered, shipped locale (Portuguese (Brasil)) that
fell through the gaps in earlier batch runs because it isn't listed
in the i18n skill's locale-reference table. The fallback chain
`pt-BR → pt → en` softened the impact, but BR-specific phrasing
needs its own translations for the 20 new keys this PR added.
`uz` stays excluded — that locale isn't registered anywhere
(missing from i18next-scanner.config.cjs, src/i18n/i18n.ts, and
TRANSLATED_LANGS), so its translation file is dead code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(i18n): translate uz for the new sync-categories strings
`uz` is a registered locale (listed in `i18n-langs.json` and
`TRANSLATED_LANGS` as `'Oʻzbek'`) but earlier batch translation
runs excluded it because the i18n skill's static locale-reference
table was incomplete. Filling in the 20 strings this PR added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(agent): update i18n skill
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`interactive-widget=resizes-content` was set in the SSR viewport
metadata so Android Chrome would shrink the layout viewport when
the on-screen keyboard opens (matching iOS default behavior).
Other browsers — Safari on macOS / iOS, desktop Chrome, Firefox —
log a console warning every page load because they don't recognize
the key.
Move the attachment client-side, gated on a UA sniff for Android,
so the meta tag stays clean for everyone else. The Android-specific
behavior (modals centered above the keyboard) is preserved on the
platform that actually needed it.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): bundle dictionary settings into the `settings` replica kind
Adds three entries to the SETTINGS_WHITELIST so `providerOrder`,
`providerEnabled`, and `webSearches` flow through the bundled
settings replica with whole-field LWW. `defaultProviderId` (last-
used tab) is deliberately excluded — it's per-device state.
The customDictionaryStore exposes `applyRemoteDictionarySettings`
so pulled values propagate into the in-memory mirror that the
reader popup and the dictionary settings panel read from. Without
this the mirror would stay stale until the next panel mount.
Also fixes `saveCustomDictionaries`: it mutated the existing
settings object in place and called `setSettings` with the same
reference, so the replicaSettingsSync subscriber saw no change
and never published. Build a fresh settings reference instead.
Collapses the original PR 6 (`dict_provider_position`) and PR 7
(`dict_web_search`) plans, which proposed per-element CRDT rows
with deterministic actor-id tiebreaks. Whole-field LWW is the
right call given how rarely users edit these on two devices at
once — same precedent as `customHighlightColors` and
`customThemes` shipping through the bundled kind in PR 5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sync): make dict.id stable across devices (= contentId)
Replaces the per-device `Math.random()` bundleDir as `dict.id` with
the cross-device-stable `contentId`. `bundleDir` keeps tracking the
device-local on-disk path, so on-disk layout is unchanged.
Touch points:
- 4 import paths in `dictionaryService.ts` + `buildLocalDictFromRow`
in `replicaDictionaryApply.ts` set `id: contentId` instead of
`id: bundleDir`.
- Every `providerOrder` / `providerEnabled` entry that came from
the dict store is now uniformly contentId-keyed, so the bundled
`settings` replica syncs them across devices without any seam
translation.
Dicts with no contentId (very old, never synced) keep their
bundleDir as id and remain local-only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): add bundled `settings` replica kind for cross-device prefs and credentials
Adds a single-row `settings` replica that syncs a whitelist of
`SystemSettings` fields across devices via per-field LWW (one entry
per dot-namespaced path). Plaintext for theme / highlight colour /
TTS configuration; encrypted (AES-GCM under the user's sync
passphrase) for kosync / Readwise / Hardcover credentials.
Highlights:
- Push-side diff against an in-memory snapshot for plaintext paths
and a localStorage SHA-256 hash for encrypted paths, so a refresh
doesn't re-publish or re-prompt for the passphrase.
- Pull-side cipher-fingerprint dedupe + per-row passphrase gate;
decryption failures surface as toasts (wrong passphrase / orphan
cipher) instead of silent drops.
- Auto-recovery for orphaned ciphers: when a row references a
saltId no longer in `replica_keys`, clear the local hash and
re-encrypt under the current salt on the next save.
- Single in-flight `/sync/replica-keys` fetch with a value cache
to coalesce the boot-time burst of concurrent unlock callers.
* fix(sync): guard settings dot-path helpers against prototype-polluting keys
Reject `__proto__`, `constructor`, and `prototype` segments in the
settings adapter's `readPath` / `writePath`. Every caller currently
passes a constant from `SETTINGS_WHITELIST`, so the guard is purely
defensive — but it silences the CodeQL prototype-pollution warning
on PR #4094 and keeps the helpers safe if a future call site ever
forwards an untrusted path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#2285.
Adds an opt-in 4-digit PIN that gates the library and reader on app
launch. Threat model: casual physical/browser access by another person
on a shared device — peace of mind, not defense against an attacker
with filesystem access. The PIN is stored as a salted PBKDF2-SHA256
hash (100k iterations) in settings.json; the plaintext PIN is never
persisted.
Configured from Settings → Advanced Settings → "Set PIN…" (and
"Change PIN…" / "Disable PIN…" once enabled). The lock screen and the
set/change/disable dialog share a single 4-dot input component
(PinInput) for a consistent UI; the dialog auto-advances focus from
Current → New → Confirm. Lock-on-resume, biometric unlock, and
account-based reset are out of scope for this MVP — disable for now is
"clear app data".
Bundles the previously-missed sync-passphrase i18n strings (PR #4090)
across all 33 locales so no `__STRING_NOT_TRANSLATED__` placeholders
remain in the tree.
New
- src/libs/crypto/applock.ts (PBKDF2 hash/verify; reuses derivePbkdf2Key)
- src/store/appLockStore.ts (gate + dialog state)
- src/components/PinInput.tsx (shared 4-dot input)
- src/components/AppLockScreen.tsx (full-screen lock gate)
- src/components/settings/AppLockDialog.tsx (set/change/disable)
- src/__tests__/libs/crypto/applock.test.ts
Modified
- src/types/settings.ts (pinCodeEnabled / pinCodeHash / pinCodeSalt)
- src/services/constants.ts (default off)
- src/components/Providers.tsx (mount gate + dialog above app shell)
- src/app/library/components/SettingsMenu.tsx (Advanced submenu entries)
- src/styles/globals.css (animate-pin-shake keyframe)
- public/locales/*/translation.json (21 PIN keys + 17 leftover passphrase keys × 33 locales)
Verified
- pnpm test (4018 pass)
- pnpm lint (clean)
- Manual web smoke: Set/Reload-locks/Wrong-PIN/Unlock/Change/Disable
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `interactive-widget=resizes-content` to the viewport meta. iOS
Safari already shrinks the layout viewport when the keyboard opens,
so existing `fixed inset-0` flex-centered modals (PassphrasePrompt,
GroupingModal, etc.) auto-center in the visible space. Android
Chrome defaults to `resizes-visual` — only the visual viewport
shrinks, layout stays full-height — leaving those modals rendered
under the keyboard. Switching to `resizes-content` makes Android
match iOS without any per-modal JS.
Updates both the App Router viewport export (src/app/layout.tsx)
and the Pages Router fallback meta (src/pages/_app.tsx).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): encrypt opds_catalog credentials end-to-end (TS path)
Wires encrypted-credential sync for opds_catalog via the CryptoSession
shipped in PR 4a (#4084) plus a new publish/pull crypto middleware.
TS-only — native still uses ephemeral storage (re-enter passphrase per
launch); PR 4d wires the OS keychain.
- ReplicaAdapter gains optional `encryptedFields: readonly string[]`.
Adapters stay sync; the middleware handles the crypto round trip.
- replicaCryptoMiddleware.ts: encryptPackedFields drops the named
fields from the push when the session is locked (no plaintext
leak); decryptRowFields drops them on pull failure (local
plaintext preserved by the store merge).
- replicaPublish / replicaPullAndApply invoke the middleware.
- OPDS adapter declares encryptedFields = [username, password] and
now pack/unpack them as plaintext.
- passphraseGate.ts: ensurePassphraseUnlocked coalesces concurrent
calls, prompts via the registered prompter with kind=setup|unlock,
throws NO_PASSPHRASE on cancel.
- PassphrasePromptModal mounted at the Providers root; registers
itself as the gate prompter.
- CryptoSession.forget() wipes server-side envelopes + salts.
- Migration 010 + replica_keys_forget RPC; DELETE
/api/sync/replica-keys + client wrapper.
- SyncPassphraseSection on the user page: status / Set / Unlock /
Lock / Forgot.
- CatalogManager pre-save: ensurePassphraseUnlocked when credentials
are present; user cancel saves locally without sync.
Plan updated: PR 4 split documented as 4a/4b/4c/4d.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): persist sync passphrase via OS keychain (Tauri)
Replaces the EphemeralPassphraseStore stub on native with real
OS-keychain storage so users don't re-enter their sync passphrase
every launch. Web stays on the in-memory ephemeral store by design.
Native bridge plugin gains 4 commands wired across all platforms:
- Rust desktop (`keyring` crate): macOS Keychain on apple-native,
Windows Credential Manager on windows-native, Linux libsecret/
Secret Service on sync-secret-service. Per-target features so each
platform compiles only the backend it needs.
- iOS Swift: Security framework Keychain (kSecClassGenericPassword,
SecItemAdd / Copy / Delete).
- Android Kotlin: androidx.security EncryptedSharedPreferences
(AndroidKeystore-derived AES-GCM master key,
AES256_SIV / AES256_GCM key/value encryption).
TS layer:
- TauriPassphraseStore wraps the bridge calls. set is fail-loud
(surfaces keychain rejection); get is fail-soft (returns null on
any error so the gate prompts).
- createPassphraseStore returns ephemeral synchronously;
upgradeToKeychainIfAvailable swaps the singleton to
TauriPassphraseStore on Tauri after probing the bridge. CryptoSession
resolves the store via createPassphraseStore() each touch so the
swap is transparent.
- CryptoSession.tryRestoreFromStore: silent unlock at boot. Stale-
entry recovery clears the store when the account has no salt
server-side. unlock/setup persist; forget also clears the store.
- Providers boot effect: upgrade keychain → silent restore.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sync): make encrypted-credential pull actually decrypt + UX polish
PR 4c shipped the encrypt path but the pull side silently dropped
ciphers when locked, the modal was busy with double-rings, and web
re-prompted on every page refresh. This rolls up the post-test
fixes + UX polish:
Pull-side decrypt:
- decryptRowFields takes an `onLocked` callback the orchestrator
wires to the passphrase gate; encountering a cipher field with a
locked session now triggers the lazy-prompt path instead of
dropping the field.
- replicaPullAndApply re-applies the unpacked row for metadata-only
kinds even when a local copy exists, so the now-decrypted creds
reach the store (the binary-kind skip-if-local optimization
doesn't apply).
- Cipher fingerprint comparison: capture the row's `cipher.c` for
each encrypted field, compare against the local record's
lastSeenCipher. Same → skip prompt + decrypt entirely. Different
(rotation / value change on another device) → prompt to
re-decrypt. Fingerprint persists via OPDSCatalog.lastSeenCipher.
Web persistence:
- SessionStoragePassphraseStore: passphrase survives page refresh
within the same tab, dies on tab close. Replaces
EphemeralPassphraseStore as the default on web. Avoids
localStorage / IndexedDB to keep the tab-scoped trust boundary.
UI:
- Renamed PassphrasePromptModal → PassphrasePrompt; modernized: filled
input style with single subtle focus border, btn-primary +
btn-ghost replaced with leaner custom buttons. eink-bordered +
btn-primary classes give the dialog correct e-paper rendering.
- globals.css: suppress redundant outline/box-shadow on focused text
inputs / textareas (the element's own border is the focus
indicator).
- AGENTS.md: documents the e-ink convention (`eink-bordered`,
`btn-primary` for inverted CTAs, etc.) so future widgets ship with
e-paper support.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires OPDS catalogs through replica sync as a metadata-only kind.
Plaintext fields only in this PR — encrypted credentials (username,
password) ship in the follow-up alongside the SyncPassphrasePanel UI
and Tauri keychain backend.
- Migration 009 extends the kind allowlist with 'opds_catalog'.
- replicaSchemas adds opdsCatalogFieldsSchema (name, url, description,
icon, customHeaders, autoDownload, disabled, addedAt) with a 50-row
per-user cap.
- New opdsCatalogAdapter is metadata-only (no `binary` capability).
Stable cross-device id from md5("opds:" + url.lower()) so two
devices that import the same URL converge to one row instead of
duplicating.
- New customOPDSStore (zustand) hydrates from SystemSettings,
publishes upserts/deletes through the replica pipeline, preserves
local-only username/password when overlaying remote updates, and
strips tombstones at the persistence boundary so existing
useSettingsStore readers (useOPDSSubscriptions, pseStream,
app/opds/page.tsx) need no migration.
- replicaPullAndApply branches on adapter.binary so metadata-only
kinds skip the bundleDir requirement and the manifest/binary path.
- CatalogManager rewires Add / Edit / Remove / Toggle / Add-popular
through the new store.
Plan update bundled in: tenet 8 (scalar settings sync via a bundled
row; collections sync per-record), per-kind allowlist now includes a
`settings` singleton that will collapse PRs 5 + 6+ into one bundled
adapter, and PR 4 is split into 4a (already merged) / 4b (this) / 4c
(encrypted credentials + UX).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the per-account PBKDF2 salt endpoint and an in-memory CryptoSession
that derives keys lazily per saltId. Lets future kinds (OPDS catalogs in
PR 4b) encrypt/decrypt fields without re-deriving on every operation.
- Migration 008: replica_keys_{create,list} RPCs round-trip the bytea
salt as base64; SECURITY INVOKER, RLS-gated by the existing replica_keys
policies.
- /api/sync/replica-keys GET/POST endpoint matches the dual app/pages
shape used by /api/sync/replicas.
- ReplicaSyncClient.{listReplicaKeys,createReplicaKey} wraps the endpoint.
- CryptoSession.{unlock,setup,encryptField,decryptField,lock} caches
derived keys per saltId; foreign envelopes trigger a lazy re-list +
derive. Iterations injectable so tests run with PBKDF2 ITER=1000.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hardcover's note → journal-id mapping store was split-brained: SQLite on
Tauri, localStorage on Web. Unify on SQLite for both. Existing localStorage
entries are migrated lazily on first loadForBook(bookHash) and removed.
Wiring up SQLite on Web exposed two latent gaps:
- @tursodatabase/database-wasm needs SharedArrayBuffer, which requires
cross-origin isolation. next.config.mjs now sets COOP/COEP headers.
- The WASM connector calls getFileHandle(name) directly under
navigator.storage.getDirectory() and does not traverse subdirectories,
so paths like "Readest/hardcover-sync.db" raise "Name is not allowed".
webAppService.openDatabase now flattens the resolved path to a single
OPFS-safe segment before opening.
Also catches up two i18n keys (`{{percentage}}% used`,
`Resets in {{duration}}`) added by the daily-reset countdown feature.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three kinds (dictionary, font, texture) of replica sync now visibly
duplicate each other; this PR extracts the shared shape now that the
abstraction is well-validated, per the plan's "extract only after the
second kind validates" guidance.
- New `services/sync/adapters/_helpers.ts` — `unwrap` field-envelope,
`singleFileFilenameFromManifest`, `singleFileBinaryEnumerator`, and
a default `computeId` for kinds with `record.contentId`. Wired into
font + texture adapters (dictionary stays bespoke — multi-file
enumeration and a different identity recipe).
- `useReplicaPull.ts` collapses the three near-identical
`buildXPullDeps` (~80 lines each) into a single
`buildReplicaPullDeps<T>` factory plus three small `ReplicaPullConfig`
records (~12 lines each). Dispatch stays a typed `switch` to keep
the generic record type sound under contravariance.
- New `services/sync/migrateLegacy.ts` — `migrateLegacyReplicas<T>`
helper that owns the rehash-flat-path → `<bundleDir>/<filename>`
migration. `migrateLegacyFonts` and `migrateLegacyTextures` are now
thin per-kind configs, ~15 lines each (down from ~60).
Net: +143 / -291 across 5 files plus 2 new helpers. No behavior
change; 3933 tests still pass.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a row beneath the translation characters bar on the user profile
page with "X% used" (start) and "Resets in H hr m min" (end). The
countdown points to the next UTC midnight, matching the server-side
daily-usage key in UsageStatsManager. Formatting goes through the dayjs
duration plugin and ticks every minute while the page is open.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Translates the six "File ..." transfer-toast strings introduced in
#4077 (File uploaded / File downloaded / Deleted cloud copy / and the
three Failed-to variants) across 33 locales. Each translation models
the locale's existing "Book ..." copy with book → file/document and
backup → copy.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plug the texture replica adapter into the kind-agnostic primitives
shipped in #4077. Textures imported on one device download and
become available on every signed-in device, with the same shape as
the font sync stack (single-file binary, contentId from
partialMD5+size+filename, bundleDir layout, replica-publish on
import, full activation on auto-download).
Includes legacy flat-path migration so pre-existing textures sync
without re-import, ColorPanel import flow now publishes the row
and queues the binary upload, and createCustomTexture preserves
contentId/bundleDir/byteSize through addTexture (mirrors the
font-import fix). Server allowlist gains 'texture' with a
single-image Zod schema; useBackgroundTexture passes replica
metadata through addTexture so the boot-time "ensure selected
texture is in store" path doesn't silently un-publish a remote
record.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(sync): kind-agnostic replica primitives
Extract dict-only sync into shared primitives (registry, pull/apply
orchestrator, persist env, schema allowlist) so other kinds can plug
in. Companion changes: per-replica Storage Manager grouping,
useReplicaPull boot-race recovery, manifest=null reconciliation on
every boot pull, copyFile takes explicit srcBase + dstBase, settled-
event helpers, lenient webDownload Content-Length (R2/S3 signed URLs
commonly omit it), and generic "File" transfer toast copy any replica
kind can share.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): cross-device custom font sync
Plug the font replica adapter into the kind-agnostic primitives:
font store gains replica wiring, custom font import publishes the
replica row + queues a binary upload, and bootstrap registers the
font adapter and download-complete handler.
Includes legacy flat-path migration so pre-existing fonts sync
without re-import, full @font-face activation on auto-download
(load + mount the rule, mirroring manual import), and a fix to
createCustomFont so contentId / bundleDir / byteSize survive the
trip through addFont — otherwise import-time publish silently
no-oped on missing contentId.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): cross-device dictionary sync
Custom MDict / StarDict / DICT / SLOB dictionaries now sync across
signed-in devices via the replica layer.
- Store mutations publish replica rows with field-level LWW + tombstones.
- Re-importing the same content (renamed or after delete) preserves the
user's label and reincarnates the server row instead of duplicating.
- Manifest commits after binary upload so other devices never see a row
whose binaries aren't on cloud storage yet.
- Pull-side orchestrator creates a placeholder dict, queues the binaries
via TransferManager, and clears the unavailable flag on completion.
- Toast copy branches by transfer kind so dict uploads don't read
"Book uploaded".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sync): boot pull and binary download path
- Defer the boot pull until TransferManager is initialized so download
enqueues aren't dropped.
- Auto-persist the local dict store after applyRemoteDictionary; otherwise
the next loadCustomDictionaries wipes the in-memory rows.
- Boot pull passes since=null so a device whose cursor advanced past
unpersisted rows can still recover.
- Skip pulling when not authenticated instead of logging
"SyncError: Not authenticated" on every boot of a signed-out device.
- downloadReplicaFile resolves the destination against the kind's base
dir; binaries previously landed at the literal lfp and openFile then
failed with "File not found".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(sync): per-page useReplicaPull hook
Lifts the boot-time pull out of EnvContext into a hook each page mounts
for the kinds it needs: useReplicaPull({ kinds: ['dictionary'] }).
Library page and the shared Reader component opt in. The hook fires 10s
after page load (so feature mounts hydrate first), dedups per-kind
across navigation, and releases the slot on failure so a later mount
can retry. Future kinds plug into the hook's per-kind switch.
Also closes two refresh-loop bugs:
- Hydrate the dict store from settings BEFORE the apply loop, so the
auto-persist doesn't clobber persisted rows that the in-memory store
hadn't yet read. Library-page refresh was the visible victim.
- Skip the download queue when every manifest file is already on disk
under the resolved bundle dir. Refreshing is a no-op; partial-
download recovery still queues because some files would be missing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): foundation for CRDT-based cross-device replica sync (Phase 1+2)
Adds the primitives and orchestration layer for syncing user-imported
assets (dictionaries, fonts, textures, OPDS catalogs, dict settings)
across devices via a polymorphic `replicas` table with field-level LWW
under HLC ordering. Phase 1 ships the foundation (CRDT, crypto, server
schemas, SQL migrations, push/pull endpoint); Phase 2 adds the adapter
registry, HTTP client, and sync manager. No modifications to existing
book sync — additive only.
Phase 1:
- src/libs/crdt.ts — HlcGenerator (monotonic + remote-absorption +
clock-regression-safe), per-field LWW with deviceId tiebreak,
remove-wins tombstones, reincarnation token revival.
- src/libs/crypto/{derive,encrypt,envelope,passphrase}.ts —
PBKDF2-600k key derivation (OWASP 2024), AES-GCM round-trip,
envelope {c,i,s,alg,h} with SHA-256 sidecar integrity check,
passphrase storage abstraction (web ephemeral; Tauri keychain stub).
- src/libs/replica-schemas.ts — Zod-backed allowlist (dictionary only
in PR 1), 64KiB row cap, 64-field cap, schemaVersion bounds,
filename validator.
- src/libs/replica-sync-server.ts — push batch validation
(auth + allowlist + schema + HLC ±60s skew clamp).
- src/pages/api/sync/replicas.ts — POST/GET endpoint wrapping the
Postgres crdt_merge_replica function via RPC.
- docker/volumes/db/migrations/003_add_replicas.sql — replicas table
+ replica_keys table + RLS.
- docker/volumes/db/migrations/004_crdt_merge_replica_fn.sql — atomic
per-field LWW merge function (forwards-compat preserves unknown
fields).
Phase 2:
- src/services/sync/replicaRegistry.ts — adapter contract
(core + optional BinaryCapability + LifecycleHooks per eng review).
- src/libs/replica-sync-client.ts — HTTP wrapper mapping status codes
to typed SyncError codes.
- src/services/sync/replicaSyncManager.ts — 5s debounced push,
immediate flush on visibilitychange/online, per-kind pull cursor,
remote HLC absorption.
Tests: 125 new (crdt 26, crypto 32, schemas 21, server 16, client 12,
registry 6, manager 12). Full suite 3656 passing, lint clean. Existing
book/config/note sync paths untouched.
Plan: ~/.claude/plans/vivid-orbiting-thimble.md
CEO plan: ~/.gstack/projects/readest-readest/ceo-plans/2026-05-06-replica-sync-cathedral.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): add kind="replica" path through TransferManager (Phase 3)
Adds the replica branch to the existing book-shaped transfer
infrastructure so dictionary (and future kinds) bundles can flow through
the same queue, retry, and progress UI as book uploads.
Existing book transfer paths remain unchanged. The book-side regression
suite (37 tests in transfer-store.test.ts, 37 in transfer-manager.test.ts)
all stay green.
Store (src/store/transferStore.ts):
- TransferItem gains kind: 'book' | 'replica' (default 'book' on legacy
persisted rows), replicaKind, replicaId, replicaFiles, replicaBase.
- New addReplicaTransfer(replicaKind, replicaId, displayTitle, type, opts)
with files + base in opts; auto-computes totalBytes from file sizes.
- New getReplicaTransfer(replicaKind, replicaId, type) lookup.
- getTransferByBookHash filters to kind === 'book' (defensive against
bookHash="" collisions on replica items).
- restoreTransfers fills kind: 'book' for legacy persisted rows.
Manager (src/services/transferManager.ts):
- queueReplicaUpload / queueReplicaDownload / queueReplicaDelete.
- executeTransfer dispatches by kind to the new executeReplicaTransfer
(iterates files, calls appService.uploadReplicaFile per file with
per-file progress aggregation) or the existing executeBookTransfer
(refactored out, byte-identical behavior).
- Dispatches replica-transfer-complete event on success so stores can
react (e.g., commit manifest_jsonb to the replica row).
Storage / cloud (src/libs/storage.ts, src/services/cloudService.ts):
- uploadReplicaFile bypasses the book-only File.name smuggling and
takes an explicit cfp (cloud file path).
- uploadReplicaFileToCloud / downloadReplicaFileFromCloud /
deleteReplicaBundleFromCloud orchestrate per-file operations under
${userId}/Readest/replicas/<kind>/<replicaId>/<filename>.
- replicaCloudKey() centralizes the path-construction rule.
- New CLOUD_REPLICAS_SUBDIR constant.
App service (src/services/appService.ts, src/types/system.ts):
- AppService gains uploadReplicaFile, downloadReplicaFile,
deleteReplicaBundle (file-level operations; orchestration lives in
TransferManager).
Tests: 18 new (12 in transfer-store.test.ts, 5 in transfer-manager.test.ts,
1 fixture). Full suite 3674 passing, lint clean. Existing book regression
clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): dictionary replica adapter + bootstrap (Phase 4, partial)
Lands the safe-to-ship foundation of Phase 4 — adapter logic, registry
bootstrap, and SystemSettings hooks for replica sync. The on-disk
migration of legacy customDictionaries (bundleDir → content-hash id),
the live store wiring, and the Settings → Sync UI are deferred to a
follow-up PR so they can land with real-device QA.
Adapter (src/services/sync/adapters/dictionary.ts):
- dictionaryAdapter: kind='dictionary', schemaVersion=1.
- pack/unpack — only synced subset (name, kind, lang, addedAt,
unsupported{,Reason}). bundleDir / files / unavailable / deletedAt
stay per-device or are handled by the tombstone mechanism.
- BinaryCapability.enumerateFiles dispatches by bundle kind:
- mdict: mdx + mdd[] + css[]
- stardict: ifo + idx + dict + syn (skips .idx.offsets / .syn.offsets
sidecars — those are device-local indices)
- dict: dict + index
- slob: single .slob file
- primaryDictionaryFile() picks the anchor file per kind for
partialMD5 hashing.
- computeDictionaryReplicaId(partialMd5, byteSize, sortedFilenames)
produces a deterministic 32-hex content-hash id used at import time.
- 23 tests cover pack/unpack identity, kind dispatch, file enumeration,
id determinism, and per-device-field exclusion.
Bootstrap (src/services/sync/replicaBootstrap.ts):
- bootstrapReplicaAdapters() registers all known adapters once at app
start. Idempotent (safe to call multiple times). Wired into
EnvContext.tsx so the registry populates on app mount.
- 3 tests cover registration, idempotency, and the PR-1 allowlist.
SystemSettings (src/types/settings.ts, src/services/constants.ts):
- +SyncCategory = 'book' | 'progress' | 'note' | 'dictionary' — typed
union for the user-facing sync toggles. 'progress' gates the
existing book-config sync (reading progress); 'note' gates
annotations; 'book' gates book binaries + metadata; 'dictionary'
gates the new replica sync. Future replica kinds extend the union.
- +SYNC_CATEGORIES readonly array for UI iteration.
- +syncCategories: Partial<Record<SyncCategory, boolean>> — per-
category opt-in toggles in DEFAULT_SYSTEM_SETTINGS (default ON for
all four). UI panel ships in the follow-up.
- +lastSyncedAtReplicas: Record<string, string> — per-kind HLC pull
cursors (matches replicaSyncManager's CursorStore contract).
Registry type cleanup (src/services/sync/replicaRegistry.ts):
- BinaryCapability.enumerateFiles return shape: localRelPath → lfp
to match the existing TransferStore.ReplicaTransferFile convention.
Tests: 28 new (23 dict + 3 bootstrap + 2 syncCategories defaults).
Full suite 3702 passing, lint clean. Existing book/config/note sync
paths untouched.
Deferred to PR 1 follow-up (with real-device QA):
- customDictionaryStore migration: rehash legacy uniqueId() bundleDir
to content-hash id; preserve providerOrder mapping; staged
.legacy/<old-id>/ backup.
- Wire customDictionaryStore mutations through replicaSyncManager
(markDirty on add/rename/delete; pull on init).
- Settings → Sync panel: per-category toggles + last-sync timestamps.
- Sync passphrase modal: set / change / forgot flow (lazy first prompt
on encrypted-field push/pull).
- <CloudReplicaRow> in CustomDictionaries.tsx for "Download from cloud
(X MB)" affordance.
- Tauri keychain backend for sync passphrase storage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): replicaSync singleton + content-hash id at dict import (Phase 4b)
Two foundation pieces that everything UI-side will sit on top of, both
purely additive — no live store wiring, no behavior change for existing
dictionary imports.
replicaSync singleton (src/services/sync/replicaSync.ts):
- initReplicaSync({deviceId, cursorStore, hlcStore?, client?}) builds
one ReplicaSyncManager backed by an HlcGenerator with persistence
wrapped around .next()/.observe(). Idempotent (second init returns
the existing instance).
- LocalStorageHlcStore (src/libs/hlc-store.ts) snapshots the HLC
counter under 'readest_replica_hlc' so it survives restart. Falls
back silently when localStorage is unavailable (private mode, SSR);
client re-derives via the existing remote max(updated_at_ts) repair
path. InMemoryHlcStore is the test backend.
- 16 tests (9 hlc-store, 7 replicaSync) covering snapshot persistence,
restore-on-init, and idempotency.
- Wiring into EnvContext for production deferred to the follow-up that
also adds the cursor store backed by useSettingsStore.
Content-hash id at dictionary import
(src/services/dictionaries/contentId.ts):
- computeDictionaryContentId(primaryFile, filenames) wraps
computeDictionaryReplicaId(partialMd5(primary), byteSize,
sortedFilenames) — the cross-device id used as the replica_id when
the dict actually pushes/pulls.
- Wired into all four import paths in dictionaryService.ts:
- stardict primary = .ifo (small text, partialMD5 ≈ full hash)
- mdict primary = .mdx (body)
- dict primary = .dict.dz (gzipped body)
- slob primary = .slob (single-file bundle)
ImportedDictionary gains contentId?: string. Optional for backwards
compat; legacy bundles without contentId are flagged as
"needs rehash before sync" by the upcoming store-wiring follow-up.
- 6 tests cover identity determinism, byteSize sensitivity, filename-
set sensitivity, and order-independence.
Tests: 22 new (9 + 7 + 6). Full suite 3724 passing, lint clean.
Existing dictionary import flow unchanged for users — contentId is an
additional field, not a replacement for the bundleDir-based id.
Deferred to follow-up (with on-device QA):
- Production cursor store backed by useSettingsStore +
appService.saveSettings.
- EnvContext call to initReplicaSync after appService boot.
- customDictionaryStore mutation hooks → replicaSyncManager.markDirty.
- Legacy bundleDir → contentId migration with .legacy/ backup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(sync): land replica-sync design plan in repo
Moves the plan document that drove this PR's foundation work
(`~/.claude/plans/vivid-orbiting-thimble.md`) into the project tree at
`apps/readest-app/.claude/plans/` so reviewers and future contributors
can read it alongside the code without leaving the repo.
The plan went through three review passes — Codex (19 findings, all
absorbed), CEO/scope review (mode SCOPE EXPANSION; encrypted secrets
pulled forward to v1, "private-only forever" posture lock), and eng
review (FULL_REVIEW mode, 16 findings absorbed). The full review trail
lives in the file's `## GSTACK REVIEW REPORT` section at the bottom.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(crdt): point README at in-repo plan path
Now that vivid-orbiting-thimble.md lives at
apps/readest-app/.claude/plans/, the README link should point there
rather than at the home-dir copy that no longer exists.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(sync): rename src/libs files to camelCase per project convention
Test files renamed in lockstep. Imports + comment references updated
across cloudService, storage, transferManager, replicaSync,
replicaSyncManager, /api/sync/replicas, and all four test files. No
behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(reader): play sound:// links in MDict definitions via MDD lookup
MDX entries reference audio resources with `<a href="sound://name.ext">`.
Until now those anchors fell through to the browser, which tried to
navigate to an invalid scheme and did nothing useful.
Wire each `sound://` anchor inside the rendered MDX body to:
- preventDefault + stopPropagation (so the parent card's tap-to-expand
doesn't fire),
- look up the path in every companion `.mdd` until one returns bytes
(js-mdict's `MDD.locateBytes` auto-normalizes the leading separator),
- wrap the bytes in a Blob and play via `new Audio(URL.createObjectURL)`,
- cache the resolved URL on the anchor so subsequent clicks reuse it,
with the URL tracked for revocation in `dispose()`.
Note: many MW-style dictionaries use `.spx` (Speex) which Chromium and
Safari don't natively decode — the lookup will succeed but playback may
fail silently. Other formats (mp3, wav, ogg vorbis) play fine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(dict): improve MDict rendering and dictionary management
Builds on the sound:// fix to round out MDict rendering and tighten
the dictionary settings panel.
MDict provider:
- Follow MDict-specific URL schemes inside the rendered HTML:
`sound://path` plays via Audio (with a deprecation toast for `.spx`
whose codec no major browser decodes), and `entry://word` /
`bword://word` forward to ctx.onNavigate so the popup re-looks-up
the target. Cycle-bounded (5 hops) `@@@LINK=<word>` content-level
redirects are followed transparently, so entries that are pure
redirect strings (e.g. "questions" → "question") render the
canonical entry instead of the literal redirect text.
- Render the body inside a shadow root so each dict's CSS stays
scoped — `<link rel="stylesheet">` references are resolved against
the companion .mdd, loose .css files imported alongside the bundle
are read at init, and `url(...)` refs inside both are rewritten to
blob URLs sourced from the MDD (covers sound icons, background
images, @font-face sources). The body is tagged `data-dict-kind="mdict"`
for downstream targeting.
- A baseline app-level stylesheet (`getDictStyles`) is injected into
every shadow root with theme-adaptive `mix-blend-mode` for `<a>`
background icons / `<a> img` (multiply on light, screen on dark);
isDarkMode is forwarded via the lookup context.
- `<img src="/path">` is now treated as MDD-relative (the tightened
IMG_SRC_PROTOCOL_RX skips schemes / protocol-relative only); a
fallback retry strips the leading slash for bundles that store the
resource without it.
- The auto-prepended light-DOM headword `<h1>` is hidden when the
dict body either leads with a same-text element (any tag — covers
`<h3 class="entry_name">`, etc.) or contains an `<h1>` with the same
trimmed text anywhere (covers wrapper-div-then-h1 layouts).
Dictionary management:
- Importing a dict whose name matches an existing one now replaces
it in place, preserving the slot in providerOrder and inheriting
the previous enabled flag. The .css extension is added to the file
picker, and loose .css files imported alongside .mdx/.mdd are
bundled with the dictionary regardless of stem-match.
- The settings panel gains an Edit mode (parity with Delete mode):
trailing pencil button on imported dicts and custom web searches
opens a rename modal. Edit and Delete are mutually exclusive.
Below 400px, the Edit/Delete labels collapse to icons only.
Card UX:
- The card's tap-to-expand handler now walks `composedPath()` so
clicks on anchors / buttons / images inside the shadow root no
longer fold the card.
i18n:
- Translations added for new strings across 33 locales.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Redesign the dictionary lookup UI as a single scrolling list of
expandable cards — one per provider that has a result — instead of
a tab strip with one active provider at a time.
Behavior:
- All enabled dictionaries are queried in parallel; cards render in
user-defined order. Cards whose provider returns no result, an
unsupported format, or an error are removed entirely.
- Cards default to expanded when 3 or fewer providers have results,
collapsed (4-line preview) otherwise. Manual taps are sticky
across re-renders; the auto-decision is reset only when a new
word is looked up.
- Web-search providers (Google, Urban, Merriam-Webster, custom
templates) appear in a separate "Search the web" section as
tappable rows. On the web build they use native target="_blank"
anchors; on Tauri the click is routed through openUrl since
target="_blank" doesn't open externally there.
- The header carries a back arrow (when in-content link navigation
has pushed onto the history stack), the looked-up word, and a
gear that deep-links to Settings → Language → Dictionaries.
Mobile / narrow viewports (<sm) get the same UX as a bottom sheet
(Dialog with snapHeight 0.75); sm+ viewports keep the anchored
popup with triangle pointer. Both share useDictionaryResults +
DictionaryResultsHeader/Body.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drag-and-drop now classifies dropped items into files vs directories.
Real files keep the existing import flow; dropped directories reuse
handleImportBooksFromDirectory via a new import-book-directory event,
matching the "From Directory" menu behavior instead of failing with
"No supported files found".
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Expose `annotation.appLink` (readest://) and `annotation.webLink`
(https://web.readest.com) as template variables for custom export
templates. The shipped default template now emits the readest:// app
deeplink for the page link so exported notes open the native app.
The non-template export mode keeps the universal https link.
Preview links also gain target="_blank" so they open in a new tab
instead of replacing the dialog.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surface series name and index from Calibre-written metadata so the
existing belongsTo.series → metadata.seriesIndex pipeline picks them up.
- PDF: read calibre:series + calibreSI:series_index from XMP
- CBZ: read ComicInfo.xml (Series/Number/Count); fall back to
ComicBookInfo/1.0 (series/issue) in the ZIP comment
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tapping the footer with `tapToToggleFooter` on cycles `progressInfoMode`
through values including 'none', which persists to view settings. When
the user later disabled the toggle in settings, nothing reverted the
saved mode — so the footer stayed hidden with no UI path back to a
visible state, only re-enabling the toggle and tap-cycling forward.
ProgressBar now self-heals: when `tapToToggleFooter` is off and the
current mode isn't already 'all', it resets to 'all'. Fires both at
mount (book opened with stuck 'none') and on the toggle transition.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Group cells in the Readest Library view rendered as FakeCover even when
their child books had perfectly good cloud covers — the queue that
fetches <hash>.png covers was only primed for cloud-only book entries
on the visible page, so children of group entries were never requested.
A later partial composite (3/4 covers) was also written to a
content-fingerprinted disk cache and kept serving forever, since the
fingerprint stayed the same after the 4th cover landed.
Two fixes wired together:
- libraryitem.set_visible_hashes now expands visible group entries to
include their first-N children's hashes, so trigger_download's
visibility filter no longer rejects them.
- group_covers.child_cover_bb queues a cloud-cover download when the
fallback path misses for a cloud-present book. Capped at 4 per group
by the existing cells_for(shape) limit.
Disk caching of composites is dropped entirely; mosaics are recomposed
in memory each paint. New spec/library/group_covers_spec.lua locks the
contract for URI round-trip, child_cover_bb's missing-cover branches,
and libraryitem's group-children expansion.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): add Uzbek (Oʻzbek) translation
Adds `uz` as a first-class supported locale across the Readest app
and the readest.koplugin companion. Also refactors the supported
locale set to source from a single ground-truth file
(`apps/readest-app/i18n-langs.json`) consumed by both the i18next
runtime and the i18next-scanner config, and replaces a NUL-byte
sentinel in `extract-i18n.js#unescapePo` with a single-pass regex
so git no longer treats the script as binary.
Closes#4053
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(i18n): add Brazilian Portuguese (pt-BR) translation
Adds `pt-BR` as a regional variant supported alongside `pt`. Falls back
to `pt` then `en` for any future missing keys, so European Portuguese
gracefully covers gaps. Translations follow Brazilian conventions
(arquivo / tela / excluir / salvar / baixar / senha, gerundive verb
forms) rather than verbatim copying the European catalog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Run scripts/extract-i18n.js — adds 70 new msgids covering the Library
view (View Mode / Group by / Sort by labels, action sheet entries,
search dialog, error toasts) and drops 1 obsolete msgid removed
during the picker rework. All 31 locales updated; new strings have
empty msgstrs awaiting translation.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(koplugin/library): data layer + busted harness + design doc
- LibraryStore: per-user SQLite index merging cloud + local books by
partial-md5 hash. listBooks/listBookshelfBooks/listBookshelfGroups,
upsertBook with cloud_present/local_present OR-merge + _force/clear
sentinels, parseSyncRow, getChangedBooks for tombstone push.
- EXTS table mirroring web's document.ts.
- busted harness with KOReader stubs (G_reader_settings, DataStorage,
lua-ljsqlite3 against :memory:); spec_helper, exts_spec, smoke_spec,
librarystore_spec covering schema, sort, group nesting, dedupe.
- Library design doc + spec README.
- pnpm test:lua wired through root + app package.json; lint-koplugin
recurses into library/ + spec/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(koplugin/library): cloud sync — push, pull, upload, delete, downloads
- Spore methods: pullBooks (incremental /sync), getDownloadUrl,
getUploadUrl, listFiles, deleteFile.
- syncbooks.lua: pushBook + pushChangedBooks (advances watermark to
max(updated_at, deleted_at)), syncBooks(opts, mode) for push/pull/both,
downloadBook + downloadCover (sync socket.http with file sink; cover
download via fork+poll with single-slot queue + visible-page filter +
coalesced refresh), uploadBook (presigned-PUT flow + best-effort
cover), deleteCloudFiles (list-then-delete-each, mirrors
cloudService.deleteBook).
- SyncAuth.withFreshToken wrapper resolves the ensureClient race; 401/403
unified across syncconfig + syncannotations.
- Cloud + local book covers shared by partial-md5 hash; cover.png cached
at <settings>/readest_covers/<hash>.png with sentinel for known 404s.
- syncbooks_spec covers row-to-wire conversion + file_key shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(koplugin/library): local discovery — sidecar scanner + cover provider
- localscanner.lightScan iterates ReadHistory entries, reads
partial_md5_checksum from .sdr sidecars, and upserts local rows.
Slow filesystem walks deferred to fullSidecarWalk (24h-gated).
- coverprovider wraps BookInfoManager:getBookInfo for local books with
graceful FakeCover fallback when coverbrowser is absent.
- localscanner_spec + coverprovider_spec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(koplugin/library): UI — widget, item, view menu, FileManager hooks
- librarywidget: full-screen Menu mixed in with CoverMenu + Mosaic/List
per zen_ui's group_view pattern. Title-bar tap → view menu, search via
left icon, drill-in/back for grouping. Async cloud sync deferred via
scheduleIn so the menu paints before HTTP fires.
- libraryitem: BIM patch for cloud-only (readest-cloud://) and group
(readest-group://) URIs; group-cover composer (2x2 mosaic for grid,
same in list) with cache key derived from the actual first-N hashes
for content-based invalidation; ListMenuItem update + paintTo patches
for wider list-mode cover strip and cloud-up/down icon overlay.
- libraryviewmenu: ButtonDialog with View/Group by/Sort by/Actions.
Default Group by = Groups (parity with web), values authors/groups.
- librarypaint: partial-page e-ink repaint shim adapted from zen_ui.
- main.lua: Library menu entry, dispatcher actions (Open Library / Push
/ Pull as general; progress + annotations as reader-only),
"Add to Readest" button in FileManager's long-press file dialog
(dedupe by partial_md5; bumps updated_at when present, inserts a
fresh local-only row otherwise; un-tombstones via _clear_fields).
Push books on Library open when auto_sync is on, pull-only otherwise.
- Long-press action sheet with Readest BookDetailView parity:
Remove from Cloud & Device / Cloud Only / Device Only,
Upload to Cloud, Download Book / Cover / All.
- Cloud-down + cloud-up SVG icons (LiaCloudDownloadAltSolid /
LiaCloudUploadAltSolid) painted in the right-side wpageinfo slot.
- i18n catalog updated for new strings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(koplugin/library): split libraryitem into focused modules
libraryitem.lua had grown to 1018 lines mixing five unrelated
concerns. Split along the natural seams:
cloud_covers — readest-cloud:// URI scheme, on-disk <hash>.png
cache, single-slot async download queue, visible-page
filter
group_covers — readest-group:// URI scheme, 2x2 mosaic composer with
content-derived cache key (first-N hashes), cell
layout table
cloud_icons — bundled cloud-up/cloud-down SVG loader, IconWidget
cache, paint-overlay positioning
list_strip — list-mode group row builder (4-cover wider strip
replacing ListMenu's square cover slot)
bim_patch — BookInfoManager:getBookInfo router (cloud / group /
local) + ListMenuItem update + paintTo patches; owns
the _library_local_paths set and orig BIM reference
libraryitem.lua is now 141 lines: just the entry-table constructors
(entry_from_row, entry_from_group, entry_back) plus thin install /
set_visible_hashes delegates. Each new module is 88-216 lines.
No behavior change — same 113 specs pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(koplugin): co-locate dev tooling, ship-zip exclusions, CI job split
- apps/readest.koplugin/scripts/build-koplugin.mjs: local sideloading
build with the same exclusions the release workflow uses.
- Move lint-koplugin + test-koplugin from apps/readest-app/scripts/ to
apps/readest.koplugin/scripts/. All koplugin dev tooling now lives
with the koplugin and is excluded from the published release zip.
- Rename to .mjs so Node treats them as ESM without the reparse warning
(the i18n CommonJS scripts stay .js).
- Release workflow: zip -r exclusions for scripts/, docs/, spec/,
.busted so dev artifacts don't ship to end users.
- PR workflow: split build_web_app into build_web_app + test_web_app
for parallelism. The test job installs luarocks + busted +
lsqlite3complete and runs pnpm test:lua. test-koplugin.mjs now
hard-fails (instead of soft-skipping) when CI=true and a tool is
missing — a broken CI toolchain previously exited 0 silently.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- i18n loader at apps/readest.koplugin/i18n.lua: isolated callable,
falls back to KOReader's gettext when a string is untranslated
- Translation catalog at locales/<i18next-code>/translation.po for 31
languages, mirroring apps/readest-app/public/locales/
- scripts/extract-i18n.js (scan _("...") and _([[...]]); preserve
existing, drop obsolete, add new) and scripts/apply-translations.js
(bulk import from /tmp/koplugin-translations/<lang>.json)
- Mirror apps/readest-app SyncInfoDialog: rename showMetaHashInfo to
showSyncInfo, dialog title "Sync Info", new Last Synced row computed
as max(last_synced_at_config, last_synced_at_notes) from doc_settings
- syncconfig.lua / syncannotations.lua mark per-book sync timestamps
on push/pull success
- Rename "Meta Hash" -> "Book Fingerprint" in koplugin and
apps/readest-app SyncInfoDialog.tsx; translations propagated to
all readest-app locales
- "book config" -> "reading progress" wording across user-facing
strings (matches QiuYukang fork terminology)
- Replace "Log out as " / "Login failed: " concat prefixes with
T(_("...%1..."), arg) placeholder pattern (RTL / verb-final friendly)
- pnpm lint:lua: luajit -b syntax check across koplugin .lua files;
soft-skips when luajit is missing locally; CI installs luajit and
runs the check unconditionally
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the dictionary system beyond StarDict/MDict with two more open
formats and a pluggable Web Search tier so users can fall back to online
sources when their offline bundles miss a word.
Formats:
- DICT (dictd, RFC 2229): .index + .dict.dz bundles. Shared DictZip
parsing with StarDict via new dictZip.ts helper.
- Slob (Aard 2): self-contained .slob containers, zlib-compressed
utf-8 entries; non-zlib/non-utf-8 bundles flagged unsupported at
import.
Web Search:
- Built-in templates for Google, Urban Dictionary, Merriam-Webster
(seeded into providerOrder, disabled by default).
- Custom URL templates via %WORD% placeholder, URL-encoded at
substitution; entries persist in settings.webSearches.
- V1 renders an "Open in {{name}}" external link (iframe embedding is
blocked by every major target site's X-Frame-Options).
UI:
- CustomDictionaries panel: flat outline-primary buttons for Import /
Add Web Search, end-aligned type badges for a uniform column,
hover states, compact tips block.
- Dictionary popup: bottom-right Manage icon (tooltip-only) deep-links
into Settings → Language → Dictionaries; rounded-corner clipping fix
on the tab strip.
File picker accepts .index and .slob; importer recognizes DICT and
Slob bundles and reads bundle metadata for friendly names.
Tests cover DICT/Slob readers and providers with real freedict-eng-nld
fixtures, web search substitution + provider rendering, and the new
store CRUD for web searches.
Closes#4038
Tapping the section info or progress bar overlays did not dismiss an
open annotation popup because the dismiss flow listens for
iframe-single-click, which only fires from inside the foliate iframe.
These overlays sit above the iframe and intercept taps before the
iframe sees them, so the popup stayed open.
Dispatch iframe-single-click first in their click handlers; if a popup
consumes it (existing useTextSelector handler dismisses popup/selection
and returns true), skip the original action.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The web client's NEXT_PUBLIC_SUPABASE_URL build arg pointed at port 7000,
but Kong is exposed on KONG_HTTP_PORT (8000 by default), so browser-side
Supabase calls failed in the self-hosted Docker setup.
Closes#4035
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the hook introduced by koreader/koreader#15240, called when
the user deletes the plugin via the plugin manager with "Also delete
plugin settings" checked. Removes the readest_sync entry from
G_reader_settings (auth tokens, user info, auto_sync flag, last_sync_at)
and resets the in-memory copy to defaults.
Closes#4039
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ensureSharedBookLocal helper makes sure the local library has both the
Book entry and the bytes on disk after /import succeeds; navigating
into the reader before this lands on "Book not found"
- ShareLanding navigates via navigateToReader (path form on web) so the
reader actually renders instead of hitting the App Router stub and
going blank
- Loading + progress UI on the landing page while bytes stream in;
Open-in-app disabled mid-import to avoid races
- UserInfo header: vertically center avatar with name/email, tighter
mobile gap, and a fillContainer prop on UserAvatar so a parent can
size the box via classes without the inline style fighting back
- Rename "Share current page" -> "Share reading progress" (and matching
post-generation hint) and shrink the dialog from 480 to 460px
- Drop the unused Reload Page menu item from SettingsMenu
- Translate the two new i18n keys across 31 locales
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Direct file download from the public share landing carries rights /
abuse risk. Replace the Download button with the Open-in-app deep link
in both the logged-in flow (now: Add to library + Open in app) and the
anonymous flow (now: Open in app + Get Readest footnote).
The /api/share/[token]/download route is left intact so re-enabling
the button later is a one-file UI change.
* fix(share): make /s landing build under Next 16 layout-prop validation
Production builds (`next build --webpack` for OpenNext on Cloudflare)
rejected the Layout default export with "Type 'LayoutProps' is not valid"
because Next 16 strictly enforces that layout components only accept
`{ children }` (and `{ params }` for dynamic segments) — never
`searchParams`. The previous design tried to read `searchParams` from
both the layout component AND its `generateMetadata`, but layouts don't
get `searchParams` at all (they're shared across child pages with
potentially different query strings).
Restructure:
- Move `generateMetadata` from `app/s/layout.tsx` to `app/s/page.tsx`,
which DOES receive `searchParams`. Page is now a server component.
- Split the existing client component into `app/s/ShareLanding.tsx`
(still `'use client'`); page.tsx wraps it in `<Suspense>` per the
Next 16 client-component contract.
- Delete `app/s/layout.tsx` — no longer needed; the project's root
layout still wraps everything.
Verified locally: `pnpm lint` clean, `pnpm build-web` green, all 9
share API routes plus `/s` show up in the route manifest.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(share): drop edge runtime from og.png so OpenNext can bundle it
OpenNext on Cloudflare errors out when bundling edge-runtime routes inside
the default server function:
app/api/share/[token]/og.png/route cannot use the edge runtime.
OpenNext requires edge runtime function to be defined in a separate
function.
Splitting into a second function bundle is more config surgery than this
route deserves. `next/og`'s `ImageResponse` (Satori + WASM yoga/resvg) has
supported the default Node-compat runtime since Next 13.4, and on
Cloudflare via OpenNext the default function IS already a Worker, so
cold-start cost is similar to edge.
Verified: `pnpm exec opennextjs-cloudflare build` now completes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a Share Book feature that generates an expiring HTTPS share URL plus a
parallel readest://share/{token} deep link. Recipients land on /s/{token},
where logged-in users can one-tap "Add to my library" (R2 server-side
byte-copy) and anonymous users download the book or open it in the app.
Sharers manage active links from a dedicated "Manage Shared Links" panel
under user settings.
Highlights:
- 9 new App Router endpoints under /api/share (create, [token], cover,
og.png, download, download/confirm, import, revoke, list).
- /s landing page with branded next/og chat unfurl image and SSR auth-cookie
detection so logged-in recipients see "Add to my library" as the primary
action without layout shift.
- Server-side R2 byte-copy for /import preserves the project's existing
invariant that every files.file_key is prefixed with its row's user_id;
stats / purge / delete / download routes work unchanged. URL-encodes the
copy source so titles with spaces or '&' don't break the copy.
- Universal 7-day expiry cap, no tier differentiation, no "never". DMCA-risk
reduction. Picker defaults to 3 days.
- Position-aware shares: "Share current page" toggle (off by default for
privacy) attaches the sharer's CFI; recipient lands at the same paragraph.
- Per-user 50-share cap, rate limiting via Cache-Control: no-store on
token-bearing responses, atomic SQL increment for download_count via a
SECURITY DEFINER function so the public confirm beacon stays safe under
concurrent fire.
- Soft revocation: presigned download URLs (5-min TTL) cannot be cancelled
before TTL; documented as accepted v1 behavior.
- token + token_hash hybrid storage: public endpoints look up by hash and
never select the raw token, so accidental SELECT-* leakage on a public
route can't expose the bearer credential.
- Mobile / desktop Tauri share via tauri-plugin-sharekit; web falls back to
navigator.share with a clipboard fallback when no native share method
exists. Share-sheet dismissal no longer silently copies.
UI:
- New Dialog with a settings-card group: iOS-style segmented duration picker
+ toggle slider for "Share current page", on a single row each.
- Reader top-bar Share button, library context-menu Share entry, manage-
shares list with cover thumbnails and overflow menu.
- New <SegmentedControl> primitive in src/components for reuse.
Coverage:
- Unit tests for token utils + URL parser (20 new tests, full suite at 3445).
- 31 locales translated for all new strings; en plurals hand-added per the
project's hand-curated en convention.
DB migration in docker/volumes/db/migrations/002_add_book_shares.sql adds
the book_shares table, RLS policies, and the increment_book_share_download
RPC. Migration is idempotent.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Relocate the toggle from the library settings menu to the Behavior settings
panel under the Device section. Add a matching command palette entry so the
setting remains discoverable via search.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Speed-read mode flashed words joined by em-dash (—) or en-dash (–) as
a single unreadable token. Split non-CJK tokens on these dashes,
keeping the dash attached to the preceding word so the punctuation
pause still fires, and treat them as pause-triggering punctuation in
the word display duration.
Closes#4022
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Chinese novels commonly use 番外 (bonus), 番外篇, or 外传 as chapter
headings, optionally combined with 第N章. The previous regex only
matched 第N章 at line start, so lines like "番外 第1章 旗开得胜"
were dropped from the TOC. Treat 番外篇/番外/外传 as preface-style
keywords so they match alongside 楔子/前言/etc.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The footer NavigationBar and slide-up panels (Color, Progress, Font &
Layout) used `bg-base-200`, which is computed as a slightly-darker shade
of the theme's background. In eink mode this rendered as a visible grey
strip even when the user picked a pure white/black theme.
Switch to `bg-base-100` in eink mode and add a `border-base-content` top
border so the bar stays visually separated from the page area without
relying on a tinted background, matching the existing eink treatment
elsewhere (e.g. PageNavigationButtons, ImageViewer).
* fix(rsvp): seed local position from synced BookConfig on resume
* refactor(rsvp): simplify seedPosition
Consolidate the matched/mismatched write paths into one localStorage
write, extract stripCfiPath() to a module-level helper, and trim the
comments around it.
* fix(rsvp): make progress bar draggable on mobile
Three coordinated fixes so the RSVP overlay's seek bar works reliably
under touch:
- Add `touch-action: none` on the slider so the mobile browser stops
claiming the gesture for scroll/pan and firing pointercancel mid-drag.
- Hoist the `.rsvp-controls`/`.rsvp-header` exclusion to the top of the
overlay's touchend handler so a successful drag isn't immediately
re-interpreted as a speed-change swipe.
- Guard `releasePointerCapture` with `hasPointerCapture` so pointercancel
arriving after the browser has already released capture (multitouch,
app backgrounding) no longer throws NotFoundError.
* feat(annotations): preview mode for deep-link landings
When the reader opens at a deep-link CFI (e.g. clicking an exported
highlight from Obsidian), the position should not be persisted as the
user's reading progress until they actually start reading. Otherwise
the deep-link visit overwrites their last-read position and propagates
that across all sync targets.
Adds a per-book `previewMode` flag in the reader store that:
- Is set to true in FoliateViewer when the URL's `?cfi=` overrides the
saved last-position.
- Is cleared on the first user-initiated relocate (page turn / scroll),
reusing the existing reason filter in `docRelocateHandler`.
- Gates the auto progress writers:
- useProgressAutoSave — skip local config persist
- useProgressSync — skip auto-push and skip the remote-progress
view.goTo (so cloud pull doesn't yank the
user away from the previewed annotation)
- useKOSync — skip auto-push (manual pushes still respected)
Hardcover sync and Discord presence are unaffected: hardcover only
fires on explicit user button press, and Discord presence carries no
position information.
Also picks up the regenerated AndroidManifest.xml change from the
existing tauri.conf.json deep-link config (registers readest:// scheme
on Android so the smart landing page's intent:// launch resolves).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(annotations): jump in place when target book is already open
When an annotation deep link arrives while the user is already in the
reader (most common case on mobile App Links), navigateToReader was
pushing the same /reader path with a different cfi query param. The
reader's init useEffect has [] deps, so it doesn't re-run, and
FoliateViewer doesn't re-read the cfi — the view stayed put.
Detect a mounted view for the target book hash by walking
viewStates and matching the hash prefix on the bookKey. If found,
call view.goTo(cfi) directly and set previewMode so the existing
gates fire. Falls back to navigateToReader when no view is open.
Also adds a console.log on each parsed deep link to make this path
easier to debug from device logs in the future.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(annotations): deep links for highlight exports
Embed an HTTPS deep link in markdown export so clicking a highlight in
Obsidian / Notion / Mail launches Readest at the exact CFI position.
Mobile App Links / Universal Links open the native app silently when
installed; desktop attempts the readest:// scheme automatically with a
manual fallback.
- Markdown export wraps the page-number text in a per-annotation link:
https://web.readest.com/o/book/{hash}/annotation/{id}?cfi=...
- New /o/... smart landing page handles platform routing (intent:// on
Android Chrome, scheme + visibility-cancel on other Android, auto
scheme + 1 s fallback on desktop, manual button on iOS).
- Reader honors a ?cfi= query param on initial load (overrides the
saved last-position for the primary book only).
- New useOpenAnnotationLink hook handles incoming readest:// and
https://web.readest.com/o/... URLs, including cold-start (getCurrent)
and library-load deferral; supports the legacy flat shape
readest://annotation/{hash}/{id} from previous Readwise syncs.
- ReadwiseClient now emits the HTTPS deep link instead of the legacy
custom scheme.
- AASA extended with /o/* matcher; Android intent-filter for the host
has no pathPrefix so it already covers it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* i18n: translate annotation deep-link strings across all locales
Translates the 13 new keys introduced for the annotation deep-link
feature into all 31 supported locales. Replaces all 403
__STRING_NOT_TRANSLATED__ placeholders.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(reader): custom dictionaries (StarDict + MDict)
Adds a pluggable dictionary provider system. Built-in Wiktionary +
Wikipedia (extracted from the legacy single-popup model into a tabbed
shell) plus user-importable StarDict (.ifo/.idx/.dict.dz/.syn) and MDict
(.mdx/.mdd) bundles.
Settings → Language → Dictionaries: import / enable / drag-reorder /
delete (delete-mode toggle mirrors CustomFonts). Drag uses @dnd-kit with
pointer/touch/keyboard sensors. Reader popup: tabbed UI, per-tab lookup
history, scroll-aware back button, last-active tab persists. Tabs grow
to natural width up to a cap, truncate with ellipsis when crowded; phantom
bold layer prevents layout shift on focus.
StarDict reader is self-contained (replaces unused foliate-js/dict.js),
with lazy random-access binary search on .idx + .syn (~420 KB Int32Array
of byte offsets vs ~10 MB of parsed JS objects), lazy DictZip chunk
decompression via fflate streaming Inflate (cmudict/eng-nld both
chunked), and an optional .idx.offsets sidecar generated at import to
skip the init scan. Cmudict 105K-entry init drops from ~10 MB heap and
2 MB IO to ~1.7 MB heap and ~500 KB IO.
MDict uses the readest/js-mdict fork (added as a submodule, consumed via
tsconfig paths so deps stay out of readest's pnpm-lock) which adds a
browser-friendly BlobScanner reading via blob.slice(...).arrayBuffer()
— slices are lazy when the Blob is Readest's NativeFile / RemoteFile.
encrypt=2 (key-info-only) MDX is fully supported via ripemd128-based
mdxDecrypt; encrypt=1 (record-block, needs user passcode) surfaces as
unsupported.
Wikipedia annotation tool removed (Wikipedia is now a tab inside the
unified popup); legacy WiktionaryPopup / WikipediaPopup deleted. Stale
annotationQuickAction === 'wikipedia' coerced to 'dictionary' on settings
load. iOS-friendly external links: skip target="_blank" on Tauri to
avoid the WebView's "open externally" path triggering the shell scope
error; the popup's container click handler routes through openUrl.
i18n: 939 strings translated across 31 locales (30 base keys + CLDR
plural forms for ar/he/sl/pl/ru/uk/ro/it/pt/fr/es).
Test fixtures bundled: cmudict (StarDict, 105K entries), eng-nld
(StarDict, smaller), and a Longman Phrasal Verbs MDX (encrypt=2).
3396 unit tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(stardict): resolve fflate from js-mdict source for vitest + Next
js-mdict is consumed as TypeScript source via tsconfig paths from
packages/js-mdict/src/. Its sources `import 'fflate'` directly, but
fflate is only installed under apps/readest-app/node_modules — so
vite's import-analysis (and Next/Turbopack's resolver) can't find
fflate when it walks up from the redirected js-mdict source location.
CI's fresh checkout exposes this; locally a leftover
packages/js-mdict/node_modules/fflate from the old workspace setup
masked it.
Pin fflate resolution to apps/readest-app/node_modules/fflate in:
- vitest.config.mts (Vite alias)
- next.config.mjs (webpack alias + Turbopack resolveAlias — Turbopack
rejects absolute paths so use a project-relative form)
- tsconfig.json (paths entry so tsgo / Biome see it)
Verified by deleting packages/js-mdict/node_modules locally and
re-running pnpm test (3396 pass), pnpm lint (clean), and both
pnpm build-web and a tauri-platform Next build (clean).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The paginator's scrolled-mode scroll handler is debounced 250 ms, so
#anchor and #primaryIndex can lag behind the user's actual viewport.
Toggling out of scrolled mode within that window made
render() → scrollToAnchor(#anchor) restore the stale anchor, reverting
the position to a previously visible section.
Update foliate-js to flush the pending scroll state before flow leaves
'scrolled', and add regression coverage for the multi-section toggle path.
Try an exact ZIP entry match first, then fall back to a case-insensitive lookup for EPUB resources when archive entry casing differs from manifest paths.
Keep ambiguous case-only duplicates exact-match only, and add a regression test that opens a real EPUB through DocumentLoader.
Update foliate-js to preserve the current intra-page scroll anchor when fixed-layout pages relayout or replace placeholders in scrolled mode, instead of snapping back with scrollIntoView().
Add regression coverage for the scroll-anchor restore path.
Closes#3876.
iOS WKWebView's in-flight audio.play() rejects with AbortError after audio.src is reset
during a stop+restart cycle. That rejection leaks through one of the .catch chains into
TTSController.error(), which unconditionally flipped state to 'stopped' — desyncing the
state machine so subsequent rate changes fell into the no-op else branch and #speak's
auto-forward gate stopped firing at paragraph end.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In multi-line PDF selections, pdf.js renders each text run as its own <span>
and inserts <br role="presentation"> at line endings. getTextFromRange only
walked text nodes, so <br>s were dropped and adjacent line-final/line-initial
words glued together (e.g. "lastfirst") in highlights, notes, and AI inputs.
Walk elements alongside text nodes and emit "\n" for <br>, mirroring how
Selection.toString() handles line breaks.
* feat(opds): add OPDS-PSE streaming support and custom OPDS 2.0 parser
* refactor(opds): remove custom parser, use updated foliate-js dependency
* fix(opds): resolve PSE auth at fetch time, drop credentials from book.url
Previously the streaming `pse://` virtual file baked the proxy URL with
the basic auth header into `book.url`, which (a) failed on desktop where
no proxy is used because the auth header was never applied to the page
fetch, and (b) leaked the credential to the sync server because transient
books still get pushed on first sync.
Now the `pse://` payload stores only the upstream OPDS template URL plus
the catalog id. A new `createPseStreamPageLoader` looks up the catalog
from settings on each open, probes auth once (cached for the session),
and applies the auth header via `tauriFetch` on desktop or via the proxy
URL on web.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a book's underlying file is missing, opening it in a dedicated
reader window showed an error toast then navigated that window to
/library, leaving a leftover library-in-reader-window the user had
to close manually. Route the recovery through a new
closeReaderWindowOrGoToLibrary() that closes the dedicated reader
window (after ensuring the main library window is visible) and only
falls back to /library navigation in the main window or on web.
Also fix a related macOS issue: the reader's CloseRequested handler
was running handleCloseBooks and calling currentWindow.destroy() on
the main window, which tore down the active book and bypassed the
Rust close-to-hide handler — making Cmd+W / traffic-light close
quit the app from the reader page (vs. correctly hiding from the
library page) and lose the active book even when the window did
hide. Skip both the cleanup and destroy on macOS for the main
window so the Rust handler hides it with the book intact, matching
the macOS minimize-to-dock convention.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On Android, long-press selects text via selectionchange while the finger
is still on the screen. The quick action handler was gated by
androidTouchEndRef and silently returned, so no popup ever opened. After
the user lifted, nothing re-ran the gated action.
Track the gated action in a small DeferredActionState ref and flush it
from the native touchend handler, so instant copy/dictionary/wikipedia/
search/translate/tts now fire on the first long-press release.
The browser delivers one large quantised delta per wheel notch, which
Chromium scrolls without interpolation — producing the jerky one-step
motion reported on Windows. Detect mouse-wheel-shaped events inside
the iframe (line-mode, or single-axis with |deltaY| ≥ 50), suppress
the native scroll, and replay the delta as an rAF exponential lerp on
the renderer's container. Trackpad / high-resolution input is left to
native scrolling so its momentum and 2-axis behaviour are preserved.
When the main window has been destroyed (Windows/Linux default close), the
reader's "go to library" button only closed the reader, leaving no library
visible. Add ensureMainLibraryWindow() that shows an existing main window
or recreates one with the 'main' label so the existing close-reader-window
wiring keeps working. Also grant the cross-window show/unminimize permissions
the call now needs.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(warichu): support warichu (割注) inline annotation layout
- Add warichu HTML transformer that converts <span class="warichu"> and
<warichu> elements into .warichu-pending placeholders during content load
- Implement runtime layout (layoutWarichu / relayoutWarichu) that measures
column position and splits text into small inline-block chunks (2 chars
each) so CSS column boundaries can break between them, preventing large
blank gaps in vertical-rl pagination
- Use column stride (column-width + column-gap) for accurate position
measurement across column boundaries
- Hook into stabilized event for initial layout and relayout on resize
- Add warichu CSS styles (inline-block chunks, half-size font, vertical align)
* fix(warichu): correct HTML slicing edge cases
- sliceHtml: re-emit tags that were already open before the slice start
so the result stays well-formed. Previously a slice past an opening
tag produced an orphan closing tag (e.g. "<b>Hello</b>"[3,5] →
"lo</b>" instead of "<b>lo</b>").
- sliceHtml / removeFirstVisibleChar / removeLastVisibleChar: treat HTML
entities (e.g. &) as one visible character so they aren't split or
truncated mid-entity (e.g. removeFirstVisibleChar("&rest") →
"amp;rest").
- buildNodes: drop a duplicate chunk.appendChild(l2).
- Add unit tests covering the above for the three pure helpers.
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
* chore(deps): update stripe
* fix: filter and sort not affecting purchases
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* refactor: rename restoredPurchases to restoredSubscriptions
The filter excludes one-time purchases, so the array contains only
subscriptions. Naming reflects that.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JavaScript's `\w` only matches ASCII, so the ORP letter-count regex stripped
all characters from Cyrillic (and other non-Latin) words, producing length 0
and always highlighting the first letter. Use `\p{L}\p{N}_` with the `u` flag
so letters from any script count toward the word length.
* Fix ZoomLabel not showing during pinch zoom
* fix: exponential wheel zoom and show ZoomLabel during pinch
- Use exponential wheel zoom (Math.exp) instead of additive for smoother PC scroll
- Show ZoomLabel during pinch start and pinch move
- Clean up unused setZoomSpeed state for wheel handler
* feat: auto-download new items from subscribed OPDS catalogs
Add opt-in auto-download per OPDS catalog. When enabled, new publications
are downloaded on app startup and pull-to-refresh. Dedup via Atom <id>.
- Extend foliate-js parser to surface entry id/updated fields
- Add subscription state to OPDSCatalog type
- New headless opdsSyncService with navigation feed crawling
- Auto-download toggle in CatalogManager UI
- Wire sync into startup and pull-to-refresh hooks
- 16 new test cases
Built with an AI code agent.
Closes#3836
* chore: point foliate-js submodule to fork with OPDS id/updated fields
CI needs to fetch the foliate-js commit that adds id and updated field
extraction from OPDS feeds. Point submodule URL to our fork where the
commit is pushed.
* feat(opds): rearchitect auto-download with two-phase OPDS sync
Replaces the monolithic opdsSyncService from #3837 with a separated
discovery + acquisition pipeline. Subscription state lives in
per-catalog JSON files under Data/opds-subscriptions/, decoupled from
catalog config.
Discovery (services/opds/feedChecker.ts) resolves a catalog URL down
to its "by newest" feed via three detection tiers — rel="http://opds-
spec.org/sort/new" (Calibre / Calibre-Web), title heuristics
("Newest", "Recently Added", "Latest"), and href patterns
(?sort_order=release_date for Project Gutenberg, /new-releases for
Standard Ebooks). It then walks only that feed plus rel=next
pagination, never the full navigation tree. When no by-newest feed is
found, the catalog is skipped with a warning instead of silently
mirroring everything.
Acquisition link selection filters to safe rels
(acquisition / acquisition/open-access — drops buy / borrow /
subscribe / sample / indirect), prefers open-access when both rels
are present, then ranks by format tier:
0 Advanced EPUB / EPUB 3 (link title, .epub3 href, version=3.x)
1 plain EPUB
2 MOBI / AZW / AZW3
3 PDF / CBZ
4 anything else
Within a tier, ties resolve by feed order. When the upstream omits or
mis-declares (octet-stream) the link's media type, format is inferred
from href extension and link title. Entries that appear in both
feed.publications and a group are deduped within the same batch.
Orchestration (services/opds/autoDownload.ts) runs catalogs
sequentially — per-catalog concurrency already caps parallel downloads
at 3, so a parallel fan-out across catalogs would be N×3 simultaneous
requests on cellular. Pending and retry items are deduped by entryId,
and entries still inside their exponential-backoff window are skipped
instead of re-attempted (avoids appending duplicate failedEntries
records). Filenames come from the last path segment, capped at 200
chars, with a try/catch around decodeURIComponent for malformed
%-sequences.
Hook (hooks/useOPDSSubscriptions.ts): startup, 5-minute interval, and
'check-opds-subscriptions' event triggers. Toggling auto-download on
fires the event so the sync runs immediately. Newly imported books
are queued for cloud upload via transferManager when the user is
logged in and settings.autoUpload is on, mirroring the manual OPDS
download path. 'opds-sync-complete' is dispatched after every sync so
listening UI can refresh without polling. loadSubscriptionState
self-heals duplicate failedEntries from older state files.
The submodule URL is reverted to readest/foliate-js (the changes
needed for OPDS id/updated extraction are already in main), and
OPDSCatalog is trimmed to only the autoDownload field.
Verbose [OPDS]-prefixed debug logs cover the whole pipeline: feed
fetch → resolve → discovery → per-item download (with magic-byte
dump of the saved file) → import → cloud upload queueing.
34 new tests across opds-feed-checker, opds-auto-download,
opds-subscription-state, opds-types.
Closes#3836.
Supersedes #3837.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Johannes Mauerer <johannes@mauerer.info>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`rsproperties::get` panics when it can't open or parse the
`/dev/__properties__` layout (documented behavior of the crate).
On some older/unusual Android builds (e.g. MediaTek Android 8.1 on
Xiaomi Mipad), this aborts the app with SIGABRT before the main
window is created.
Replace the crate with a direct FFI call to Android's native
`__system_property_get`, which has existed since the earliest
Android versions and returns an error code instead of panicking.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On macOS, closing the last window (Cmd+W or the red traffic light) was
quitting the app, which is unexpected — the native convention is that
Cmd+W closes the window while the app keeps running in the dock, and
only Cmd+Q quits.
Intercept the window CloseRequested event on macOS, prevent the close,
and hide the window instead so the app remains active. Handle the
Reopen event (fired when the user clicks the dock icon) to restore the
hidden window. Cmd+Q continues to go through applicationWillTerminate:
and exits the app normally.
The TOC occasionally flashed a scroll to the current item and then
snapped back to the top, and on slow mobile first-opens sometimes
stayed at the top entirely.
Root cause: `useOverlayScrollbars({ defer: true })` schedules OS
construction via `requestIdleCallback` with a ~2233 ms timeout. On a
busy first open the timeout fires before the browser goes idle, so OS
wraps the viewport late — and the wrap step resets the scroller's
`scrollTop` synchronously, undoing Virtuoso's earlier scroll to the
current item. Virtuoso's `rangeChanged` / `onScroll` don't propagate
the reset for another frame, so any guard based on tracked scroll
state reads stale.
* refactor(toc): cache TOC + section fragments per book
Moves the TOC regrouping and section-fragment computation out of
foliate-js/epub.js #updateSubItems into the readest client as
computeBookNav / hydrateBookNav in utils/toc.ts. The result is
persisted to Books/{hash}/nav.json — capturing the book's full
navigable structure (TOC hierarchy + sections with hierarchical
fragments). Compute once, persist locally, hydrate on subsequent
opens. Designed to serve current human-facing navigation (TOC
sidebar, progress math) and future agentic navigation (LLM-driven
seeking by structural location).
Versioned by BOOK_NAV_VERSION for forward invalidation. Existing
books regenerate transparently on next open.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: update worktree scripts
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On iOS, navigating to a book group in the library caused the WebKit GPU
process to exceed its 300 MB jetsam limit (peaking at ~328 MB), resulting
in a blank screen flash and broken scroll state.
Three changes reduce peak GPU memory usage:
- Add overscan={200} to VirtuosoGrid/Virtuoso so only items within 200px
of the viewport are rendered, limiting simultaneous image decoding
- Add loading="lazy" to both Image components in BookCover so the browser
defers decoding offscreen cover images
- Conditionally mount the <video> and <audio> elements in AtmosphereOverlay
only when atmosphere mode is active, eliminating idle H.264 decoder
memory overhead
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* perf(store): decouple page turn from full library rewrite for large collections
Previously every page turn triggered setLibrary() which copied the entire
library array, ran refreshGroups() with MD5 hashing over all books, and
caused cascading re-renders. With ~2800 books this made reading unusable.
- Add hash-indexed Map to libraryStore for O(1) book lookups
- Add lightweight updateBookProgress() that skips array copy and refreshGroups
- Use hash index in setProgress, saveConfig, and initViewState
- Batch cover URL generation with concurrency limit on library load
Addresses #3714
* perf(import): replace filter()[0] with find() to short-circuit on first match
* fix(store): replace Object.assign state mutation with immutable spread in setConfig
* perf(persistence): remove JSON pretty-printing to reduce serialization overhead
* fix(reader): stabilize debounce reference in useIframeEvents to prevent timer reset on re-render
* perf(context): memoize provider values to prevent unnecessary consumer re-renders
* perf(store): cache visible library to avoid refiltering on every access
* perf(library): remove redundant refreshGroups call already triggered by setLibrary
* perf(import): replace O(n) splice(0,0) with O(1) push for new book insertion
* perf(import): defer library persistence to end of import batch instead of every 4 books
* perf(library): skip full library reload on reader close since store is already in sync
* fix: address PR review feedback for library perf optimizations
Correctness fixes for issues found in code review:
- fix(library): restore library reload on close-reader-window. Reader
windows are independent Tauri webviews with their own libraryStore
instance, so progress / readingStatus / move-to-front updates from
the reader do not propagate to the main window. Reload from disk
so the library reflects the changes the reader just persisted.
- perf(import): wire BookLookupIndex into importBooks. The lookupIndex
parameter on bookService.importBook had no caller, leaving the
Map-based dedup path dead. Build the index once per import batch
in app/library/page.tsx and thread it through appService.importBook
so the O(1) dedup path is actually exercised.
- perf(import): defer library save to end of batch. Add a skipSave
option to libraryStore.updateBooks and call appService.saveLibraryBooks
once after the entire import loop, instead of once per concurrency-4
sub-batch.
- fix(store): make updateBookProgress immutable. The previous in-place
mutation reused the same library array reference, bypassing Zustand
change detection AND leaving the visibleLibrary cache holding stale
Book references. Now slice the array, update the entry, and refresh
visibleLibrary. Also make readingStatus a required parameter so
future callers cannot accidentally clear it by omitting the argument.
- fix(store): make saveConfig immutable. It previously mutated the Book
object's progress / timestamps in place and used splice/unshift on
the shared library array. Now spread to a new book object and rebuild
via setLibrary. Also corrects the interface signature to return
Promise<void> (the implementation was already async).
- fix(store): make updateBook immutable for the same reason — it was
mutating the previous-state library array before spreading.
- fix(context): wrap AuthContext login/logout/refresh in useCallback.
Without this, the useMemo deps array changed every render and the
memo was a no-op, defeating the optimization the PR was trying to
add.
- fix(reader): use a ref for handlePageFlip in useMouseEvent's debounce.
The empty-deps useMemo froze the first-render handler; with the ref
the debounced wrapper always invokes the latest closure.
Test coverage added:
- library-store: immutable updateBookProgress, visibleLibrary cache
refresh, deleted-book filtering, updateBooks skipSave option
- book-data-store: immutable saveConfig, move-to-front correctness,
visibleLibrary order, persistence behavior
- import-metahash: BookLookupIndex update on new import, lookup-index
consultation before scanning books array
- auth-context (new file): context value identity stability across
re-renders, callback identity stability
- useIframeEvents (new file): debounced wheel handler dispatches to
the latest handlePageFlip after re-render
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(types): move BookLookupIndex to types/book.ts
Avoids the inline `import('@/services/bookService').BookLookupIndex`
type annotation in types/system.ts. Both the AppService interface and
the bookService implementation now import BookLookupIndex from the
canonical location alongside Book.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(import): convert importBook params to options object
Replace the long positional-argument list on appService.importBook
(saveBook, saveCover, overwrite, transient, lookupIndex) with a
single options object so callers no longer need to pad with
`undefined, undefined, undefined, undefined` to reach the parameter
they actually want to set.
Before:
await appService.importBook(file, library, undefined, undefined,
undefined, undefined, lookupIndex);
After:
await appService.importBook(file, library, { lookupIndex });
The underlying bookService.importBook is also refactored to take an
options object: required AppService callbacks (saveBookConfig,
generateCoverImageUrl) are bundled with the optional flags via an
ImportBookInternalOptions interface that extends the public
ImportBookOptions defined in types/book.ts.
All existing call sites updated to the new shape.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The breadcrumb "All" button was broken on first click after entering a
group because next-view-transitions@0.3.5's useTransitionRouter wraps
router.replace() in startTransition + document.startViewTransition, and
this combination is incompatible with Next.js 16.2 RSC navigation when
only the search params change for the same pathname (e.g.
/library?group=foo -> /library). The navigation silently never commits.
Extract the library navigation logic into a useLibraryNavigation hook
that uses plain useRouter from next/navigation. The data-nav-direction
attribute is still set so existing directional CSS keeps working when
view transitions fire via popstate.
See https://github.com/shuding/next-view-transitions/issues/65 for the
upstream incompatibility.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PDF.js TextLayer renders <br> between text spans for visual line wrapping.
The TTS SSML generator was converting these to <break> elements, causing
TTS engines to pause at every PDF line break within paragraphs. Fix by
rejecting <br> (along with canvas and annotationLayer) via the node filter
when the document is detected as a PDF.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The `pages` getter used Math.ceil(viewSize / containerSize) which inflates
the count by 1 when floating-point drift makes the ratio slightly above an
integer (e.g. 4.00000001 → 5). Use Math.round to absorb sub-pixel drift,
matching the approach already used in #getPagesBeforeView.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the inline touch-swipe event dispatching with a module-level
interceptor registry. This lets the reading ruler (priority 10) claim
drag gestures before the swipe-to-flip handler (priority 0), preventing
conflicts when dragging the ruler on touch devices.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* security: fix for code scanning alert no. 11: Incomplete multi-character sanitization
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* fix: use dotAll flag to match multi-line HTML comments
Add the 's' flag to the comment-stripping regex so '.' matches
newlines, ensuring comments spanning multiple lines are also removed.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix: iterative dotAll sanitization in extractChaptersFromSegment
Fixes code scanning alert #10 (incomplete multi-character sanitization).
Apply the same fix as alert #11: replace one-shot comment stripping
with an iterative loop using the 's' (dotAll) flag so nested and
multi-line HTML comments are fully removed.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix: iterative HTML tag sanitization in cleanDescription
Fixes code scanning alert #9 (incomplete multi-character sanitization).
Replace one-shot tag stripping with an iterative loop so crafted inputs
like nested/overlapping tags cannot leave '<script' behind after a single
replacement pass.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* security: fix for code scanning alert no. 11: Incomplete multi-character sanitization
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* fix: use dotAll flag to match multi-line HTML comments
Add the 's' flag to the comment-stripping regex so '.' matches
newlines, ensuring comments spanning multiple lines are also removed.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat: Add option to split words in RSVP mode
* fix(rsvp): replace lookbehind regex with lookahead-only split in getHyphenParts
* feat: Add option to split words in RSVP mode
* fix(theme): update data-theme and themeCode when system theme changes
* feat(rsvp): split on ellipsis between letters and preserve delimiter type
* fix(rsvp): fix incorrect merged line
* feat(rsvp): insert blank frame between consecutive identical words
* refactor(sidebar): replace react-window and OverlayScrollbars with react-virtuoso and CSS scrollbars
* feat(toc): smooth scroll to active chapter on sidebar open
* test(theme-store): expand dark theme palette fixture with full color tokens
* refactor: remove dead code and consolidate duplicate CSS scrollbar rules
* fix(toc): fix auto-scroll on sidebar open and improve scroll behavior
- Add isSideBarVisible to scroll effect deps so it fires on sidebar open
- Use setTimeout delay for Virtuoso to finish layout before scrolling
- Add 10s cooldown on user scroll to re-enable auto-scroll
- Use smooth scroll for short distances (<16 items), instant for longer
- Track visible center via rangeChanged for accurate distance calculation
- Fix tab navigation background opacity (bg-base-200 instead of /20)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore(security): update CodeQL workflow to remove languages
Removed unsupported languages from CodeQL workflow.
* style: format codeql.yml with Prettier
Fix CI format check failure by applying Prettier formatting to the
CodeQL workflow file.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Validate serverUrl in the kosync API proxy to block requests to
private/internal addresses and non-http(s) schemes. Also fix isLanAddress
to detect 0.0.0.0 and bracket-wrapped IPv6 private addresses.
Closes code-scanning alert #14.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Support HTTP Basic auth for kosync connections
* fix(kosync): use separate password field for HTTP Basic auth
- Add `password?` field to KOSyncSettings to store the plain password
alongside the existing `userkey` (md5 hash), preserving backward
compatibility for existing users
- Replace Buffer.from() with btoa() for browser-compatible Base64 encoding
- Simplify buildHeaders() to use config fields directly instead of
the useAuth union type
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Closes#3770.
Add transformStylesheet rule that clips CSS width declarations exceeding the
viewport with max-width and border-box sizing. Also add @testing-library/react
to vitest browser optimizeDeps.include to prevent mid-test Vite reloads on CI.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Wiktionary REST API does not support Chinese entries — simplified
characters return 404, traditional characters omit Chinese results, and
no pronunciation data is ever included. Switch Chinese lookups to the
Wiktionary Action API which returns full wikitext with pinyin and
definitions. Also add encodeURIComponent to the REST API URL for all
other languages.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat(hardcover): add one-way Hardcover sync integration
- Add HardcoverClient with ISBN/title-based book lookup, progress push, and note sync
- Add HardcoverSyncMapStore for persistent local mapping of note IDs to Hardcover journal IDs
- Add GraphQL queries/mutations for Hardcover API (insert/update/recreate journal entries)
- Add DB migration for hardcover_note_mappings table (schema: hardcover-sync)
- Add HardcoverSettings dialog component (connect/disconnect/enable toggle)
- Add useHardcoverSync hook wired in Annotator for push-notes and push-progress events
- Add Hardcover Sync section in BookMenu with per-book toggle (persisted in BookConfig)
- Add HardcoverSettings type and DEFAULT_HARDCOVER_SETTINGS to system settings
- Add hardcoverSyncEnabled per-book flag to BookConfig
- Mount HardcoverSettingsWindow in Reader alongside KOSync and Readwise windows
Sync is one-way (Readest → Hardcover), manual-action only, per-book opt-in.
Supports idempotent note sync: insert new, skip unchanged, update changed, recreate on stale remote ID.
* fix(hardcover): proxy API calls server-side to fix CORS, normalize Bearer token
- Add /api/hardcover/graphql Next.js route that proxies POST requests server-side,
bypassing CORS restrictions (Hardcover API has no Access-Control-Allow-Origin header)
- On Tauri (desktop), calls Hardcover directly; on web, routes through the proxy
- Normalize token in HardcoverClient: accept raw JWT or 'Bearer <jwt>' format
- Update helper text to point to hardcover.app → Settings → API
* fix(hardcover): surface note-sync no-ops and harden book resolution
- Show toast feedback when book data is still loading, Hardcover is not configured,
or the current book has no annotations/excerpts to sync
- Show an explicit info toast when note sync finds no new changes
- Parse Hardcover search results in the current hits/document response shape
- Resolve note sync through ensureBookInLibrary for parity with progress sync
- Add console logging for note/progress sync failures
* debug(hardcover): add runtime instrumentation for note sync
* feat(metadata): keep identifier stable and store ISBN separately
- Add dedicated metadata.isbn field
- Expose ISBN as its own editable field in book metadata
- Preserve identifier semantics for existing source IDs and hashes
- Route metadata auto-retrieval ISBN handling through the new field
- Prefer metadata.isbn for Hardcover matching
* fix(hardcover): avoid wasm sqlite for note mappings on web
- Store Hardcover note sync mappings in localStorage on web
- Keep sqlite-backed mappings for desktop/native environments
- Remove the web-only database dependency from manual note sync
* fix(hardcover): dedupe notes by payload hash across unstable note IDs
- Add payload-hash lookup in HardcoverSyncMapStore
- Reuse existing journal mapping when payload already synced
- Prevent duplicate insertions when note IDs change or duplicate locally
* fix(hardcover): avoid duplicate quote export when annotation note exists
- Detect excerpt+annotation pairs for the same highlight
- Skip standalone excerpt export when annotation has note text
- Keep annotation export as the single source of truth
* docs(hardcover): add consolidated change summary for review
* fix(hardcover): suppress excerpt export by CFI when annotation note exists
* fix(hardcover): suppress empty-note annotation duplicates when note exists
* fix(hardcover): deduplicate notes by text and cfi base node
* refactor(hardcover): optimize sync performance, add rate limiting and clean up debug tools
* chore: remove dev-only change log from main
* test(hardcover): add unit tests for sync mapping and client logic
* chore: custom deployment and UI fixes
* fix(hardcover): use timestamptz for accurate annotation time
* fix(hardcover): use date scalar and RFC 3339 formatting for journal entries
* Revert "chore: custom deployment and UI fixes"
This reverts commit 0329aba7129d1e1ebf2c663804b8fba9a9f87b91.
* Fix hardcover progress dates and surface imported ISBNs
* Fix Hardcover currently reading sync
* fix(hardcover): avoid promoting note sync status
* style(hardcover): apply prettier formatting
* test(hardcover): fix strict TypeScript assertions
* test(hardcover): harden sync regression coverage
* test(hardcover): fix lint and formatting regressions
* fix(hardcover): narrow note dedupe range matching
* refactor(hardcover): extract note dedupe helpers
* refactor(isbn): extract metadata normalization helpers
* feat(hardcover): improve synced quote formatting
* feat(hardcover): sync progress by edition percentage
* fix(hardcover): reuse active read on first sync
* style(hardcover): apply formatting fixes
* fix(hardcover): address adversarial review findings
- null-guard insert_user_book response: throw with error message
when the API returns a null user_book instead of crashing on
property access
- skip progress push when Hardcover edition page count is unknown:
getHardcoverProgressPages now returns null when context.pages and
context.bookPages are both null (title-search path); pushProgress
exits early instead of silently sending an inaccurate local page
number
- apply edition preference for title-search books: after
searchBookByTitle resolves a book ID, fetch QUERY_GET_BOOK_USER_DATA
via editions(where: { book_id: ... }) to retrieve the user's active
read edition and user_book, then apply the same active-read-edition
-> user_book-edition waterfall used for ISBN lookups
- add regression tests for all three fixes
- verify all query/mutation field names against hardcover schema.graphql
* fix(ui): restore highlight options layout and clean up color name editing
Restore the pre-#3741 layout for both the AnnotationPopup highlight
options row and the HighlightColorsEditor settings panel.
HighlightOptions: re-add `justify-between` on the outer container and
remove `flex-1` from the color strip so the gap between the style box
and color strip responds to the number of colors.
HighlightColorsEditor: restore `grid-cols-3 sm:grid-cols-5` grid,
remove always-visible name inputs, and add a click-to-edit popover on
each color circle with hover tooltip for the label.
Add a browser screenshot test that renders the real AnnotationPopup
component with Tailwind CSS and compares against baseline PNGs for
5, 10, and 15 colors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The translate.toil.cc relay that backs the yandex provider is currently
unavailable. Introduce a `disabled` flag on TranslationProvider and
filter disabled providers out of both `getTranslators()` and
`getTranslator()` so the settings panel, translator popup, and the
fallback logic in `useTranslator` all agree the provider doesn't exist —
no per-callsite changes needed. Flip the flag back (or delete the line)
on `yandexProvider` to re-enable once the upstream is healthy.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Guard handleTTSSpeak with a single-flight ref so a second tts-speak
event that arrives while the first is still inside its initial awaits
(initMediaSession / backgroundAudio / TTSController.init) is ignored
instead of racing ahead to construct a second TTSController. Without
this, rapid clicks produced two concurrent speakers talking over each
other because viewState.ttsEnabled is only set at the end of the first
invocation, so the footer bar would dispatch tts-speak twice.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a background texture is active, skip applying the overridden bg/fg/border
colors on block elements so the texture stays visible instead of being painted over.
On mobile tablets/foldables (Android or iOS) in portrait, the viewport
width can exceed the Tailwind \`sm:\` breakpoint (640px), causing the
desktop footer bar to show and hiding the brightness / font size /
color / progress panels that only exist in the mobile layout.
The previous fix in #3746 introduced a regression on phones: wrapping
MobileFooterBar's children in a \`<div>\` changed the flex layout of
the footer container, and panels no longer slid cleanly behind the
navigation bar on dismissal. That PR was reverted.
This re-implementation scopes the override narrowly:
- \`forceMobileLayout\` is only true for mobile devices in portrait
with innerWidth >= 640. Phones (innerWidth < 640) always get
\`false\`, so every \`!forceMobileLayout && '…'\` expression
evaluates to the original class string — phone classNames are
set-equal to the pre-#3746 version.
- MobileFooterBar keeps its Fragment return; no wrapper div is
introduced anywhere in the tree, preserving the panel slide-down
animation exactly as before.
- DesktopFooterBar, NavigationBar, and the three panels
(ColorPanel / FontLayoutPanel / NavigationPanel) gate their
\`sm:hidden\` / \`sm:flex\` classes on \`!forceMobileLayout\` so
they correctly show/hide on wide portrait tablets.
- The orphaned \`.force-mobile-layout\` CSS override in globals.css
is removed since we no longer rely on a class-based escape hatch.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The sidebar delete handler always removed annotations through the note
bubble overlay key (`NOTE_PREFIX + cfi`), but highlights are keyed by
the raw CFI. Deleting a highlight from the sidebar therefore left its
overlay drawn until the book was reopened, while the popup path — which
lets `wrappedFoliateView` default the value to the CFI — worked.
Introduce `removeBookNoteOverlays` that mirrors the draw filters in
`Annotator.tsx` and clears every overlay a BookNote can own (highlight
and/or note bubble), and route the sidebar delete through it.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use string-parsed year to avoid UTC/timezone boundary issues
* feat: Add option to split words in RSVP mode
* fix: change the checkbox to a toggle
* fix(rsvp): speed up countdown from 2.4s to 1.5s
* feat(rsvp): skip to next page when no readable text is found
* fix(rsvp): replace lookbehind regex with lookahead-only split in getHyphenParts
* fix: add highlight color label fields
* fix: add default highlight label sync fields
* fix: add highlight prefs sync helpers
* fix: add highlight color name inputs
* fix: persist and sync highlight color names
* fix: add highlight color label helpers
* fix: add long press highlight label preview
* fix: pull highlight color prefs during library sync
* test: cover highlight color label helpers
* fix: widen highlight color name inputs
* fix: show highlight names on hover and touch hold
* fix: prevent highlight name input overlap
* fix: improve highlight name input responsiveness
* fix: support drag scrolling for highlight colors
* fix: batch custom color and label updates
* fix: serialize highlight prefs saves
* fix: align color strip drag and restore color clicks
* fix: translate default highlight color labels
* refactor: remove highlight preference sync wiring
* fix: align highlight option i18n with existing pattern
* fix: remove redundant english highlight keys
* fix: support raw and normalized highlight label keys
* fix: use underscore translator in highlight options
* fix: translate custom highlight color labels in editor
* refactor: simplify highlight settings persistence
* fix: maintianer review
* refactor: simplify highlight prefs save and clean up editor
- Drop the skipUserColors/skipLabels options from handleHighlightPrefsChange
and always persist both arrays; the flags only masked a no-op caller.
- Type handleHighlightColorsChange with Record<HighlightColor, string>
instead of typeof so the signature reads clearly.
- Remove the always-true `|| true` guard around the custom colors section.
- Stop wrapping user-typed custom color labels with _(), matching the
built-in color inputs and keeping the input value equal to what the
user typed.
* refactor: couple highlight labels to their colors
Replace the parallel `highlightColorLabels: Record<string, string>` map
with label storage that lives next to each color. This removes the hex
key normalization layer and its whole class of orphan/case-drift bugs.
Data model:
userHighlightColors: string[] -> UserHighlightColor[]
({ hex, label? })
highlightColorLabels: Record<string, string> -> (removed)
defaultHighlightLabels:
Partial<Record<DefaultHighlightColor, string>>
A `migrateHighlightColorPrefs` helper runs during `loadSettings` and
handles both the shipped `string[]` layout and the draft-build
`highlightColorLabels` layout: hex-keyed labels attach to matching user
colors, name-keyed labels move into `defaultHighlightLabels`. Malformed
entries are dropped.
Editor:
- Label inputs commit on blur (Enter also commits), so typing a long
label no longer fires `setSettings`/`saveSettings` on every
keystroke. A small `LabelInput` component owns the draft state and
syncs if the prop changes externally.
- Three explicit callbacks (`onCustomHighlightColorsChange`,
`onUserHighlightColorsChange`, `onDefaultHighlightLabelsChange`)
replace the previous single callback with opaque skip flags.
Picker:
- Extracted a reusable `useDragScroll` hook (mouse only, 6px
threshold, 120ms click suppression) from the inline state machine
in `HighlightOptions`. The picker drops to ~280 lines.
- Long-press preview stays touch/pen only. It now reads labels via
`getHighlightColorLabel` (which returns `undefined` when no user
label is set), letting the component layer decide whether to fall
back to a translated default name.
i18n:
- Default color names ('red' | 'yellow' | 'green' | 'blue' | 'violet')
are registered once at module scope via `stubTranslation` and
translated at the picker layer through `useTranslation`. User-typed
labels are never run through `_()`, so what the user types is what
the editor shows.
Tests:
- `annotator-util.test.ts`: rewritten around the new helper contract
(user label -> undefined fallback) and case-insensitive hex matching.
- `settings-highlight-migration.test.ts`: new, covers legacy
`string[]`, already-migrated entries, malformed hex filtering, and
the two draft-label fold paths.
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Add Hungarian (hu) as a new supported locale with full translation coverage.
Also translate the new "Toggle Toolbar" key across all 30 existing locales.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Hardcover GraphQL API rejects requests from tauri://localhost origin on iOS.
Switch to tauriFetch (like KOSyncClient) to bypass CORS in Tauri environments.
Also coerce bookId/editionId/pages from search results to numbers since the
search API returns string IDs but the GraphQL mutations expect 32-bit integers.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
preloadNextSSML() called tts.next() interleaved with await, creating async
gaps where the concurrent #speak() could dispatch marks against corrupted
#ranges state (replaced by next() for a different block). Since mark names
restart at "0" per block, marks resolved to the wrong text position, causing
accidental page turns and highlights far from the current sentence.
Fix: gather all tts.next() and tts.prev() calls synchronously (no await
between them) so no async code can see the intermediate #ranges state.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On Android tablets/foldables in portrait, screen width can exceed 640px
causing the desktop footer bar to show. Now use portrait orientation
detection to force the mobile footer bar layout on Android regardless
of screen width.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(android): add D-pad navigation for Android TV remote controller
Add basic D-pad/arrow key navigation support for Android TV Bluetooth
remote controllers, covering the full flow: library grid browsing,
reader page turning, toolbar interaction, and TTS toggle.
Library:
- Custom spatial navigation hook for grid D-pad navigation
- Arrow keys move between BookshelfItem elements with auto column detection
- ArrowDown from header enters the bookshelf grid
Reader:
- Enter key toggles header/footer toolbar visibility
- Left/Right navigate between toolbar buttons, Up/Down moves between
header and footer bars
- Back button dismisses toolbar (with blur) before sidebar/library
- Focus-probe technique for reliable button visibility detection
across fixed/hidden containers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: only auto-focus toolbar buttons on keyboard activation, not mouse hover
Track pointer activity to distinguish keyboard vs mouse toolbar activation.
When the toolbar appears due to mouse hover, skip auto-focus to prevent
unwanted focus outlines on desktop.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: track keyboard events instead of pointer events for auto-focus guard
Pointer events from iframes don't bubble to the main document, so
tracking pointermove/pointerdown missed hover interactions over book
content. Track keydown instead — auto-focus only when a recent keyboard
event (Enter key) triggered the toolbar, not mouse hover.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(hardcover): add one-way Hardcover sync integration
- Add HardcoverClient with ISBN/title-based book lookup, progress push, and note sync
- Add HardcoverSyncMapStore for persistent local mapping of note IDs to Hardcover journal IDs
- Add GraphQL queries/mutations for Hardcover API (insert/update/recreate journal entries)
- Add DB migration for hardcover_note_mappings table (schema: hardcover-sync)
- Add HardcoverSettings dialog component (connect/disconnect/enable toggle)
- Add useHardcoverSync hook wired in Annotator for push-notes and push-progress events
- Add Hardcover Sync section in BookMenu with per-book toggle (persisted in BookConfig)
- Add HardcoverSettings type and DEFAULT_HARDCOVER_SETTINGS to system settings
- Add hardcoverSyncEnabled per-book flag to BookConfig
- Mount HardcoverSettingsWindow in Reader alongside KOSync and Readwise windows
Sync is one-way (Readest → Hardcover), manual-action only, per-book opt-in.
Supports idempotent note sync: insert new, skip unchanged, update changed, recreate on stale remote ID.
* fix(hardcover): proxy API calls server-side to fix CORS, normalize Bearer token
- Add /api/hardcover/graphql Next.js route that proxies POST requests server-side,
bypassing CORS restrictions (Hardcover API has no Access-Control-Allow-Origin header)
- On Tauri (desktop), calls Hardcover directly; on web, routes through the proxy
- Normalize token in HardcoverClient: accept raw JWT or 'Bearer <jwt>' format
- Update helper text to point to hardcover.app → Settings → API
* fix(hardcover): surface note-sync no-ops and harden book resolution
- Show toast feedback when book data is still loading, Hardcover is not configured,
or the current book has no annotations/excerpts to sync
- Show an explicit info toast when note sync finds no new changes
- Parse Hardcover search results in the current hits/document response shape
- Resolve note sync through ensureBookInLibrary for parity with progress sync
- Add console logging for note/progress sync failures
* debug(hardcover): add runtime instrumentation for note sync
* feat(metadata): keep identifier stable and store ISBN separately
- Add dedicated metadata.isbn field
- Expose ISBN as its own editable field in book metadata
- Preserve identifier semantics for existing source IDs and hashes
- Route metadata auto-retrieval ISBN handling through the new field
- Prefer metadata.isbn for Hardcover matching
* fix(hardcover): avoid wasm sqlite for note mappings on web
- Store Hardcover note sync mappings in localStorage on web
- Keep sqlite-backed mappings for desktop/native environments
- Remove the web-only database dependency from manual note sync
* fix(hardcover): dedupe notes by payload hash across unstable note IDs
- Add payload-hash lookup in HardcoverSyncMapStore
- Reuse existing journal mapping when payload already synced
- Prevent duplicate insertions when note IDs change or duplicate locally
* fix(hardcover): avoid duplicate quote export when annotation note exists
- Detect excerpt+annotation pairs for the same highlight
- Skip standalone excerpt export when annotation has note text
- Keep annotation export as the single source of truth
* docs(hardcover): add consolidated change summary for review
* fix(hardcover): suppress excerpt export by CFI when annotation note exists
* fix(hardcover): suppress empty-note annotation duplicates when note exists
* fix(hardcover): deduplicate notes by text and cfi base node
* refactor(hardcover): optimize sync performance, add rate limiting and clean up debug tools
* chore: remove dev-only change log from main
* test(hardcover): add unit tests for sync mapping and client logic
* chore: custom deployment and UI fixes
* fix(hardcover): use timestamptz for accurate annotation time
* fix(hardcover): use date scalar and RFC 3339 formatting for journal entries
* Revert "chore: custom deployment and UI fixes"
This reverts commit 0329aba7129d1e1ebf2c663804b8fba9a9f87b91.
* Fix hardcover progress dates and surface imported ISBNs
* Fix Hardcover currently reading sync
* fix(hardcover): avoid promoting note sync status
* style(hardcover): apply prettier formatting
* test(hardcover): fix strict TypeScript assertions
* test(hardcover): harden sync regression coverage
* test(hardcover): fix lint and formatting regressions
* fix(hardcover): narrow note dedupe range matching
* refactor(hardcover): extract note dedupe helpers
* refactor(isbn): extract metadata normalization helpers
* feat(hardcover): improve synced quote formatting
When the Android native TTS engine is paused or stopped but not shut down,
it holds resources and drains battery. This adds a 30-minute idle timer that
automatically shuts down the TextToSpeech engine and MediaPlaybackService
after inactivity. The engine transparently re-initializes on next use.
Also adds missing androidx.lifecycle:lifecycle-process dependency to fix
ProcessLifecycleOwner build error.
Closes#3713
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Hidden easter egg: re-clicking the active light mode sun icon
or dark mode moon icon activates an ambient atmosphere with a
komorebi leaf shadow video overlay and forest background audio. Audio
adapts to theme: birds for light mode, crickets for dark mode.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(rsvp): rename pause setting to punctuation delay for clarity
* fix(rsvp): update chapter header as user crosses TOC sections
* fix(rsvp): stop playback at end of book instead of restarting last chapter
* feat(rsvp): convert WPM badge to speed selector dropdown
* feat(rsvp): scroll active chapter to center when chapter dropdown opens
* fix(rsvp): show correct chapter name in header instead of "Select Chapter"
* fix(rsvp): start from selected chapter when switching via dropdown
* fix(rsvp): restore saved position when resuming from a different section
* fix(rsvp): remove shrink animation from header buttons on click
* refactor(rsvp): process one spine section at a time, greadtly reducing complexity
* refactor(rsvp): remove dead state and derive progress from currentIndex
* feat(rsvp): allow pausing during countdown
* fix(rsvp): use relocate event instead of fixed 500ms delay for chapter transitions
* fix(rsvp): persist WPM and punctuation pause settings across sessions
* fix(rsvp): prefix unused bookKey field with underscore to satisfy lint
* Revert "fix(rsvp): prefix unused bookKey field with underscore to satisfy lint"
This reverts commit 7b3a34813f0c3771e9af8d00c7c7dc77c8db161c.
* fix(rsvp): remove unused bookKey field from RSVPController
* feat(rsvp): make progress bar draggable
* fix: revert settings.json changes
* fix(rsvp): restore genuinely useful comments
* i18n: update translations
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Paginate Supabase select queries in the storage stats API to avoid
the 1000 row default limit. Align StorageManager's formatFileSize
with useQuotaStats calculation so quota values display consistently.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add "Full sync all annotations" menu item that pushes and pulls all
annotations regardless of the last sync timestamp, enabling users to
sync old highlights that were created before the plugin was installed.
Changes:
- Add full_sync parameter to push/pull that bypasses timestamp filter
and uses since=0 for pulling all server annotations
- Deduplicate by annotation ID alongside position-based dedup
- Store server ID on pulled annotations and reuse it when pushing
- Parse ISO 8601 timestamps from server to preserve original
created/updated dates instead of using current time
- Resolve KOReader page numbers from xpointers via getPageFromXPointer
- Resolve Readest page numbers from CFI via getCFIProgress on pull
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(library): always sort series books by index ascending, closes#3709
When grouping by series, the sort direction (asc/desc) was applied to
within-series book sorting, causing books to appear in reverse series
index order when the library was sorted descending. Now series index
sort is always ascending (1, 2, 3…) regardless of the global sort
direction. Also sort series groups by name when "Sort by Series" is
selected instead of falling back to updatedAt.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(library): skip merge sort when inside a series group
The allItems.sort at the end of sortedBookshelfItems was re-sorting
books using the regular bookSorter (e.g. Date Read), which overrode
the within-group sort that correctly prioritized series index. Now
when inside a group, the already-sorted books are returned directly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add ~290 new unit tests covering utils (lru, diff, css, a11y, walk,
usage, txt-worker), services (EdgeTTSClient, transformers), and
suppress noisy console output across all test files. Rename 27
camelCase test files to dash-case for consistency.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a new TTS tab in settings with media metadata update frequency control
(sentence/paragraph/chapter) to reduce Bluetooth notification spam, and move
TTS highlight settings from Color tab to the new TTS tab. Also add a highlight
opacity setting with live preview in the Color tab.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When TOC entries use fragment-suffixed hrefs (e.g. ch01.xhtml#ch01),
the sectionsMap lookup matched subitems with cfi: undefined instead of
parent sections with valid CFIs. This caused findTocItemBS to map every
annotation to the last TOC entry. Now skip subitems lacking a CFI and
fall back to the base section.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two issues caused embedded EPUB fonts to fail:
1. Adobe font deobfuscation (http://ns.adobe.com/pdf/enc#RC) derived the
XOR key from the wrong UUID. getUUID() picked the first UUID across all
dc:identifier elements (e.g. Calibre's internal UUID) instead of the
book's unique-identifier. Also fixed getElementById not working on
DOMParser-parsed XML (no DTD), and made UUID regex case-insensitive.
2. transformStylesheet replaced generic font families (sans-serif, serif,
monospace) with CSS var() references without fallback values. In
fixed-layout iframes where setStyles doesn't inject the variables,
the undefined var() invalidated the entire font-family declaration
per CSS spec, causing all fonts to fall back to system defaults.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(shortcuts): change bookmark shortcut from Ctrl+D to Ctrl+B, closes#3675
Ctrl+D was bound to both Toggle Bookmark (General) and Dictionary
Lookup (Selection). When text was selected and Ctrl+D pressed, both
actions fired — opening the dictionary AND adding an unwanted bookmark.
Changed bookmark to Ctrl+B/Cmd+B to resolve the conflict. Added a
unit test that detects identical keybinding lists across actions to
prevent this class of bug in the future.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* i18n: mark shortcut section names for translation
Wrap SHORTCUT_SECTIONS values with stubTranslation() so i18next-scanner
picks them up. Translate the 4 new keys (General, Text to Speech, Zoom,
Window) across all 29 locales.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a keyboard shortcuts help dialog toggled with '?' that displays all
shortcuts grouped by section with platform-appropriate key rendering.
Restructure DEFAULT_SHORTCUTS to include i18n descriptions and section
metadata. Translate 37 new keys across 29 locales.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add bidirectional annotation/highlight sync between KOReader and Readest:
- Add xpointer0/xpointer1 fields to BookNote and DBBookNote types for
KOReader XPointer positions alongside Readest's CFI format
- Extend transform layer to pass through xpointer fields to/from DB
- Convert CFI→XPointer on push and XPointer→CFI on pull in useNotesSync,
discarding notes that fail conversion
- Support KOReader's text()[K].N indexed text node format in xcfi.ts for
paragraphs with inline elements (e.g. <a> page anchors)
- Generate KOReader-compatible XPointers: text().N for single text nodes,
text()[K].N only when multiple direct text nodes exist
- Skip cfi-inert elements (injected by Readest at runtime) in XPointer
path building and resolution
- Map highlight colors between KOReader and Readest color systems
- Implement KOReader plugin annotation push/pull with deterministic IDs,
auto-sync on document open/close, and UIManager refresh on pull
- Refactor koplugin into focused modules: syncauth, syncconfig,
syncannotations, selfupdate
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(reader): import Foliate annotations on book open (Linux only), closes#2180
Automatically imports annotations, bookmarks, and reading progress from
Foliate's data files when opening a book on Linux. Uses foliateImportedAt
flag to prevent re-importing on subsequent opens.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor(annotation): move Foliate import into annotation provider pattern
Restructure annotation import as a multi-provider service following the
translators pattern. Foliate becomes a provider under
services/annotation/providers/, making it easy to add more import sources.
Each provider controls its own availability check and skip-if-imported guard.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a "Refresh Metadata" option under Advanced Settings that re-parses
book files to update metadata (series info, language, etc.) for books
imported before these fields were supported.
- Add refreshBookMetadata() in bookService that opens a book file,
re-parses with DocumentLoader, and updates metadata/metaHash/series
without touching updatedAt or user-edited fields
- Add menu item in SettingsMenu with progress display
- Add unit tests for single-book metadata refresh
- Translate new i18n strings across all 29 locales
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three bugs in createChapterRegexps for English txt-to-epub conversion:
1. numberPattern had a capturing group (\d+|...) that leaked single
digits into split() results instead of chapter titles. Changed to
non-capturing (?:\d+|...).
2. combinedPattern wrapped the heading in (?:...) so split() consumed
the title without returning it. Changed to (...) capturing group,
matching the Chinese regex structure.
3. The prefix (?:^|\n|\s) allowed matching Chapter/Section after any
whitespace, causing mid-sentence false positives. Changed to
(?:^|\n) for line-start only.
Added a second-tier regex for bare numbered headings (e.g. "1.1The
Elements of Programming", "1Building Abstractions") commonly found
in academic texts. Dotted numbers allow optional space before title;
single bare digits require immediate title to avoid footnote matches.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract useSwipeToDismiss and usePanelResize hooks from duplicated code
in SideBar and Notebook. Add mobile swipe-to-dismiss drag handle to
Notebook matching SideBar's existing behavior. Fix drag cursor showing
col-resize instead of row-resize during vertical drags.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add formatBlockQuote utility and blockquote nunjucks filter so both
default and custom template exports prefix every line with `>`.
Document all available formatters in the custom template help panel.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(rsvp): add persistent context, display settings, and layout improvements, closes#3333
Add always-visible collapsible context panel, font size adjustment,
ORP color selection, and stable context window that only rebuilds on
scroll. Refactor speed controls into playback row with collapsible
settings behind a gear icon. Translate new i18n keys across 29 locales.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(selfhost): update docker db schema to match FileRecords (#3527)
In #2636, FileRecord was defined to have updated_at field
which is used when it is accessed from the database. But the
local dev setup is missing this field.
This diff adds an updated_at column to match the expectation
* chore(ci): cache system dependency and rustc outputs in ci
* chore(ci): lint script changed from tsc to tsgo
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Azeem Bande-Ali <me@azeemba.com>
In #2636, FileRecord was defined to have updated_at field
which is used when it is accessed from the database. But the
local dev setup is missing this field.
This diff adds an updated_at column to match the expectation
- Sort author groups by last name instead of first name when sorting by author
- Sort series groups by book author instead of series name when sorting by author
- Place ungrouped books before custom groups instead of mixing them together
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
When LANG=C.UTF-8, navigator.language can return 'C' which is not a
valid BCP 47 tag, causing Intl.NumberFormat and toLocaleString to throw.
Normalize POSIX values (C, C.UTF-8, POSIX) to 'en-US' at the source.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- Apply alwaysOnTop setting on reader window init so books opened
in a new window correctly inherit the setting
- Update tauriHandleSetAlwaysOnTop to apply to all open windows
so toggling from any window keeps all windows in sync
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(layout): add top/bottom margin to container in scrolled mode, closes#3463
When showMarginsOnScroll is enabled, the paginator margins are set to 0
but the FoliateViewer container had no compensating padding, causing
header/footer bars to overlap content. Now the container padding aligns
content top to the bottom of the header bar (gridInsets.top + 44px) and
content bottom to the top of the footer bar (52px + safe area padding).
Also fixes the footer bar height constant from 44 to 52 to match
ProgressBar's actual h-[52px].
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore(ci): cache all crates including local path deps in Tauri build
Add cache-all-crates: 'true' to Swatinem/rust-cache so that vendored
local path dependencies (packages/tauri/crates/, packages/tauri-plugins/)
are cached between CI runs instead of being recompiled from scratch.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
RemoteFile (used on all desktop platforms) extended File([]) with empty
data and overrode slice(), text(), arrayBuffer() but not stream(). The
large file path (>8MB) introduced in #3320 uses file.stream() to read
content incrementally, which returned empty data from the base File
class, causing "No chapters detected" for large TXT files.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* chore: bump nodejs version to 24
* fix(sidebar): use position fixed and transform for mobile sidebar
Use position: fixed to prevent horizontal scrolling on the mobile
bottom sheet, and replace style.top with transform: translateY() for
smooth drag performance. Cache element refs to avoid
document.querySelector on every drag frame. Apply the same position:
fixed fix to the notebook panel. Closes#3492
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Set up WebDriver-based testing for the Tauri app with two tiers:
- Vitest browser-mode tests (*.tauri.test.ts) running inside the Tauri WebView
for plugin IPC testing (libsql, smoke tests)
- WDIO E2E tests (*.e2e.ts) for UI-level interaction testing
Key changes:
- Add webdriver Cargo feature gating tauri-plugin-webdriver
- Add runtime capability for remote URLs (webdriver builds only)
- Add vitest.tauri.config.mts and wdio.conf.ts connecting to embedded
WebDriver server on port 4445
- Add shared tauri-invoke helper for IPC from Vitest iframe context
- Add testing documentation in docs/testing.md
The change adds overflow-wrap: break-word to the footnote popup body styles, which ensures long unbreakable strings (like URLs or long words) wrap properly instead of overflowing
the popup container.
* added current time to desktop bar
* added time prototype to footer, needs code cleanup and settings toggle
* fixed settings toggle, added translations and code cleanup
* added battery support and moved Statusbar to own Component
* #3306 added 24 hour clock support
* refactored code styling and getting rid of any type in battery hook
* Add battery info for Tauri Apps
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
* Fix generic font families resolving incorrectly
Replace `serif`, `sans-serif`, and `monospace` keywords in epub stylesheets with their corresponding CSS variables (`var(--serif)`, `var(--sans-serif)`, `var(--monospace)`), ensuring the user's configured fonts are always used.
Fixes https://github.com/readest/readest/issues/3334
* Got rid of the lookbehind
But now we're using a placeholder.
* fix: add missing TXT worker files for large import path
* fix(ios): decode import paths and stabilize large TXT conversion
* style(txt): run prettier on TXT conversion files
* fix(txt): restore chunk iterator API and stream cancel behavior
* fix(txt): avoid unused private segment iterator
* chore: clean up log for unit tests
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
When a native context menu is triggered via right-click, the menu.popup()
call immediately takes control and disrupts the browser's pointer event
flow. This prevents the pointerUp event from being delivered, leaving the
input event loop in an inconsistent state where the first tap after
dismissing the menu is not captured.
The fix adds a 100ms delay before showing the context menu, allowing the
browser's input loop to complete the pointerUp event for the right-click
before the native menu interferes.
* Allow users to change the "remaining pages" label
Makes the remaining page number and the label text that comes with it targetable with CSS.
Solves https://github.com/readest/readest/issues/3343.
* Fixed formatting
* initial files
* added testing files
* removed unused files
* cleaned additional mounts
* fixed sql init
* removed more unused files
* moved to docker folder
* revert package.json
* gitignore update
* env example comments and compose necessary healthcheck
* ghcr package impl
* updated dockerfile steps for layer caching
* added development-stage to dockerfile to dev environment
* added documentation on how to use dockerfile and compose.yml
* fixed prettier issues
* fixed image tag
* removed workflow for later
* fix: highlight in dark mode eink
* refactor: deduplicate
* fix: bw eink highlights to use the fg colour so saved highlights are visible
* fix: avoid grayscale color in E-ink mode for better legibility
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
* feat: add RSVP speed reading feature
Implements Rapid Serial Visual Presentation (RSVP) speed reading mode:
- Display words one at a time with ORP (Optimal Recognition Point) highlighting
- Adjustable WPM speed (100-1000) with keyboard/button controls
- Punctuation pause settings (25-200ms)
- Progress tracking with chapter navigation
- Context panel showing surrounding text when paused
- Keyboard shortcuts (Space, Escape, arrows) and touch gestures
- Chapter selector for quick navigation
- Respects current theme colors
- Persists settings (WPM, pause duration, position) per book
New files:
- services/rsvp/RSVPController.ts - Main controller with playback logic
- services/rsvp/types.ts - TypeScript interfaces
- components/rsvp/RSVPOverlay.tsx - Full-screen RSVP reader UI
- components/rsvp/RSVPControl.tsx - Integration component
- components/rsvp/RSVPStartDialog.tsx - Start position selection
Closes#3111
* test(rsvp): add Playwright e2e tests for RSVP feature
- Set up Playwright test infrastructure with config
- Add comprehensive e2e tests for RSVP speed reading:
- Opening RSVP from View menu
- Start dialog options
- Play/pause toggle
- Speed adjustment
- Skip navigation
- Keyboard shortcuts
- Progress bar
- Chapter navigation
- Accessibility tests
- Add test data attributes and ARIA labels to RSVPOverlay
- Add test scripts to package.json
* refactor: remove test files, unsure of use case for now
* chore: remove Playwright e2e test scripts from package.json
- Deleted e2e test scripts related to Playwright from package.json as they are no longer needed.
- Removed Playwright as a dev dependency to streamline the project.
* chore: sync pnpm-lock.yaml after removing @playwright/test
* fix: lint errors and timezone-aware date formatting
* fix(rsvp): restore correct position when exiting RSVP mode
Fix bug where exiting RSVP after changing speed would navigate to wrong
position (several pages backwards). The issue was that Range objects
become stale if the document re-renders, and the position recovery used
global word index instead of per-document index.
- Add docWordIndex to track word position within each document
- Add docTotalWords to RsvpStopPosition for accurate recovery
- Validate Range before using it, recreate from current DOM if stale
- Use same word extraction logic as RSVPController for consistency
* fix(rsvp): prevent arrow keys from changing chapter dropdown
Arrow keys during RSVP mode were inadvertently navigating the chapter
dropdown instead of controlling RSVP speed. Fixed by using capture phase
for keyboard events and stopping propagation to prevent native elements
from receiving the events.
* chore: remove playwright-mcp screenshots from repo
* chore: update gitignore
* fix(i18n): wrap all RSVP overlay strings with translation function
Restore missing translation wrappers for all user-facing strings:
- Close RSVP button labels and tooltips
- Select Chapter dropdown
- WPM display
- Context panel header
- Ready state text
- Chapter Progress label
- Words count and time remaining
- Reading progress slider
- Pause/punctuation label
- Skip back/forward buttons
- Play/Pause button
- Speed control buttons
* fix(rsvp): use CFI-based position tracking for accurate page positioning
- Add CFI generation for each extracted word during RSVP processing
- Implement CFI path and character offset comparison for precise position matching
- Update startFromCurrentPosition to use CFI matching instead of word index
- Add extractFullDocPathWithOffset to handle CFI character offsets
- Simplify RSVPControl by removing unused helper functions
- Remove word index from RsvpPosition in favor of CFI-only tracking
* fix(ViewMenu): re-enable Speed Reading Mode in production environment
* refactor(ViewMenu, RSVPController): clean up code formatting and improve readability
* refactor(ViewMenu): remove unused IoSpeedometer import
* refactor(RSVPController): streamline CFI handling and improve section comparison
- Removed redundant logic and re-using CFI util functions for CFI operations
* refactor(RsvpPosition): remove legacy wordIndex field for cleaner type definition
* feat(library): implement grouping of books by series or author
- Added functionality to group books in the library by series or author.
- Introduced new utility functions for creating book groups and parsing author strings.
- Updated the LibraryPageContent to track the current group for navigation.
- Created a new GroupHeader component to display the current group context.
- Enhanced the Bookshelf component to support displaying grouped books.
- Updated settings to include grouping options in the view menu.
New files:
- src/__tests__/utils/libraryUtils.test.ts - Unit tests for new utility functions.
- src/app/library/components/GroupHeader.tsx - Component for displaying group context.
- src/app/library/utils/libraryUtils.ts - Utility functions for grouping and parsing authors.
* Use group by and sort by constants, and split sort/group by menus into their own elements
* Format code with autoformatter
* Remove any casting from tests
* Translate missing strings
* refactor: cleaner layout with collapsible view menu options
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
* feat: add RSVP speed reading feature
Implements Rapid Serial Visual Presentation (RSVP) speed reading mode:
- Display words one at a time with ORP (Optimal Recognition Point) highlighting
- Adjustable WPM speed (100-1000) with keyboard/button controls
- Punctuation pause settings (25-200ms)
- Progress tracking with chapter navigation
- Context panel showing surrounding text when paused
- Keyboard shortcuts (Space, Escape, arrows) and touch gestures
- Chapter selector for quick navigation
- Respects current theme colors
- Persists settings (WPM, pause duration, position) per book
New files:
- services/rsvp/RSVPController.ts - Main controller with playback logic
- services/rsvp/types.ts - TypeScript interfaces
- components/rsvp/RSVPOverlay.tsx - Full-screen RSVP reader UI
- components/rsvp/RSVPControl.tsx - Integration component
- components/rsvp/RSVPStartDialog.tsx - Start position selection
Closes#3111
* fix(rsvp): use portal to fix overlay stacking context issue
- Render RSVP overlay and start dialog via React Portal to document.body
- This ensures the overlay appears above all other content regardless of
parent component CSS transforms or stacking contexts
- Add fallback colors for theme values to ensure solid background
* fix(rsvp): improve UX with progress sync, sentence highlight, and better dialogs
- Fix start dialog transparency by using solid opaque background with proper
fallback colors for both light and dark modes
- Increase context words from 50 to 100 words before/after current word
- Add progress sync on RSVP exit - navigates reader to the last word position
- Add temporary bright green sentence underline on exit (5 second duration)
to easily locate where reading left off
- Helper function to expand word range to full sentence boundaries
* fix(rsvp): store full BookNote for proper annotation removal
The addAnnotation API requires the full BookNote object (including CFI)
when removing annotations, not just the ID. Changed tempHighlightIdRef
to tempHighlightRef to store the complete BookNote object.
* test(rsvp): add Playwright e2e tests for RSVP feature
- Set up Playwright test infrastructure with config
- Add comprehensive e2e tests for RSVP speed reading:
- Opening RSVP from View menu
- Start dialog options
- Play/pause toggle
- Speed adjustment
- Skip navigation
- Keyboard shortcuts
- Progress bar
- Chapter navigation
- Accessibility tests
- Add test data attributes and ARIA labels to RSVPOverlay
- Add test scripts to package.json
* fix(rsvp): clarify start dialog option labels
Update the RSVP start dialog to use clearer language:
- "From Beginning" → "From Chapter Start" (since it starts from chapter beginning)
- "From Current Position" → "From Current Page" (starts from visible page)
* fix(rsvp): use correct theme colors from themeCode
The RSVP components were using incorrect palette key names (camelCase
like `base100` instead of hyphenated like `base-100`), causing the
fallback colors to always be used instead of the reader's actual theme.
Fix by using themeCode.bg, themeCode.fg, and themeCode.primary directly,
which are already resolved from the palette with correct keys.
* fix(rsvp): use theme accent color for sentence underline and persist until page change
- Change underline color from hardcoded green (#22c55e) to theme accent
color (themeCode.primary), matching the ORP focal point highlight
- Remove 5-second timeout that auto-removed the underline
- Add cleanup on page navigation, new RSVP session start, and unmount
- Add removeRsvpHighlight helper function for consistent cleanup
* fix(rsvp): transition to next chapter when reaching end of section
When RSVP reached the end of a chapter, it would restart the current
chapter instead of moving to the next one. This happened because RSVP
extracts ALL words from the current section via renderer.getContents(),
so when words run out, the entire chapter is done.
- Always use view.renderer.nextSection() when RSVP exhausts words
- This moves to the next chapter instead of staying in the current one
* refactor: remove test files, unsure of use case for now
* chore: remove Playwright e2e test scripts from package.json
- Deleted e2e test scripts related to Playwright from package.json as they are no longer needed.
- Removed Playwright as a dev dependency to streamline the project.
* fix(rsvp): ensure CFI retrieval occurs before navigation
- Updated comments to clarify the necessity of obtaining CFI for both the navigation and sentence highlight before invoking the goTo() method, as it may re-render the document and invalidate Range objects.
- Introduced a new variable for sentence text to enhance readability and maintainability of the code.
* chore: sync pnpm-lock.yaml after removing @playwright/test
* style: format RSVP components
* fix: lint errors and timezone-aware date formatting
* i18n: support CJK text and add translations
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
* feat: enable linking to settings items
* feat: integrate command palette with global state and styles
* feat: add command pallete ui and fuzzy search
* feat: add command palette with global state and styles
* feat: add command registry and search dependencies
* fix: open command palette with shortcuts in reader page
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
* fix(settings): ensure latest config is used when saving view settings
* fix: reader ruler appearing before other book reading contents
* feat(reader): dispatch reader-closing event during book close
* feat(reader) add auto reposition to top when changing pages and position persistence on book close
* fix(reader): add rtl prop to ReadingRuler component
* fix(reader): handle vertical ltr writing mode in ruler auto positioning
* chore: revert redundant changes to settings.ts
* refactor: remove redundant reader-closing
* refactor: use store progress for ruler positioning with throttled saving
* feat(highlight): extend types and constants for custom hex colours
* feat(highlight): update colour to support hex strings
* refactor(annotator): update component to accept hex colours
* feat(highlight): add colour picker with max 4 custom colors
* feat(settings): add custom colour editor with limit
* refactor: custom highlight colors can only be modified in settings
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Move Radix/shadcn-based UI primitives into a dedicated
`components/primitives` directory to better reflect
their low-level, composable nature.
Imports remain ergonomic via an alias to `components/ui`.
App-level components should continue to use PascalCase filenames.
* refactor(ai): remove chat icons from conversation list items
* feat(ai): add scroll-to-bottom button with animations and dynamic spacer
* fix(ai): align connection status icon with text in settings
* feat(ai): scroll to bottom on chat panel open and improve scroll behavior
* feat(ai): add loading overlay with blur transition for chat history
* feat(ai): pass loading state through component chain for history loading
* feat(ai): add dependencies
* chore: bump zod version to default version
* feat(ai): define types and model constants
* feat(ai): ollama provider for local LLM
* feat(ai): implement openrouter provider for cloud models
* feat(settings): register ai settings panel in global dialog
* refactor(ai): expose provider factory and service layer entry point
* test(ai): add unit tests for the providers
* test(ai): add unit tests for the providers
* feat(ai): settings panel for ai configurations
* refactor(ai): rewrite aipanel with autosave and greyed out disabled state
* fix: remove unused onClose prop from aipanel
* test(ai): update mock data
* refactor(ai): remove models
* refactor: use centralised defaults in system defaults
* chore(ai): remove comments
* fix(ai): merge default ai settings on load to prevent undefined values
* refactor(ai): rewrite settings panel with autosave and model input
* feat(ai): add ai tab with simplified highlighting
* feat(sidebar): render AIAssistant for ai tab
* feat(ai): add chat UI
* feat(ai); add chat service with RAG context
* feat(ai): temp debug logger
* feat(ai): add RAG service
* feat(ai): add text chunking utility
* feat(ai): add structured method
* feat(ai): add chatstructured method
* feat(ai): add rag types nd structured output schema
* feat(ai): add aistore, indexdb, bm25
* fix: update lock file
* feat(ai): update types for AI SDK v5
* feat(ai): add placeholder gateway model constants
* refactor(ai): update OllamaProvider for AI SDK
* feat(ai): add native gateway provider
* refactor(ai): update provider exports
* refactor(ai): use streamText from AI SDK
* refactor(ai): use embed from AI sdk
* refactor(ai): update provider factory exports
* feat(ai): add AI Elements and shadcn components
* config: add shadcn component config
* deps: add AI SDK and AI Elements dependencies
* config: add ai packages to transpilePackages
* refactor(ai): remove OpenRouterProvider and old tests
* feat(ai):add assistant-ui components
* feat(ai): add TauriChatAdapter for assistant-ui runtime
* refactor(ai): remove ai-elements components
* dep(ai): install assistant-ui and update next config
* chore(ai): export adapters from service index
* feat(ui): enhance ui components for assistant integration
* feat(settings): migrate ai settings to gateway
* feat(sidebar): integrate assistant-ui
* feat: add ai settings toggle to sidebar content
* feat: conditionally show ai tab in sidebar navigation
* feat: update ai model constants for cheaper options
* feat: add gateway provider with proxied embedding
* feat: add timeouts to ollama provider health checks
* feat: add retry logic to rag service embeddings
* feat: add error recovery to ai store
* feat: add ai feature tests
* feat: add ai api endpoints
* feat: add proxied gateway embedding provider
* feat: add ai runtime utilities
* feat: add ai retry utilities
* feat: add tauri env example template
* feat: add web env example template
* chore: add env
* feat(ai): update models and pricing, remove GLM-4.7-FlashX
* feat(ai): improve system prompt with official headings and no numeric citations
* feat(ai): optimize system prompt for tauri chat
* feat(ui): refine ai chat UI and relocate sources
* feat(ui): update ai settings panel with model pricing and custom model support
* feat(ai): add custom model support to ai settings
* test(ai): update constants tests for removed model
* feat(api): implement ai chat proxy route
* feat(api): implement ai embedding proxy route
* feat(ai): implement ai gateway health check and proxy logic
* feat(ai): simplify proxied embedding provider
* feat(ui): improve markdown text rendering
* feat(ui): add input group component
* test(ai): update ai provider tests
* feat(ai): add pageNumber to text chunk schema
* feat(ai): implement page-based chunking with 1500 char formula
* feat(ai): bump db to v2 and add store reset migration
* feat(ai): transition rag pipeline to page level spoiler filtering
* feat(ai): overhaul readest persona and antijailbreak prompt
* feat(ai): update tauri adapter for page tracking and persona
* chore(ai): export aiStore and logger from core index
* feat(reader): integrate page tracking and manual index reset
* feat(ui): add re-index action and reset logic to chat
* chore: sync pnpm lockfile with ai dependencies
* feat(utils): add browser-safe file utilities for web builds
* refactor(utils): use dynamic tauri fs import to prevent web crashes
* refactor(services): defer osType call to init() for web compatibility
* refactor(services): import RemoteFile from file.web
* refactor(services): import ClosableFile from file.web
* fix(libs): cast Entry to any for getData access
* fix(annotator): cast Overlayer to any for bubble access
* refactor(ai): replace SparklesIcon with BookOpenIcon for index prompt
* test(ai): add pageNumber to TextChunk mocks
* test(ai): fix chunkSection signature in tests
* chore: update files
* fix(ai): prevent useLocalRuntime crash when adapter is null
* refactor: optimize annotator overlay drawing
* feat: stabilize AI assistant runtime and adapter
* refactor: improve document zip loader type safety
* feat: update tauri chat adapter for dynamic options
* fix: restore architecture comments and refine platform properties
* build: update lockfile with assistant-ui patch
* fix(library): patch @assistant-ui/react for runtime initialization
* build: update dependencies in readest-app
* build: update root dependencies and patch configuration
* fix(ai): patch @assistant-ui/react for thread deletion and runtime init
* fix(ai): update assistant-ui patch with dist guards and deletion fallback
* build: sync lockfile with assistant-ui patch updates
* chore(env): update .gitignore by removing .env files from it
* chore(env): update .gitignore by adding .env.local
* chore(env): update .gitignore by adding .env*.local
* fix: restore static osType import
* chore: sync submodules with upstream/main
* refactor: remove redundant file.web module and revert import
* chore: update pnpm-lock.yaml
* refactor: revert guards
* refactor; remove deprecated codes and extract prompts.ts
* refactor(ai): remove unused ragservice exports
* refactor: remove unused ollama and embedding models
* refactor: remove unused type
* test: remove test for the now deleted constants
* refactor: remove unused export
* style: fix ui component formatting
* style: fix core and style file formatting
* test: fix broken ai provider import
* fix: typescript error
* fix: add eslint disable command
* fix(deps): remove unused ai sdk provider util after v6 ai sdk migration
* fix(patch): add lookbehind regex patch
* feat(dep): upgrade vercel ai sdk to v6 and ai-sdk-ollama to v3
* chore: update lockfile for vercel ai sdk v6
* refactor(ai): remove EmbeddingModel generic for ai sdk v6
* refactor(ai): remove EmbeddingModel generic for ai sdk v6
* test(ai): update mock to use embeddingModel
* fix(patch): add lookbehind regex patch for email autolinks in markdown
* refactor(ai): use ai sdk v6 syntax
* fix: prettier formatting
* chore: revert cargo.lock
* fix(ai): update proxied embedding model to v3 spec
* feat(ai): add aiconversation types for chat persistence
* feat(ai): add conversation/message indexeddb and crud operations
* feat(ai): create aiChatStore zustand store for chat state management
* feat(notebook): add notebookactivetab state for Notes/AI
* refactor(ai): refine conversation and message types for persistence
* feat(types): add notebookActiveTab to ReadSettings type
* chore: update deps
* feat: add notebookactive tab default value
* feat: add hook for ai chat
* feat: update left side panel with history/chat icon
* feat: integrate ChatHistoryView into sidebar content
* feat: create UI for managing AI chat history
* feat: implement persistent history with assistant-ui adapter
* feat: create tab navigation component for notes and AI
* feat: add tab navigation and AI assistant view
* feat: update header to display active tab title
* fix: formatting
* feat: remove title and update new chat button
* fix: formatting
* fix: revert tooltip and styling
* feat: implement cross-platform ask dialog bridge
* feat(ai): preserve history during ui clear & use native dialogs
* fix: align notebook navigation height with sidebar tabs
* fix(ai): add missing dependency to handleDeleteConversation hook
* docs: update PROJECT.md with session highlights
* chore: delete projectmd
* chore: update package.json and lock file
* chore: update package.json
* chore: remove patch
* chore: upgrade react types to 19 and show ai features only in development mode for now
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Convert to   before sanitization and restore after XMLSerializer
serialization. This handles the platform difference where XMLSerializer
produces different representations of non-breaking spaces ( ,  ,
or \u00A0) across different WebView implementations.
Add a floating navigation component that appears when search results exist.
The component includes:
- Top bar: displays search term and current section with TOC and close buttons
- Bottom bar: search results, previous, and next navigation buttons
- Page-based navigation using isCfiInLocation to skip between pages with results
* fix(settings): ensure global settings sync across all open panes
Previously, changing a global setting (like font size) only updated the currently active book pane, causing split-view panes to desynchronize.
This commit updates the settings logic to propagate global changes to all open book instances immediately. It also ensures that settings UI panels correctly re-render when preferences are updated from outside the component.
* refactor to reuse some code
* fix: pointer in doc check
---------
Co-authored-by: André Angelantoni <andre.angelantoni@performantlabs.com>
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
* add: basic ui replacement menu
* feat(replacement): modified ViewSettings interface and added Replacement type
* add: frontend menu ui to annotation settings
- create replacementoptions file for 4 fix options: fix once, fix in library, fix in book, fix in library
-integrate with annotator.tsx
only frontend changes, but initialzied in backend
* add: delete global option and click gear option to get rid of menu
* docs: add test cases for replacementoptions file
* edits to enable readest to build
* basic changes for rule types
* replacement transformer file added
* additional support code added
* interim updates to replacement.ts file
* adding console log statements to confirm functionality without frontend
* adding more console logs for debugging; i think i got my replacement working, will clean console logs and add actual tests now.
* figured out how to get my transformer to work. replacement doesnt actually work yet. figuring that out rn. committing before i destroy something, lol
* replcement logic working with hard coded tests. code is cleaned up with minimal console logs. actual replacement logic + testing is next :)
* test suite built, and fully passing. made consle log edits too.
* added more replacement rules, but figuring out why they arent being implemented by my code.
* cleaning up test suite to not break when there are 0 rules; test is commited with 1 local rule. not sure if that rule is going to copy over when i merge.
* feat(replacement): Add text field, case sensitivity checkbox, and confirmation dialog to ReplacementOptions
- Add text input field for replacement text with placeholder
- Add 'Case Sensitive' checkbox (default: unchecked/case-insensitive)
- Implement two-step confirmation flow with Back/Confirm buttons
- Show preview of original text, replacement text, scope, and case sensitivity
- Disable scope buttons until replacement text is entered
- Display truncated preview for long selected text (>50 chars)
- Export ReplacementConfig type for use in parent components
* feat(replacement): Add 30-word limit and integrate new ReplacementOptions component
- Add MAX_REPLACEMENT_WORDS constant (30 words)
- Add getWordCount() utility function for word counting
- Show warning toast when word limit exceeded on Text Replacement click
- Replace old fix handlers with single handleReplacementConfirm()
- Integrate with new ReplacementConfig (replacementText, caseSensitive, scope)
- Display success toast with scope and case sensitivity info on confirm
* fix(build): Add ReplacementMenu placeholder component
- Create placeholder component to fix missing import error in reader/page.tsx
- Component returns null for now, to be implemented with global replacement rules
* test(replacement): Add comprehensive tests for ReplacementOptions and word limit
ReplacementOptions.test.tsx:
- Test rendering of text input, checkbox, and scope buttons
- Test case sensitivity checkbox toggle and state
- Test disabled buttons when no replacement text entered
- Test confirmation dialog flow and Back/Confirm buttons
- Test click outside and Cancel button behavior
- Test full replacement flow with all options
wordLimit.test.ts:
- Test word counting with various inputs (spaces, newlines, unicode)
- Test 30-word limit boundary conditions
- Test case-sensitive vs case-insensitive matching logic
- Test edge cases (empty string, long words, punctuation)
* refactor: removed unused initial definition of Replacement
* feat: added replacement rules window in bookmenu
* test: added tests to verify the replacement rules window renders book and global replacement rules, and it opens when bookmenu item is clicked
* feat: added Replacement tab in SettingsDialog, displays global rules
* feat(replacement): connected front-end to functions. todo: fix the automatic reload functionality.
* fix(replacement): simplified re-rendering logic, doesn't fail on epubs anymore.
* test: add integration tests for text replacement functionality
* fix: added single rules section to ReplacementRulesWindow
* fix(replacement): added null checks to some unsafe calls in integration tests
* fix(replacements): added non-null assertion operator for a previously initialized variable
* refactor: created ReplacementPanel and edited style of inputs
* feat: disable the edit feature for selected phrase
* refactor: use toast instead of banner for confirmation msg
* feat: automatically reload the page to apply changes
* feat: disable global rule for book if deleted in book view
* fix(replacement): Improve popup positioning and eliminate ghost animation
- Add viewport boundary detection to keep popup within visible area
- Calculate position only once on mount to prevent jumping when other UI appears
- Use visibility: hidden until position is calculated to eliminate ghost animation
- Add max-height with overflow-y: auto for scrollable content
- Popup now appears directly in correct position without two-step animation
* fix: implement single-instance replacement with persistence
- Add sectionHref to TransformContext for section tracking
- Add singleInstance, sectionHref, occurrenceIndex fields to ReplacementRule
- Pass section name from FoliateViewer to transformer context
- Switch transformer from DOM-based to string-based replacement
- Handle single-instance rules with section matching and occurrence tracking
- Update Annotator to track occurrence index and apply direct DOM changes
- Persist single-instance rules for refresh survival
Single-instance replacements now:
1. Apply immediately via direct DOM modification
2. Store occurrence index and section for precise targeting
3. Persist across page refreshes
* fix: allow multiple single-instance replacements for same word
Single-instance rules now always create new entries instead of merging.
This fixes the issue where replacing multiple occurrences of the same
word would overwrite previous rules.
The transformer applies rules in sequence, so each rule targets
occurrence index 0 of the current (modified) string, allowing
cascading replacements to work correctly after refresh.
* fix: prevent cascading replacements and add wholeWord support
- Add wholeWord field to ReplacementRule for word boundary matching
- Track replaced regions to prevent replacement text from being re-matched
- Fix cascading replacement issue where replacement text was matched again
- Apply replacements from right to left to preserve positions
- Support whole word matching with \b boundaries for both single-instance and regular rules
* Fix whole-word matching for replacement rules
- Auto-enforce whole-word matching for simple word patterns (letters only)
- Add HTML tag boundary checks to prevent matching across tags
- Add double-check validation for whole-word matches
- Prevent matching 'and' inside words like 'England', 'stand', 'understand'
- Add comprehensive logging for debugging replacement issues
* test: added rAF in setup to for ReplacementOptions tests
* fix: only allow replacement for epubs, remove replacement rendering for non-epubs, add test cases
* refactor: refactored replacement logic for case sensitivity and word boundaries
* test: added tests for scope precedence and case sensitivity across scopes
* refactor: removed unnecessary code from testing
* feat: able to display, edit, and delete single-instance rules in book settings
* fix: connected case sensitive checkbox to backend, fixed merge and delete logic
* test: updated test cases to reflect changes on case sensitivity and rules rendering
* test: modified ReplacementOptions test to remove unnecessary case sensitive check from merge
* fix: add logic for grayed out button for non-epubs
* chore: update foliate-js submodule from upstream merge
* fix: resolve all TypeScript/ESLint linting errors
- Fix prefer-const error in ReplacementOptions.tsx
- Fix set-state-in-effect error in ReplacementRulesWindow.tsx (use lazy initializer)
- Replace all @typescript-eslint/no-explicit-any with proper types (ReplacementRule, unknown, etc.)
- Fix unused error variables in replacement.ts (prefix with _)
- Remove unused eslint-disable directives
- Add missing ReplacementRule import in ReplacementPanel.tsx
* fix: add localStorage mock to vitest setup
- Fixes test failures in ReplacementRulesWindow and SettingsDialog tests
- localStorage mock ensures all Storage API methods are available in test environment
* fix: resolve ESLint and TypeScript build errors
- Fix all remaining @typescript-eslint/no-explicit-any errors in test files
- Fix unused error variables in replacement.ts (prefix with _)
- Fix TypeScript error in ReplacementRulesWindow.tsx (move @ts-ignore to correct location)
- All ESLint checks now pass
- Web and Tauri builds compile successfully
* fix: remove lookbehind regex for browser compatibility
- Replace lookbehind assertions (?<!...) with manual boundary checking
- Add isUnicodeWordChar helper function for manual Unicode word boundary detection
- Apply manual boundary checks in applyMultiReplacement and applySingleInstance
- Fixes build_web_app check failures by avoiding lookbehind in compiled output
- Maintains whole-word matching functionality for both ASCII and Unicode patterns
* fix: update tauri-utils version to 2.8.1 to resolve duplicate symbol error
- Update local tauri-utils version from 2.8.0 to 2.8.1 to match crates.io version
- Fixes duplicate symbol __TAURI_BUNDLE_TYPE linker error
- Ensures all dependencies use the same tauri-utils version
* fix: use local tauri path directly to resolve version conflicts
- Change tauri dependency to use local path instead of version requirement
- This ensures all dependencies use the same local tauri version (2.9.3)
- Fixes 'links = Tauri' conflict error in Rust linting
- The patch.crates-io should still work for transitive dependencies
* fix: use version requirement with patch for tauri dependency
- Revert to using version requirement '2' instead of direct path
- Rely on [patch.crates-io] to use local tauri version
- Remove Cargo.lock to force fresh dependency resolution
- This should resolve the 'links = Tauri' conflict by ensuring
all tauri dependencies (direct and transitive) use the patched version
* fix: remove plugin patches that cause resolution errors
- Remove all tauri-plugin git patches from [patch.crates-io]
- Keep only tauri, tauri-utils, and tauri-build patches
- Plugins from crates.io will use the patched tauri via transitive dependencies
- Fixes error: patch for tauri-plugin-oauth failed to resolve
* fix: update tauri submodule with tauri-utils version fixes
* fix: revert tauri submodule and update tauri-utils to 2.8.0
- Revert submodule changes that can't be pushed to remote
- Update local tauri-utils version to 2.8.0 to match other packages
- This avoids the need to modify the submodule
* fix: add tauri-plugin to workspace and patch to resolve duplicate symbol error
- Add packages/tauri/crates/tauri-plugin to workspace members
- Add tauri-plugin patch to [patch.crates-io]
- This ensures all tauri dependencies use local versions
- Fixes duplicate symbol __TAURI_BUNDLE_TYPE linking error
* chore: restore Cargo.lock from upstream
- Restore the original Cargo.lock from readest/readest main branch
- This ensures reproducible builds and matches upstream
- The lock file will be updated by cargo when dependencies change
* fix: resolve TypeScript errors in test files
- Fix ReplacementOptions.test.tsx: add optional chaining for possibly undefined values
- Fix ReplacementRulesWindow.test.tsx: use proper type assertions for store setState calls
- Use (store.setState as unknown as (state: unknown) => void) pattern for partial state updates
* fix: prevent race condition when deleting replacement rules rapidly
- Add isReloading state to track ongoing delete/edit operations
- Prevent multiple rapid deletions that cause runtime errors during page reload
- Show warning toast when user tries to delete while reload is in progress
- Add finally blocks to ensure isReloading is always reset
- This prevents the 'book doesn't finish rerendering' error
* fix: allow phrases and lines with quotes for single-instance replacements
- Updated isWholeWord() to allow phrases (text with spaces or punctuation)
- Phrases are always allowed for single-instance replacements
- Only single words are checked for partial word matches
- Fixes issue where lines with quotes couldn't be replaced
- Added detailed logging for debugging phrase detection
* fix: allow selections with boundary punctuation and fix pattern matching for punctuation
- Updated isWholeWord() to explicitly allow selections that start or end with punctuation (e.g., 'tis, off;, look,)
- Fixed normalizePattern() to handle patterns with leading/trailing punctuation correctly
- Word boundaries are now only added around the word part, not the punctuation
- Fixes issue where replacements like 'scholar;' were not matching correctly
* fix: escape HTML entities in replacement text to preserve angle brackets
- Added escapeHtmlEntities() function to escape HTML special characters
- Apply HTML escaping to replacement text in both multi and single-instance replacements
- Fixes issue where replacement text like '<<AND>>' was being interpreted as HTML tags
- Angle brackets and other HTML entities are now properly escaped and displayed correctly
* fix: revert Tauri backend changes and resolve package.json conflict
- Revert Cargo.toml and src-tauri/Cargo.toml to match upstream/main
- Resolve @tauri-apps/cli version conflict (2.9.5 -> 2.9.6)
- These changes are not related to the replacement feature implementation
* fix: update pnpm-lock.yaml to match @tauri-apps/cli 2.9.6
* removed useless tests and backend tests from ReplacementOptions integration testing suite
* chore: revert foliate-js submodule to match readest/readest main
* fix: refactored wordLimit logic into a separate util file
* fix: removed additional pr description
* refactor: rewrite replacement transformer to use DOM-based approach
replace string manipulation with DOMParser and TreeWalker
follow pattern from simpleecc transformer
* style: format code with prettier
* fix: remove unused string-manipulation functions
* fix: refactored display dialog logic to match other dialogs
* fix: enabled global rule deletion in book menu
* fix: removed ReplacementPanel from library settings
* fix: deleted SettingsDialog.replacement.test.tsx since we no longer need to display replacements in library settings
* fix: removed text replacement tab from settings dialog
* fix: applied prettier code formatter to replacement rules window
* chore: fix formatting and remove unused file listed by chrox
* chore: format all changed files from pr 2693 and revert pnpm-lock
* rebased Cargo.lock, package.json, pnpm-lock.yaml to upstream main
edits to enable readest to build
* basic changes for rule types
* replacement transformer file added
* additional support code added
* interim updates to replacement.ts file
* adding console log statements to confirm functionality without frontend
* adding more console logs for debugging; i think i got my replacement working, will clean console logs and add actual tests now.
* figured out how to get my transformer to work. replacement doesnt actually work yet. figuring that out rn. committing before i destroy something, lol
* replcement logic working with hard coded tests. code is cleaned up with minimal console logs. actual replacement logic + testing is next :)
* test suite built, and fully passing. made consle log edits too.
* added more replacement rules, but figuring out why they arent being implemented by my code.
* cleaning up test suite to not break when there are 0 rules; test is commited with 1 local rule. not sure if that rule is going to copy over when i merge.
* add: basic ui replacement menu
* add: frontend menu ui to annotation settings
- create replacementoptions file for 4 fix options: fix once, fix in library, fix in book, fix in library
-integrate with annotator.tsx
only frontend changes, but initialzied in backend
* add: delete global option and click gear option to get rid of menu
* docs: add test cases for replacementoptions file
* feat(replacement): modified ViewSettings interface and added Replacement type
feat(replacement): modified viewsettings interface and added ReplacementRulesConfig
* feat(replacement): Add text field, case sensitivity checkbox, and confirmation dialog to ReplacementOptions
- Add text input field for replacement text with placeholder
- Add 'Case Sensitive' checkbox (default: unchecked/case-insensitive)
- Implement two-step confirmation flow with Back/Confirm buttons
- Show preview of original text, replacement text, scope, and case sensitivity
- Disable scope buttons until replacement text is entered
- Display truncated preview for long selected text (>50 chars)
- Export ReplacementConfig type for use in parent components
* feat(replacement): Add 30-word limit and integrate new ReplacementOptions component
- Add MAX_REPLACEMENT_WORDS constant (30 words)
- Add getWordCount() utility function for word counting
- Show warning toast when word limit exceeded on Text Replacement click
- Replace old fix handlers with single handleReplacementConfirm()
- Integrate with new ReplacementConfig (replacementText, caseSensitive, scope)
- Display success toast with scope and case sensitivity info on confirm
* fix(build): Add ReplacementMenu placeholder component
- Create placeholder component to fix missing import error in reader/page.tsx
- Component returns null for now, to be implemented with global replacement rules
* test(replacement): Add comprehensive tests for ReplacementOptions and word limit
ReplacementOptions.test.tsx:
- Test rendering of text input, checkbox, and scope buttons
- Test case sensitivity checkbox toggle and state
- Test disabled buttons when no replacement text entered
- Test confirmation dialog flow and Back/Confirm buttons
- Test click outside and Cancel button behavior
- Test full replacement flow with all options
wordLimit.test.ts:
- Test word counting with various inputs (spaces, newlines, unicode)
- Test 30-word limit boundary conditions
- Test case-sensitive vs case-insensitive matching logic
- Test edge cases (empty string, long words, punctuation)
* refactor: removed unused initial definition of Replacement
* feat: added replacement rules window in bookmenu
* test: added tests to verify the replacement rules window renders book and global replacement rules, and it opens when bookmenu item is clicked
* feat: added Replacement tab in SettingsDialog, displays global rules
* fix: added single rules section to ReplacementRulesWindow
* refactor: created ReplacementPanel and edited style of inputs
* feat(replacement): connected front-end to functions. todo: fix the automatic reload functionality.
* fix(replacement): simplified re-rendering logic, doesn't fail on epubs anymore.
* test: add integration tests for text replacement functionality
* fix(replacement): added null checks to some unsafe calls in integration tests
* fix(replacements): added non-null assertion operator for a previously initialized variable
* feat: disable the edit feature for selected phrase
* refactor: use toast instead of banner for confirmation msg
* feat: automatically reload the page to apply changes
* feat: disable global rule for book if deleted in book view
* fix(replacement): Improve popup positioning and eliminate ghost animation
- Add viewport boundary detection to keep popup within visible area
- Calculate position only once on mount to prevent jumping when other UI appears
- Use visibility: hidden until position is calculated to eliminate ghost animation
- Add max-height with overflow-y: auto for scrollable content
- Popup now appears directly in correct position without two-step animation
* fix: only allow replacement for epubs, remove replacement rendering for non-epubs, add test cases
* fix: add logic for grayed out button for non-epubs
* fix: resolve all TypeScript/ESLint linting errors
- Fix prefer-const error in ReplacementOptions.tsx
- Fix set-state-in-effect error in ReplacementRulesWindow.tsx (use lazy initializer)
- Replace all @typescript-eslint/no-explicit-any with proper types (ReplacementRule, unknown, etc.)
- Fix unused error variables in replacement.ts (prefix with _)
- Remove unused eslint-disable directives
- Add missing ReplacementRule import in ReplacementPanel.tsx
* fix: add localStorage mock to vitest setup
- Fixes test failures in ReplacementRulesWindow and SettingsDialog tests
- localStorage mock ensures all Storage API methods are available in test environment
* fix: implement single-instance replacement with persistence
- Add sectionHref to TransformContext for section tracking
- Add singleInstance, sectionHref, occurrenceIndex fields to ReplacementRule
- Pass section name from FoliateViewer to transformer context
- Switch transformer from DOM-based to string-based replacement
- Handle single-instance rules with section matching and occurrence tracking
- Update Annotator to track occurrence index and apply direct DOM changes
- Persist single-instance rules for refresh survival
Single-instance replacements now:
1. Apply immediately via direct DOM modification
2. Store occurrence index and section for precise targeting
3. Persist across page refreshes
* fix: allow multiple single-instance replacements for same word
Single-instance rules now always create new entries instead of merging.
This fixes the issue where replacing multiple occurrences of the same
word would overwrite previous rules.
The transformer applies rules in sequence, so each rule targets
occurrence index 0 of the current (modified) string, allowing
cascading replacements to work correctly after refresh.
* fix: prevent cascading replacements and add wholeWord support
- Add wholeWord field to ReplacementRule for word boundary matching
- Track replaced regions to prevent replacement text from being re-matched
- Fix cascading replacement issue where replacement text was matched again
- Apply replacements from right to left to preserve positions
- Support whole word matching with \b boundaries for both single-instance and regular rules
* Fix whole-word matching for replacement rules
- Auto-enforce whole-word matching for simple word patterns (letters only)
- Add HTML tag boundary checks to prevent matching across tags
- Add double-check validation for whole-word matches
- Prevent matching 'and' inside words like 'England', 'stand', 'understand'
- Add comprehensive logging for debugging replacement issues
* refactor: refactored replacement logic for case sensitivity and word boundaries
* test: added tests for scope precedence and case sensitivity across scopes
* refactor: removed unnecessary code from testing
* feat: able to display, edit, and delete single-instance rules in book settings
* fix: connected case sensitive checkbox to backend, fixed merge and delete logic
* test: updated test cases to reflect changes on case sensitivity and rules rendering
* test: modified ReplacementOptions test to remove unnecessary case sensitive check from merge
* fix: resolve ESLint and TypeScript build errors
- Fix all remaining @typescript-eslint/no-explicit-any errors in test files
- Fix unused error variables in replacement.ts (prefix with _)
- Fix TypeScript error in ReplacementRulesWindow.tsx (move @ts-ignore to correct location)
- All ESLint checks now pass
- Web and Tauri builds compile successfully
* fix: update tauri-utils version to 2.8.1 to resolve duplicate symbol error
- Update local tauri-utils version from 2.8.0 to 2.8.1 to match crates.io version
- Fixes duplicate symbol __TAURI_BUNDLE_TYPE linker error
- Ensures all dependencies use the same tauri-utils version
* fix: use local tauri path directly to resolve version conflicts
- Change tauri dependency to use local path instead of version requirement
- This ensures all dependencies use the same local tauri version (2.9.3)
- Fixes 'links = Tauri' conflict error in Rust linting
- The patch.crates-io should still work for transitive dependencies
* fix: use version requirement with patch for tauri dependency
- Revert to using version requirement '2' instead of direct path
- Rely on [patch.crates-io] to use local tauri version
- Remove Cargo.lock to force fresh dependency resolution
- This should resolve the 'links = Tauri' conflict by ensuring
all tauri dependencies (direct and transitive) use the patched version
* fix: remove plugin patches that cause resolution errors
- Remove all tauri-plugin git patches from [patch.crates-io]
- Keep only tauri, tauri-utils, and tauri-build patches
- Plugins from crates.io will use the patched tauri via transitive dependencies
- Fixes error: patch for tauri-plugin-oauth failed to resolve
* fix: add tauri-plugin to workspace and patch to resolve duplicate symbol error
- Add packages/tauri/crates/tauri-plugin to workspace members
- Add tauri-plugin patch to [patch.crates-io]
- This ensures all tauri dependencies use local versions
- Fixes duplicate symbol __TAURI_BUNDLE_TYPE linking error
* chore: restore Cargo.lock from upstream
- Restore the original Cargo.lock from readest/readest main branch
- This ensures reproducible builds and matches upstream
- The lock file will be updated by cargo when dependencies change
* fix: resolve TypeScript errors in test files
- Fix ReplacementOptions.test.tsx: add optional chaining for possibly undefined values
- Fix ReplacementRulesWindow.test.tsx: use proper type assertions for store setState calls
- Use (store.setState as unknown as (state: unknown) => void) pattern for partial state updates
* fix: allow selections with boundary punctuation and fix pattern matching for punctuation
- Updated isWholeWord() to explicitly allow selections that start or end with punctuation (e.g., 'tis, off;, look,)
- Fixed normalizePattern() to handle patterns with leading/trailing punctuation correctly
- Word boundaries are now only added around the word part, not the punctuation
- Fixes issue where replacements like 'scholar;' were not matching correctly
* fix: prevent race condition when deleting replacement rules rapidly
- Add isReloading state to track ongoing delete/edit operations
- Prevent multiple rapid deletions that cause runtime errors during page reload
- Show warning toast when user tries to delete while reload is in progress
- Add finally blocks to ensure isReloading is always reset
- This prevents the 'book doesn't finish rerendering' error
* fix: escape HTML entities in replacement text to preserve angle brackets
- Added escapeHtmlEntities() function to escape HTML special characters
- Apply HTML escaping to replacement text in both multi and single-instance replacements
- Fixes issue where replacement text like '<<AND>>' was being interpreted as HTML tags
- Angle brackets and other HTML entities are now properly escaped and displayed correctly
* fix: revert Tauri backend changes and resolve package.json conflict
- Revert Cargo.toml and src-tauri/Cargo.toml to match upstream/main
- Resolve @tauri-apps/cli version conflict (2.9.5 -> 2.9.6)
- These changes are not related to the replacement feature implementation
* fix: update pnpm-lock.yaml to match @tauri-apps/cli 2.9.6
* removed useless tests and backend tests from ReplacementOptions integration testing suite
* chore: revert foliate-js submodule to match readest/readest main
* fix: refactored display dialog logic to match other dialogs
* fix: enabled global rule deletion in book menu
* fix: removed ReplacementPanel from library settings
* fix: deleted SettingsDialog.replacement.test.tsx since we no longer need to display replacements in library settings
* fix: removed text replacement tab from settings dialog
* fix: applied prettier code formatter to replacement rules window
* fix: refactored wordLimit logic into a separate util file
* fix: removed additional pr description
* style: format code with prettier
* chore: fix formatting and remove unused file listed by chrox
* chore: format all changed files from pr 2693 and revert pnpm-lock
* fix: fixed inconsistencies from rebase
* refactor: removed unused code
* refactor: removed unintentional formatting changes
* fix: set upstream for packages/tauri-plugins to the readest branch
* fix: used original Cargo.lock file
* fix: got Cargo.lock from upstream
* fix: fetched SettingsDialog from upstream main
* fix: pointed tauri-plugins to the same commit as upstream
* chore: remove unnecssary comments from replacement.ts
* chore: fixed more unnecessary comments
---------
Co-authored-by: fatbiscuit247 <fatbiscuit247@github.com>
Co-authored-by: joon <your.email@example.com>
Co-authored-by: jarchenn <jerryc2@andrew.cmu.edu>
Co-authored-by: joon0429 <68578999+joon0429@users.noreply.github.com>
Co-authored-by: Jerry Chen <50bmg@Jerrys-MacBook-Pro-9.local>
Co-authored-by: Alicia Chen <aliciach@andrew.cmu.edu>
Co-authored-by: Jerry Chen <50bmg@MacBook-Pro-7.local>
Co-authored-by: Jerry Chen <50bmg@macbook-pro-158.wifi.local.cmu.edu>
Co-authored-by: fatbiscuit247 <136537548+fatbiscuit247@users.noreply.github.com>
* feat(windows): Add Windows Explorer thumbnail support for ebooks (closes#2534)
- Implement IThumbnailProvider COM handler for Windows Shell integration
- Support EPUB, MOBI, AZW, AZW3, KF8, FB2, CBZ, CBR formats
- Add cover extraction with Readest icon overlay
- Register thumbnail handler via NSIS installer hooks
- Only show thumbnails when Readest is the default app for the file type
* chore: clean up build script for thumbnail extension
---------
Co-authored-by: chrox <chrox.huang@gmail.com>
* fix(annotator): enhance PDF context menu for translation and improve touch handling
* feat(annotator, shortcuts): add keyboard shortcuts for selection actions
* feat(annotator): reposition popups on scroll to enhance user experience
* feat(annotator): disable currently unsupported annotator functions for PDFs
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
If the publisher has already been parsed, don’t remap it from the author or contributor fields again.
This prevents failures when parsing metadata like <dc:creator opf:role="pbl" />.
Implement logarithmic mapping for screen brightness slider to improve
usability at low brightness levels where small percentage changes have
significant visual impact.
Restructure the Farsi translation to align with the format and key organization used in other translation files. This improves consistency and maintainability.
Add ability to customize highlight colors with hex color picker. Users can now set custom colors for all five highlight styles (red, violet, blue, green, yellow) in the settings panel.
Fixes#2271
Introduces an adaptive icon for the Android launcher, defining
separate layers for background, foreground, and monochrome
elements. Enhances visual consistency and supports adaptive
icon features on modern Android devices.
# required to fetch internal or private CodeQL packs
packages:read
# only required for workflows in private repositories
actions:read
contents:read
strategy:
fail-fast:false
matrix:
include:
- language:actions
build-mode:none
- language:javascript-typescript
build-mode:none
- language:rust
build-mode:none
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name:Checkout repository
uses:actions/checkout@v6
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Initializes the CodeQL tools for scanning.
- name:Initialize CodeQL
uses:github/codeql-action/init@v4
with:
languages:${{ matrix.language }}
build-mode:${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- name:Run manual build steps
if:matrix.build-mode == 'manual'
shell:bash
run:|
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
[Readest][link-website] is an open-source ebook reader designed for immersive and deep reading experiences. Built as a modern rewrite of [Foliate](https://github.com/johnfactotum/foliate), it leverages [Next.js 15](https://github.com/vercel/next.js) and [Tauri v2](https://github.com/tauri-apps/tauri) to deliver a smooth, cross-platform experience across macOS, Windows, Linux, Android, iOS, and the Web.
[Readest][link-website] is an open-source ebook reader designed for immersive and deep reading experiences. Built as a modern rewrite of [Foliate](https://github.com/johnfactotum/foliate), it leverages [Next.js 16](https://github.com/vercel/next.js) and [Tauri v2](https://github.com/tauri-apps/tauri) to deliver a smooth, cross-platform experience across macOS, Windows, Linux, Android, iOS, and the Web.
| **Multi-Format Support**| Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, TXT, PDF | ✅ |
| **Scroll/Page View Modes**| Switch between scrolling or paginated reading modes. | ✅ |
| **Full-Text Search**| Search across the entire book to find relevant sections. | ✅ |
| **Annotations and Highlighting**| Add highlights, bookmarks, and notes to enhance your reading experience and use instant mode for quicker interactions. | ✅ |
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. | ✅ |
| **File Association and Open With**| Quickly open files in Readest in your file browser with one-click. | ✅ |
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
| **OPDS/Calibre Integration** | Integrate OPDS/Calibre to access online libraries and catalogs. | ✅ |
| **Translate with DeepL and Yandex**| From a single sentence to the entire book—translate instantly. | ✅ |
| **Text-to-Speech (TTS) Support**| Enjoy smooth, multilingual narration—even within a single book. | ✅ |
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
| [**Sync with Koreader**][link-kosync-wiki] | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | ✅ |
| **Accessibility** | Provides full keyboard navigation and supports for screen readers such as VoiceOver, TalkBack, NVDA, and Orca. | ✅ |
| **AI-Powered Summarization**| Generate summaries of books or chapters using AI for quick insights. | 🛠 |
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🛠 |
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
Stay tuned for continuous improvements and updates! Contributions and suggestions are always welcome—let's build the ultimate reading experience together. 😊
@@ -109,9 +109,10 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
### Platform-Specific Downloads
- macOS / iOS / iPadOS : Search for "Readest" on the [App Store][link-appstore], also available on TestFlight for beta test (send your Apple ID to <readestapp@gmail.com> to request access).
- Windows / Linux / Android: Visit [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
- macOS / iOS / iPadOS : Search and install **Readest** on the [App Store][link-appstore], _also_ available on TestFlight for beta test (send your Apple ID to <readestapp@gmail.com> to request access).
- Windows / Linux / Android: Visit and download **Readest** at [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
-Linux users can also install [Readest on Flathub][link-flathub].
- Web: Visit and use **Readest for Web** at [https://web.readest.com][link-web-readest].
## Requirements
@@ -121,8 +122,8 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
If you have Nix installed, you can leverage flake to enter a development shell
@@ -249,6 +257,32 @@ Please check the [wiki][link-gh-wiki] of this project for more information on de
- See Issue [readest/readest#358](https://github.com/readest/readest/issues/358) for further details, or head over to our [Discord][link-discord] server and open a support discussion with detailed logs of your environment and the steps you’ve taken.
### 2. AppImage Launches but Only Shows a Taskbar Icon
On some Arch Linux systems—especially those using Wayland—the Readest AppImage may briefly show an icon in the taskbar and then exit without opening a window.
You might see logs such as:
```
Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
```
This behavior is usually caused by compatibility issues between the bundled AppImage libraries and the system’s EGL / Wayland environment.
**Workaround 1: Launch with LD_PRELOAD (recommended)**
You can preload the system Wayland client library before launching the AppImage:
This workaround has been confirmed to resolve the issue on affected systems.
**Workaround 2: Use the Flatpak Version**
If you prefer a more reliable out-of-the-box experience on Arch Linux, consider using the [Flatpak build on Flathub][link-flathub] instead. The Flatpak runtime helps avoid system library mismatches and tends to behave more consistently across different Wayland and X11 setups.
## Contributors
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please **review our [contributing guidelines](CONTRIBUTING.md) before you start**. We also welcome you to join our [Discord][link-discord] community for either support or contributing guidance.
@@ -261,17 +295,15 @@ Readest is open-source, and contributions are welcome! Feel free to open issues,
## Support
If Readest has been useful to you, consider supporting its development. Your contribution helps us squash bugs faster, improve performance, and keep building great features.
If Readest has been useful to you, consider supporting its development. You can [become a sponsor on GitHub](https://github.com/sponsors/readest), [donate via Stripe](https://donate.stripe.com/4gMcN5aZdcE52kW3TFgjC01), or [donate with crypto](https://donate.readest.com). Your contribution helps us squash bugs faster, improve performance, and keep building great features.
We would also like to thank the [Web Chinese Fonts Plan](https://chinese-font.netlify.app) for offering open-source tools that enable the use of Chinese fonts on the web.
---
@@ -303,15 +337,18 @@ The following fonts are utilized in this software, either bundled within the app
Readest is a cross-platform e-reader (macOS, Windows, Linux, Android, iOS, Web) built on Next.js and Tauri. It processes user-supplied ebook files, syncs data to the cloud, integrates with external services (OPDS catalogs, KOReader, DeepL, Yandex), and handles user authentication.
| Malicious ebook author | Craft a malformed file to exploit the parser or renderer |
| Network attacker (MitM) | Intercept sync traffic to steal credentials or inject data |
| Malicious OPDS server | Serve crafted catalog responses to exploit the client |
| Compromised dependency | Supply chain attack via npm or Cargo ecosystem |
| Unauthorized user | Access another user's synced library or annotations |
### Attack Surfaces & Mitigations
#### 1. Ebook File Parsing
- **Risk:** Malformed EPUB/MOBI/PDF files could trigger parser bugs, path traversal, or script injection via embedded HTML/JS.
- **Mitigations:** Ebook content is rendered in a sandboxed iframe. External script execution is blocked. File parsing is isolated from the main process.
#### 2. Cloud Sync & Authentication
- **Risk:** Credential theft, session hijacking, or unauthorized access to another user's library data.
- **Mitigations:** All sync traffic uses HTTPS/TLS. Authentication tokens are stored securely (OS keychain/secure storage). Server-side authorization ensures users can only access their own data.
#### 3. OPDS / External Catalog Integration
- **Risk:** A malicious OPDS server could serve crafted XML to exploit the parser, or redirect downloads to malicious files.
- **Mitigations:** OPDS responses are parsed defensively. Users explicitly add catalog sources. Downloaded files are treated as untrusted user content.
#### 4. Rendered HTML/JS in Ebook Content
- **Risk:** Embedded JavaScript in EPUB files could attempt XSS or data exfiltration.
- **Mitigations:** Book content is rendered in a sandboxed iframe with scripting restrictions. Navigation outside the book context is blocked.
#### 5. Supply Chain
- **Risk:** Compromised npm or Cargo packages could introduce malicious code.
- **Mitigations:** Dependencies are pinned via `pnpm-lock.yaml` and `Cargo.lock`. Dependabot and GitHub's dependency review are enabled for automated vulnerability detection.
#### 6. Desktop Native Code (Tauri)
- **Risk:** Tauri IPC commands could be abused by malicious web content to access the filesystem or OS APIs.
- **Mitigations:** Tauri's allowlist restricts which IPC commands are exposed. File system access is scoped to the application data directory.
### Out of Scope
- Vulnerabilities in user's operating system or browser outside of Readest's control
- Physical access attacks to a user's device
- Issues in third-party services (DeepL, Yandex, Calibre) themselves
## Supported Versions
Readest does not currently maintain separate release channels. Security updates are provided only for the latest release series.
| Version | Supported |
| ------- | ------------------ |
| 0.10.x | :white_check_mark: |
| < 0.10 | :x: |
## Reporting a Vulnerability
Please report suspected vulnerabilities privately. Do not open a public GitHub
issue or discussion for security-sensitive reports.
Use GitHub's private vulnerability reporting for this repository:
- [Share-a-Book Feature (in progress)](share-feature.md) — locked decisions for the /s/{token} share-link feature; plan at ~/.claude/plans/ok-we-will-learn-cosmic-acorn.md
- [readest.koplugin i18n](koplugin-i18n.md) — gettext loader at `apps/readest.koplugin/i18n.lua`, `.po` catalog at `locales/<i18next-code>/translation.po`, extract/apply scripts in `scripts/`
## Patterns
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars hook integration for overlay scrollbars on mobile webviews
- [Design system → DESIGN.md](feedback_design_system_doc.md) — codify recurring UI/UX rules in `apps/readest-app/DESIGN.md`; never `pl/pr/ml/mr/text-left/text-right` (RTL); §5 boxed list anatomy has uniform `min-h-14` rows and chromeless controls
## Architecture Notes
- foliate-js is a git submodule at `packages/foliate-js/`
- Multiview paginator: loads adjacent sections in background, multiple View/Overlayer instances per book
- Dropdown menus use `DropdownContext` (not blur-based) for screen reader compat
## Workflow
- [Test file filter](feedback_test_file_filter.md) — use `pnpm test <path>` without `--` to run a single file
- [Always rebase before PR](feedback_pr_rebase.md) — rebase onto origin/main before creating PRs
- [New branch per PR](feedback_pr_new_branch.md) — always create a fresh branch from main for each new PR/issue
- [Upgrade gstack locally](feedback_gstack_upgrade.md) — always upgrade from the project's .claude/skills/gstack, not global
- [No lookbehind regex](feedback_no_lookbehind_regex.md) — never use `(?<=)` or `(?<!)` in JS/TS; build check rejects them
- [Use worktree](feedback_use_worktree.md) — never `git worktree add` directly; always `pnpm worktree:new` before PR review, issue fix, or feature work
- [en/translation.json holds ONLY plural variants + proper nouns](feedback_en_plurals_manual.md) — non-plural strings stay out (defaultValue: key is the en source); plural strings (`_('...', { count })`) need hand-added `_one`/`_other` entries or the singular renders as "1 days"
- [Never push on every change](feedback_dont_push_every_change.md) — hold pushes during active bug iteration; commit locally only until user confirms or work hits a clean done-state
- [No test seams in production code](feedback_no_test_seams_in_prod.md) — production must never import or call `__reset*ForTests`; cross-module test resets belong in the test file's beforeEach/afterEach
-`useInstantAnnotation.ts` - Creating new annotations on selection
### Highlight Rendering
- Highlights rendered by foliate-js `Overlayer` (SVG overlayer in paginator shadow DOM, not iframe)
- Each view in multiview paginator has its own `Overlayer` instance with unique clipPath ID
-`Overlayer.add()` stores range + draw function; `redraw()` recalculates positions from stored ranges
- Colors stored as color names mapped to custom hex via `globalReadSettings.customHighlightColors`
- Sidebar uses `color-mix()` CSS function with custom colors, not Tailwind utility classes (#3273)
- Rounded highlight style supported via `vertical` option passed to overlayer (#3208)
### Multiview Overlayer Pitfalls
- **Duplicate SVG IDs**: Each overlayer creates `<clipPath>` for loupe hole — IDs MUST be unique per instance or `url(#id)` resolves to wrong element, clipping everything
- **docLoadHandler scope**: `FoliateViewer.tsx` re-adds annotations on `load` event — MUST filter by `detail.index` (loaded section), not re-add ALL annotations (overwrites drag edits)
- **MagnifierLoupe lifecycle**: Don't destroy/recreate loupe on every drag tick — `hideLoupe()` should only run on unmount, `showLoupe()` fast path updates position only
- **Stale closures in useTextSelector**: `getProgress()` must be called inside callbacks, not captured at hook top-level (useFoliateEvents deps are `[view]` only)
## Fix History
| Issue | Problem | Root Cause | Fix |
|-------|---------|------------|-----|
| #3286 | Selection stuck on first annotation | `initializedRef` guard blocked re-computation | Remove guard, consolidate style/color effects |
| #3273 | Custom colors not in sidebar | Hardcoded Tailwind classes | Use inline `style` with `color-mix()` |
| #3234 | Letter-by-letter selection on mobile | No word boundary snapping | Add `snapRangeToWords()` using `Intl.Segmenter` |
| #3208 | Hard rectangular highlights | No border radius support | Pass `vertical` option, update foliate-js |
| #3002 | Can't see text under finger | No magnification UI | New `MagnifierLoupe` component using `view.renderer.showLoupe()` |
| #3082 | No page numbers on annotations | `pageNumber` field missing | Add `pageNumber` to BookNote type, compute on create |
**Pattern:** A CSS rule targets too many elements, causing unintended visual side effects.
**Examples:**
-`hr { mix-blend-mode: multiply }` applied to ALL hr elements instead of only decorative ones (#3086)
-`p img { mix-blend-mode }` applied to block images, not just inline (#3112)
-`svg, img { height: auto; width: auto }` overrode explicit HTML width/height attributes (#3274)
- Background-color override applied unconditionally instead of only when user enabled color override (#3316)
**Fix Strategy:** Narrow selectors with class qualifiers (`.background-img`, `.has-text-siblings`) or attribute pseudo-selectors (`:where(:not([width]))`). Check if the rule should be conditional on a user setting.
### 2. Conditional vs Unconditional Style Overrides
**Pattern:** CSS rules meant for "Override Book Color/Layout" mode are placed in the always-active stylesheet.
**Examples:**
- Calibre `.calibre { color: unset }` was in `getLayoutStyles()` instead of `getColorStyles()` (#3448)
- Image background-color override applied without checking `overrideColor` flag (#3316, #3377)
**Fix Strategy:** Move rules to the correct conditional block: `getColorStyles()` for color overrides, `getLayoutStyles()` for layout overrides. Check the `overrideColor`/`overrideLayout` flags.
### 3. Missing EPUB Stylesheet Transformations
**Pattern:** EPUB stylesheets contain CSS that conflicts with app functionality.
**Examples:**
-`user-select: none` prevents text selection (#3370) -> regex replace in `transformStylesheet()`
-`font-family: serif/sans-serif` on body bypasses user font (#3334) -> detect and unset
- Hardcoded Calibre backgrounds persist in dark mode (#3448) -> unset in color override
**Fix Strategy:** Add regex-based transformation passes in `transformStylesheet()` in `style.ts`.
### 4. Stale State / Refs Not Reset
**Pattern:** A `useRef` or state variable is set once and never properly reset, blocking re-entry.
**Examples:**
- TTS `ttsOnRef` prevented restarting TTS from a new location (#3292)
-`initializedRef` in AnnotationRangeEditor prevented handle position updates (#3286)
-`view.tts` not nulled on shutdown prevented clean TTS restart (#3400)
- TTS safety timeout fired after pause, advancing to next sentence (#3244)
**Fix Strategy:** Check all refs/guards in the affected flow. Ensure cleanup in shutdown/unmount. Remove overly aggressive guards that prevent re-entry.
### 5. Platform API Differences
**Pattern:** A Web API behaves differently or is unavailable on certain platforms.
**Examples:**
-`navigator.getGamepads()` returns null on older Android WebView (#3245)
-`CompressionStream` unavailable on some Android versions (#3255)
-`btoa()` throws on non-ASCII characters (#3436)
- View Transitions API unsupported in WebKitGTK/Linux (#3417)
-`document.startViewTransition()` crashes on Linux
**Fix Strategy:** Always check API availability before use. Add fallback paths. Use feature detection, not platform detection when possible.
### 6. Safe Area Inset Issues
**Pattern:** UI elements overlap system bars (status bar, navigation bar, notch) on mobile.
**Examples:**
- Zoom controls behind status bar (#3426)
- Android navigation bar overlap (#3466)
- iPad sidebar insets incorrect (#3395)
- Reader page layout jump after system UI change (#3469)
**Fix Strategy:** Use `gridInsets` and `statusBarHeight` from `useSafeAreaInsets`. Use `env(safe-area-inset-*)` CSS functions. Call `onUpdateInsets()` after system UI visibility changes. See `docs/safe-area-insets.md`.
### 7. Z-Index Layering Issues
**Pattern:** Interactive elements rendered behind other layers, becoming unclickable.
**Examples:**
- Navigation buttons invisible on mobile (#3201) -> added `z-10`
- Annotation nav bar too prominent (#3386) -> reduced from `z-30` to `z-10`
- Page nav buttons behind TTS control (#3184)
**Fix Strategy:** Check z-index ordering. Use minimum necessary z-index. Reference the z-index hierarchy in the codebase.
### 8. Event Handling Race Conditions
**Pattern:** Timing issues between pointer events, native menus, and React state updates.
- Traffic light buttons flicker due to timeout race (#3488, #3129)
- Android tool buttons unresponsive due to premature re-selection (#3225)
**Fix Strategy:** Add small delays before native menu calls. Check event state machine consistency. Remove premature re-triggers on Android.
### 9. foliate-js Rendering Issues
**Pattern:** Bugs in the lower-level EPUB renderer (paginator.js, epub.js).
**Examples:**
- Image size not constrained in double-page mode (#3432)
- Background not shown in scrolled mode (#3344)
- Section content cached incorrectly after mode switch (#3242, #3206)
- Swipe sensitivity too low for non-animated paging (#3310)
**Fix Strategy:** Check both `columnize()` and `scrolled()` code paths in paginator.js. Verify CSS variables (`--available-width`, `--available-height`) are computed correctly. Test in both paginated and scrolled modes.
### 10. Progress/Navigation Calculation Errors
**Pattern:** Page counts, progress percentages, or position tracking are wrong.
**Examples:**
- Progress shows 99.9% at last page (#3383) -> boundary condition
- Pages left shows estimated instead of physical count (#3213, #3200)
- TOC auto-scrolls on expand (#3124) -> scroll-into-view triggered too broadly
**Fix Strategy:** Use physical `view.renderer.page`/`view.renderer.pages` instead of estimated section metadata. Check boundary conditions (0-indexed vs 1-indexed, inclusive vs exclusive).
### 11. Debounced State Stale on User-Initiated Layout Change
**Pattern:** A scroll/resize handler is debounced for performance, but during the debounce window any code path that re-runs layout based on saved state (e.g. `#anchor`, `#primaryIndex`) sees stale values.
**Example:**
- Scrolled-mode toggle reverted to previous chapter (#3987): the paginator's scroll handler is debounced 250 ms, so toggling `flow=scrolled → flow=paginated` within that window made `render() → scrollToAnchor(#anchor)` restore the anchor from before the user scrolled into the next section. Both `#anchor` and `#primaryIndex` were stale together, sending the position back.
**Fix Strategy:** When an external trigger forces a re-render (here, `setAttribute('flow', ...)`), flush the debounced state synchronously *before* changing the layout. In paginator.js this means overriding `setAttribute` and calling `#detectPrimaryView()` + `#getVisibleRange()` while `this.scrolled` is still true.
### 12. Multiview Paginator Side Effects
**Pattern:** The multiview paginator (e925e9d+) loads adjacent sections in background. Events from these loads can interfere with user interactions on the primary section.
**Examples:**
-`load` event from adjacent section triggers `docLoadHandler` which re-adds ALL annotations, overwriting drag edits
- Multiple overlayers with duplicate SVG `<clipPath>` IDs cause `url(#id)` to resolve to wrong element
-`MagnifierLoupe` destroying/recreating body clone on every drag tick triggers ResizeObserver → expand → redraw
**Fix Strategy:** Scope event handlers to the loaded section's index. Use unique IDs for SVG elements across overlayer instances. Minimize iframe DOM mutations during drag operations.
## Debugging Workflow
1.**Identify the category** from the issue description
2.**Check `style.ts`** first for any CSS-related visual bugs
3.**Check foliate-js** for rendering/layout bugs
4.**Check platform-specific code** for mobile/desktop differences
5.**Write a failing test** before implementing the fix
6.**Test in both paginated and scrolled modes** for layout changes
7.**Test on multiple platforms** for any UI change
The Node `ws` npm package (used transitively by `isomorphic-ws`) opens WebSockets by calling `http.request({ createConnection })`. The Cloudflare Workers runtime does not implement `options.createConnection`, so any attempt to `new WebSocket(url, { headers })` in a Worker throws:
```
The options.createConnection option is not implemented
```
This applies even with `compatibility_flags = ["nodejs_compat"]`.
## Correct pattern: fetch-based upgrade
On Workers you open a WebSocket by calling `fetch()` with an `Upgrade: websocket` header against the **https://** (not `wss://`) form of the URL. The response has `status === 101` and a non-standard `webSocket` property that must be `accept()`ed before use:
thrownewError(`WebSocket upgrade failed with status ${response.status}`);
}
constws=response.webSocket;
ws.addEventListener('message',onMessage);
ws.accept();
ws.send(payload);
```
Detect the Workers runtime with `typeof globalThis.WebSocketPair !== 'undefined'` — `WebSocketPair` is a Workers-only global.
## Binary frames arrive as Blob (critical)
Cloudflare Workers deliver WebSocket binary frames as **`Blob`** — not `ArrayBuffer` (browsers) and not `Uint8Array` (Node `ws`). Blob decoding is async via `blob.arrayBuffer()`, so:
1. You must serialize decodes through a promise chain to keep frames in receive order — otherwise parallel awaits can merge bytes out of order.
2. Any terminal text message (e.g. Edge TTS's `Path: turn.end`) arrives **synchronously** and will finalize the stream before the in-flight Blob decodes have flushed. Always `await pendingBinary` in the turn.end handler and the close handler before checking whether data was received.
`src/libs/edgeTTS.ts``#fetchEdgeSpeechWs` has three branches: Tauri (plugin-websocket), Cloudflare Workers (fetch upgrade + Blob handling), and browser/Node fallback (`isomorphic-ws`). The route that exercises the CF branch is `src/app/api/tts/edge/route.ts`, hit when the web client falls back from direct `wss://` (which browsers can't set headers on) to the `/api/tts/edge` HTTPS endpoint.
description: Android TV / Bluetooth remote D-pad navigation architecture, key files, and pitfalls encountered during implementation
type: project
---
## D-pad Navigation Architecture
D-pad support enables Bluetooth remote controller navigation on Android TV (and keyboard arrow navigation on desktop).
### Key Files
-`src/app/reader/hooks/useSpatialNavigation.ts` — Reader toolbar D-pad navigation. Left/Right navigates between buttons, Up/Down moves between header↔footer. Auto-focuses first button on show. Uses focus-probe technique for visibility detection.
-`src/app/library/hooks/useSpatialNavigation.ts` — Library grid D-pad navigation. Arrow keys move between BookshelfItem elements. ArrowDown from outside bookshelf (e.g. header) enters the grid via window-level listener.
-`src/app/reader/hooks/useBookShortcuts.ts` — `toggleToolbar` handler shows/hides header+footer bars. Skips when a `<button>` is focused (lets native click fire).
-`src/__tests__/hooks/useSpatialNavigation.test.tsx` — Unit tests for reader toolbar navigation.
### Design Decisions
- **No third-party library**: Tried `@noriginmedia/norigin-spatial-navigation` but it failed due to init timing issues (React child effects run before parent effects) and conflicts with the existing `useShortcuts` system. Custom solution is simpler and more reliable.
- **Two `useSpatialNavigation` hooks**: Same name in different directories — library version handles grid navigation, reader version handles toolbar button navigation. Different navigation patterns but same concept.
- **Platform-agnostic hooks**: Both `useSpatialNavigation` hooks work on all platforms, not just Android.
- **Focus-probe for visibility**: `offsetParent` is unreliable for detecting visible buttons (returns null inside `position: fixed` containers on mobile). Instead, try `btn.focus()` and check if `document.activeElement === btn` — this correctly handles all hiding methods (display:none, visibility:hidden, fixed positioning).
### Pitfalls
1.**WebView spatial navigation conflict**: Android WebView has built-in spatial navigation that intercepts D-pad arrow keys and moves DOM focus between `tabIndex>=0` elements. Added `tabIndex={-1}` to non-interactive overlay elements (HeaderBar trigger, ProgressBar, FooterBar trigger, SectionInfo) to prevent focus theft.
2.**`eventDispatcher.dispatchSync` short-circuits**: When multiple handlers are registered for `native-key-down`, the first handler returning `true` stops propagation. The FooterBar's Back handler fires before the Reader's. Both must independently call `blur()` — can't rely on the Reader's handler running.
3.**Must blur on toolbar dismiss**: When Back/Escape dismisses the toolbar, the focused button must be blurred. Otherwise `document.activeElement` remains a hidden button, and `toggleToolbar` skips Enter when `activeElement.tagName === 'BUTTON'`. Blur is called in FooterBar's handleKeyDown (for Back and Escape) and in Reader's handleKeyDown.
4.**Arrow key trapping must use `stopPropagation`**: Without it, arrow keys bubble to `window` where `useShortcuts` handles them as page turns. The toolbar keydown handler on the container div calls `e.stopPropagation()` + `e.preventDefault()` to prevent this.
5.**Library grid needs window-level listener**: The bookshelf container keydown handler only fires when focus is inside it. A separate `window` keydown listener handles ArrowDown from the header into the grid (when focus is outside the container).
6.**Auto-focus race on toolbar show**: Both header and footer bars auto-focus their first button when `isVisible` becomes true simultaneously. The last effect to run wins. This is acceptable — user can navigate between them with Up/Down.
7.**`offsetParent` null in fixed containers**: On mobile, `.footer-bar` uses `position: fixed`. All child buttons have `offsetParent === null`, making `offsetParent`-based visibility checks useless. The focus-probe approach (try focus, check activeElement) is the reliable alternative.
description: Readest has a design system doc — codify recurring UI/UX rules there, don't just apply them ad-hoc. Memory points at the canonical location and the patterns it covers.
Don't `git push` after each commit while a bug is being actively iterated on. Commit locally as needed but hold the push.
**Why:** When a fix doesn't actually solve the user-reported bug, every push is wasted CI cycles + remote churn the user has to look past on the PR. The user is testing live and will tell us when something's actually verified.
**How to apply:** During debugging or fix iterations on a single user-reported bug, commit locally only. Push when (a) the user confirms the fix works, (b) the user explicitly asks to push, or (c) we hit a clean done-state on a multi-step task. New commits + lint/test green is not enough.
name: en/translation.json holds ONLY plural variants and proper-noun overrides
description: For non-plural strings, do NOT add to en/translation.json — the source-code key IS the en value via `defaultValue: key`. ONLY plural strings need explicit `_one`/`_other` entries in en, because i18next needs the forms to pick from.
**Rule:**`public/locales/en/translation.json` is hand-curated and contains essentially two kinds of entries:
1.**Plural variants** (`<base>_one`, `<base>_other`, and locale-specific `_few`/`_many`/etc. as needed by CLDR). i18next MUST find these to know which form to render — without them, `count: 1` falls back to the bare key like `{{count}} days` and renders "1 days" instead of "1 day".
2.**Proper-noun overrides** (e.g., font names like `LXGW WenKai GB Screen`) where the en value differs from the key.
Everything else — ordinary translatable strings like `Sign in to share books` — does NOT belong in en/translation.json. The translation hook calls `t(key, { defaultValue: key, ...options })`, so for any string not in en/translation.json, i18next renders the key itself. That's why a 51-key en file works for a codebase with thousands of `_()` callsites.
**Why en is hand-curated:** the project's `i18next-scanner.config.cjs` lists every locale EXCEPT `en` in its `lngs` array. The scanner generates `__STRING_NOT_TRANSLATED__` placeholders only for the listed locales; `en` is never touched.
**Workflow when adding `_(...)` calls:**
- **Non-plural string** (e.g. `_('Sign in to share books')`): add the `_(...)` call, run `pnpm run i18n:extract`, translate the new placeholders in non-en locales. **Do NOT touch `en/translation.json`** — the key itself is the en value.
- **Plural string** (e.g. `_('{{count}} days', { count: n })`): same as above, PLUS hand-add `<base>_one` and `<base>_other` to `en/translation.json` (and `_few`/`_many`/etc. only if the source language ever needs them, which English doesn't). Convention from existing entries (e.g., `Are you sure to delete {{count}} selected book(s)?_one` → `Are you sure to delete {{count}} selected book?`): keep `{{count}}` interpolated even in `_one`, and swap any `(s)` placeholder to the proper singular/plural noun.
**Audit script:** walk `src/`, regex-match `_('...', { ..., count: ... })`, for each base key verify both `<base>_one` and `<base>_other` exist in `en/translation.json`. (See conversation history for an implementation.)
**Where this bit us:**
- Initial bug: `_('{{count}} days', { count: n })` rendered "1 days" because en had no `_one` form.
- Audit found 16 missing en plural keys from earlier PRs (OPDS / TTS / dictionary import) silently rendering wrong.
- Then overcorrected and added non-plural keys like `Sign in to share books` to en/translation.json — wrong, breaks the project's convention. en stays clean for non-plural strings.
description: Always upgrade gstack from the project directory (.claude/skills/gstack), not from a global install
type: feedback
---
When upgrading gstack, always run the upgrade from the current project's `.claude/skills/gstack` directory (local-git install), not from a global install path.
**Why:** The project uses a local-git gstack install at `apps/readest-app/.claude/skills/gstack`. Previous mistakes upgraded a global copy while the project's local copy stayed outdated.
**How to apply:** When `/gstack-upgrade` is invoked, ensure the `cd` and `git reset --hard origin/main && ./setup` happen inside the project's `.claude/skills/gstack` directory.
description: Never use lookbehind assertions in JS/TS code — the build check rejects them for browser compatibility
type: feedback
---
Never use lookbehind regex (`(?<=...)` or `(?<!...)`) in JavaScript/TypeScript source code. Use `(?:^|[^...])` or other alternatives instead.
**Why:** The project has a `check:lookbehind-regex` build check (`pnpm check:all`) that scans the Next.js output chunks and fails if any lookbehind assertions are found. Older WebViews (especially on some Android devices) don't support lookbehinds.
**How to apply:** When writing regex that needs to assert what comes before a match, use a non-capturing group with alternation (e.g., `(?:^|[^a-z-])`) instead of a negative lookbehind (`(?<![a-z-])`). This applies to all `.ts`/`.tsx`/`.js` files that end up in the build output.
description: Each new PR/issue should get its own fresh branch from main, never reuse an existing feature branch
type: feedback
---
Always create a new branch from main for each new PR or issue. Never reuse an existing feature branch for unrelated work.
**Why:** The user corrected this when a storage fix was committed on the `feat/full-sync-annotations` branch instead of a dedicated branch. Mixing unrelated changes on the same branch makes PRs harder to review and manage.
**How to apply:** Before committing fixes, create a new branch like `fix/<topic>` from `origin/main`. Only reuse a branch if the work is directly related to that branch's existing purpose.
description: Rebase to origin/main before creating pull requests
type: feedback
---
Always rebase the branch onto origin/main before creating a pull request.
**Why:** The user wants PRs to be up-to-date with main to avoid merge conflicts and keep a clean history.
**How to apply:** Before running `gh pr create`, always run `git fetch origin && git rebase origin/main` first. If there are conflicts, resolve them before proceeding.
description: Use pnpm test/test:browser with path directly (no --) to run a single test file
type: feedback
---
Run a specific test file with `pnpm test <path>` or `pnpm test:browser <path>` — no `--` separator.
**Why:** Adding `--` before the path (e.g. `pnpm test:browser -- <path>`) causes vitest to ignore the file filter and run all test files. Without `--`, pnpm appends the path directly to the vitest command, which correctly filters to that file only.
**How to apply:** Always use `pnpm test src/__tests__/foo.test.ts` or `pnpm test:browser src/__tests__/foo.browser.test.tsx` when verifying a specific test file.
Always use `pnpm worktree:new <branch-name|pr-number>` to create an isolated worktree before starting work on:
- Reviewing a GitHub PR (e.g., `pnpm worktree:new 3809`) → worktree at `~/dev/readest-pr-3809`
- Fixing a GitHub issue (e.g., `pnpm worktree:new fix/issue-123`) → worktree at `~/dev/readest-fix-issue-123`
- Implementing a feature request (e.g., `pnpm worktree:new feat/my-feature`) → worktree at `~/dev/readest-feat-my-feature`
Worktree directory convention: `readest-<name>` in the parent of the repo root (`~/dev/`), with slashes replaced by dashes.
Use `pnpm worktree:rm <branch-name|pr-number>` to clean up when done.
**Why:** Keeps the current bare repo branch untouched. Each task gets its own isolated workspace with submodules, dependencies, env files, and vendor assets already set up.
**How to apply:** Before touching any code for a PR review, bug fix, or feature, run `pnpm worktree:new` first. Work inside the new worktree directory (e.g., `~/dev/readest-pr-3809/apps/readest-app/`). Clean up with `pnpm worktree:rm` after merging or finishing.
The KOReader plugin (`apps/readest.koplugin/`) has its own gettext-based i18n system, parallel to but separate from the readest-app i18next setup.
## Loader
- File: `apps/readest.koplugin/i18n.lua` — isolated module, returns a callable table via `setmetatable({}, {__call = ...})`, so `_("msg")` syntax works as a drop-in replacement for `require("gettext")`. Provides `ngettext`/`pgettext`/`npgettext` too. Falls back to KOReader's native `gettext` for missing strings.
- All Lua sources do `local _ = require("i18n")` (not `require("gettext")`).
- **Never rename `i18n.lua` to `gettext.lua`** — it would shadow KOReader's module via `require("gettext")` and break the fallback chain (recursive require / never loaded).
## Catalog layout
-`apps/readest.koplugin/locales/<lang>/translation.po` — mirrors `apps/readest-app/public/locales/<lang>/translation.json` exactly, using **i18next-style codes** (`zh-CN`, not `zh_CN`).
- The loader converts KOReader's locale (e.g. `zh_CN.utf8` → `zh-CN`) before lookup.
- Fallback chain: full code → base lang (`pt_BR` → `pt`) → `zh-CN` for any unspecified zh variant.
- Language list is the single source of truth at `apps/readest-app/i18next-scanner.config.cjs` (`options.lngs`) — currently 31 languages.
## Scripts (in `apps/readest.koplugin/scripts/`)
- **`extract-i18n.js`** — primary tool. Run with `node scripts/extract-i18n.js`. Scans `*.lua` for `_("...")`, `_('...')`, and `_([[...]])` (with proper Lua-escape handling), reads each `.po`, **preserves existing translations**, adds new msgids with empty `msgstr`, drops obsolete msgids. Idempotent.
- **`apply-translations.js`** — bulk applier. Reads `/tmp/koplugin-translations/<lang>.json` files (key = msgid, value = translation) and fills empty `msgstr ""` lines only — **never overwrites** existing translations.
## Workflow for adding/changing strings
1. Edit Lua source(s). Use `_("Foo")` or `T(_("Foo %1"), arg)` (`T` from `require("ffi/util").template`) — **never**`_("Foo ") .. arg`, because RTL/verb-final languages can't reorder the placeholder.
2.`node scripts/extract-i18n.js` — adds new empty msgids, drops obsolete.
3. To translate: drop `<lang>.json` files into `/tmp/koplugin-translations/`, then `node scripts/apply-translations.js`.
4. Verify with `luac -p apps/readest.koplugin/*.lua` and re-run `extract-i18n.js` (should report no changes — idempotency check).
## Translation conventions
- Brand names "Readest" and "KOReader" stay untranslated.
- Technical terms ("PDF", "API", "URL", "Supabase", "Hash") generally kept as-is, sometimes transliterated in non-Latin scripts.
- Dialog title = title case (`Sync Info`); menu item label = sentence case (`Sync info`).
description: Active implementation of /s/{token} share links + cherry-picks (CFI auto-include, 1-tap library import, branded OG images). All locked decisions and the plan file path.
Active feature on branch `dev` as of 2026-05-02. Plan file: `/Users/chrox/.claude/plans/ok-we-will-learn-cosmic-acorn.md` (always read for source of truth before continuing).
**Why:** complement just-shipped annotation deep-links (PRs #4018, #4019) with whole-book sharing. Cloud-sync infra exists; this layers public time-limited links on top.
**How to apply:** these decisions are locked across CEO + eng + design reviews. Do NOT re-litigate when implementing.
## Locked decisions (authoritative)
- **Universal 7-day cap** on share expiry, no tier differentiation, no "Never". All users pick `[1, 3, 7]` days. DMCA-risk reduction.
- **App Router** for all share routes (mirrors `/o` precedent + recent Stripe / AI / IAP / OPDS). Pages API `storage/*` neighbors stay where they are.
- **Single non-dynamic landing page** at `src/app/s/page.tsx` + rewrite `'/s/:token' → '/s?token=:token'` in `next.config.mjs`. Mirrors `/o`'s pattern exactly. Avoids `[token]` dynamic-segment trap under `output: 'export'`.
- **R2 server-side byte-copy** for `/import` (recipient-side library import). NOT a reference. Preserves invariant that every `files` row's `file_key` starts with that row's `user_id` — keeps stats / purge / delete / download routes working unchanged.
- **`token_hash` (sha256) in DB**, never the raw token. Raw token shown to user once at create.
- **Live `(user_id, book_hash)` resolution** at every access (no FK to `files`). Re-uploads of the same hash follow the share automatically.
- **GET `/download` is a 302 redirect with NO DB writes**. Count via separate `POST /download/confirm` so unfurlers / prefetchers can't inflate counts.
- **Atomic SQL `download_count = download_count + 1`**, not read-modify-write.
- **Per-user 50-share cap**, enforced at create-time.
- **Auth detection on /s landing**: server-render via Supabase auth cookie in `app/s/layout.tsx`. No layout shift. Falls back to anonymous flow + post-hydration upgrade if SSR fails.
- **Cover-less `og.png` fallback**: text-only display-type card. NO placeholder rectangle, NO procedural pattern.
- **Universal Links / App Links**: handle `https://web.readest.com/s/...` in v1 via `useOpenShareLink`. Tauri's `applinks` config already covers the host.
1.**Position-aware share** — every share initiated from the reader auto-includes `cfi`; recipient lands at sharer's paragraph. Toggle in dialog (default on, only visible when reader-context).
3.**1-tap "Add to my library"** — logged-in recipient on `/s` gets primary action that calls `/import` (R2 byte-copy) and navigates to `/reader?ids=...&cfi=...`.
4.**Branded OG image** — `/api/share/[token]/og.png` server-renders via `@vercel/og`. Spec includes anti-slop checklist.
Deferred to TODOS.md: QR code on landing, notify-on-download toggle.
## Open implementation questions (resolve mid-build)
1. Upload-confirmation: HEAD R2 in `/create`, or add `uploaded_at` column to `files`? — recommend HEAD.
2. Migration directory: project has no `apps/readest-app/supabase/migrations/`. Confirm where SQL actually lands before writing the file.
3.`@vercel/og` runtime compat with CloudFlare Workers (OpenNextJS). Verify before relying on it; fallback to Satori + sharp if incompatible.
4. App Router route handlers under `src/app/api/share/...` should be silently dropped by `output: 'export'` in Next 16.2.3. Confirm during first Tauri build.
## Critical files for implementation
See "Critical Files (modify or create)" table in the plan. Key starting points:
-`src/libs/storage-server.ts` — extract `getDownloadSignedUrl` from `pages/api/storage/download.ts`
Virtuoso manages its own internal scroller. On Android WebView (and similar) native scrollbars auto-hide, so users see no scrollbar. The fix: wrap Virtuoso with OverlayScrollbars using the `useOverlayScrollbars` hook — **not** the `OverlayScrollbarsComponent`.
## Migration from `customScrollParent`
The previous approach used `customScrollParent` to let an outer `OverlayScrollbarsComponent` own the scroll. This was replaced: Virtuoso now owns its own scroller, and OverlayScrollbars wraps it. This means:
- Remove `customScrollParent` prop from Virtuoso/VirtuosoGrid
- Remove the outer `OverlayScrollbarsComponent` wrapper
- Use `scrollerRef` instead to capture Virtuoso's scroller element
- If the parent needs the scroller ref (e.g. for pull-to-refresh, scroll save/restore), expose it via a callback prop like `onScrollerRef`
For `VirtuosoGrid`, same pattern — pass `scrollerRef={handleScrollerRef}`.
## Footer spacer
When Virtuoso owns its own scroller (no `customScrollParent`), the last items may be hidden behind bottom UI (tab bars, safe area). Add a Virtuoso `Footer` component to the components config:
```tsx
constVIRTUOSO_COMPONENTS={
List: MyListComponent,
Footer:()=><divstyle={{height: 34}}/>,
};
```
## Key points
- **`useOverlayScrollbars`** hook, not `OverlayScrollbarsComponent` — the component can't share a viewport with Virtuoso
- Wrapper div needs `ref={osRootRef}` and `data-overlayscrollbars-initialize=""`
-`initialize({ target: root, elements: { viewport: scroller } })` tells OverlayScrollbars to use Virtuoso's existing scroller as its viewport (no new DOM element)
- The `initialized` event **must** restore overflow CSS vars (`--os-viewport-overflow-x/y`) so OverlayScrollbars doesn't fight Virtuoso's scroll management
- No custom Scroller component needed — `scrollerRef` replaces the old `Scroller` component pattern (e.g. `TOCScroller` was removed)
## Used in
-`src/app/library/components/Bookshelf.tsx` — library grid/list with parent scroller exposure for pull-to-refresh and scroll save/restore
-`src/app/reader/components/sidebar/TOCView.tsx` — sidebar TOC (self-contained, no parent scroller needed)
Before marking work complete, all applicable checks must pass:
1.`pnpm test` — unit tests (vitest)
2.`pnpm lint` — Biome + tsgo (also runs `pnpm lint:lua` if luajit is installed)
3.`pnpm test:lua` — busted unit tests for `apps/readest.koplugin/spec/` (only when koplugin Lua files changed; soft-skips when busted/luajit not installed)
4.`pnpm fmt:check` — Rust format check (only when `src-tauri/` files changed)
5.`pnpm clippy:check` — Rust lint (only when `src-tauri/` files changed)
Extract i18n strings from readest.koplugin Lua sources and translate empty
msgstrs in apps/readest.koplugin/locales. Use when the user invokes
/i18n-koplugin or asks to extract/translate koplugin i18n strings.
Runs scripts/extract-i18n.js to sync .po catalogs from `_("...")` calls,
then fills any empty `msgstr ""` entries across all locale files.
user_invocable: true
---
Extract/translate i18n strings for `readest.koplugin`. The catalogs are gettext `.po` files (not JSON like the main app). Run from the repo root or any worktree — the script resolves paths relative to the plugin dir.
## Step 1: Determine the working directory
If currently in a PR worktree (e.g., `/Users/chrox/dev/readest-pr-*`), use that. Otherwise use the main repo. The plugin dir is `<repo-root>/apps/readest.koplugin`.
## Step 2: Extract msgids from Lua sources
```bash
cd <repo-root>/apps/readest.koplugin
node scripts/extract-i18n.js
```
This scans every `*.lua` file under `apps/readest.koplugin/` (except `spec/` and dotdirs) for `_("...")` and `_([[...]])` calls, then for each language listed in `apps/readest-app/i18next-scanner.config.cjs`:
- appends new msgids with empty `msgstr ""`
- preserves existing translations
- drops obsolete msgids
- rewrites the `.po` header (Plural-Forms etc.)
The output prints `<lang> <kept>/<total> (-<dropped> obsolete)` per locale.
## Step 3: Find untranslated entries
An untranslated entry is a non-empty `msgid` followed by an empty `msgstr ""` (the file's header pair `msgid ""` / `msgstr ""` is NOT a translation — skip it).
```bash
cd <repo-root>/apps/readest.koplugin/locales
# List locales that still have untranslated strings, with counts
for f in */translation.po;do
# Count empty msgstrs that follow a non-empty msgid
n=$(awk '
/^msgid "/ { msgid=$0; next }
/^msgstr ""$/ { if (msgid != "msgid \"\"") c++; next }
'"$f")
["$n" -gt 0]&&echo"$f: $n untranslated"
done
```
If no results, report that all strings are translated and stop.
To list the actual untranslated msgids in one locale:
```bash
awk '
/^msgid "/ { msgid=$0; next }
/^msgstr ""$/ { if (msgid != "msgid \"\"") print msgid; next }
1. Read the preceding `msgid "..."` — that's the English source string.
2. Identify the target locale from the file path (e.g., `locales/ja/translation.po` → Japanese; see table below).
3. Provide an accurate translation. Use the locale reference table for the language; match the tone/terminology already used in the same file (check existing translated entries for context).
4. Preserve `printf`-style placeholders verbatim: `%s`, `%d`, `%1$s`, `%(name)s`, etc.
5. Preserve newlines as `\n`, tabs as `\t`, and escape `"` as `\"` and backslashes as `\\` inside the msgstr.
Edit the `.po` files directly with the Edit tool — do NOT use sed for this, because msgids may contain characters that confuse shell quoting. Each replacement should target the unique `msgid "<English>"\nmsgstr ""` block:
Old:
```
msgid "<English string>"
msgstr ""
```
New:
```
msgid "<English string>"
msgstr "<translation>"
```
Batch all locales for the same key together when possible — keeps the translation set consistent.
### Locale reference
The supported language set is **not hardcoded in this skill**. The ground truth is `apps/readest-app/i18n-langs.json` — both `i18next-scanner.config.cjs` (via `require`) and `src/i18n/i18n.ts` (via JSON import) source from it, and `extract-i18n.js` reads `lngs` from the scanner config at runtime. To list the current set:
```bash
cat <repo-root>/apps/readest-app/i18n-langs.json
```
Map each code to a language name when translating. If a code in `i18n-langs.json` is missing from the `LANG_META` table inside `extract-i18n.js`, the script prints `<code> skipped (no metadata in extract-i18n.js)` — in that case, add the metadata entry there first, then re-run extraction.
## Step 5: Verify
Re-run the count loop from Step 3 and confirm zero untranslated strings remain. Report:
- number of msgids extracted
- per-locale count of strings translated
- any locales that were already complete
Optionally, run the koplugin Lua tests if `busted`/`luajit` are installed:
Extract i18n strings, translate missing translations, or add a new language to readest-app.
Use when the user invokes /i18n or asks to extract/translate i18n strings or add a new locale.
Runs i18next-scanner to extract keys, then translates any __STRING_NOT_TRANSLATED__
placeholders across all locale files.
user_invocable: true
---
Extract/translate i18n strings or add a new language for readest-app. Run from the app directory (either the main repo or a worktree).
## Step 0: Determine the mode
- If the user asks to **add a new language/locale**, go to **Adding a New Language** below.
- Otherwise, go to **Extracting & Translating Strings** below.
## Step 1: Determine the working directory
If currently in a PR worktree (e.g., `/Users/chrox/dev/readest-pr-*`), use that. Otherwise use the main repo. The app directory is `<repo-root>/apps/readest-app`.
---
## Adding a New Language
When the user asks to add a new language (e.g., "add Hungarian", "add hu locale"):
### Step A1: Register the locale in two places
1.**`i18n-langs.json`** — Append the locale code to the array. Both `i18next-scanner.config.cjs` and `src/i18n/i18n.ts` import from this file, so they pick up the new entry automatically.
2.**`src/services/constants.ts`** — Add an entry to `TRANSLATED_LANGS` with the locale code and native language name (e.g., `hu: 'Magyar'`). If the locale already exists in `TRANSLATOR_LANGS`, remove the duplicate there since it will be inherited via the spread.
### Step A2: Generate the translation file
```bash
cd <app-dir>
pnpm run i18n:extract
```
This creates `public/locales/<code>/translation.json` with all keys set to `__STRING_NOT_TRANSLATED__`.
### Step A3: Translate all strings
Follow **Step 4** below to translate every `__STRING_NOT_TRANSLATED__` entry in the new locale file.
### Step A4: Verify
Follow **Step 5** below to confirm zero remaining untranslated strings for the new locale.
---
## Extracting & Translating Strings
### Step 2: Extract i18n strings
```bash
cd <app-dir>
pnpm run i18n:extract
```
This runs `i18next-scanner` which scans source files for translation keys and adds any new keys to all locale files with `__STRING_NOT_TRANSLATED__` as the placeholder value.
If no results, report that all strings are already translated and stop.
### Step 4: Translate missing strings
For each `__STRING_NOT_TRANSLATED__` found:
1. Identify the English key (e.g., `"Hide Scrollbar"`)
2. Identify the target locale from the file path (e.g., `locales/ja/translation.json` -> Japanese)
3. Provide an accurate translation for each locale
Use `sed -i ''` on macOS to replace in-place. Handle all locales in one batch:
```bash
cd <app-dir>/public/locales
sed -i '''s/"<Key>": "__STRING_NOT_TRANSLATED__"/"<Key>": "<translation>"/' <locale>/translation.json
```
### Locale reference
The canonical, complete list of supported locales lives in `i18n-langs.json` (codes only) and `TRANSLATED_LANGS` in `src/services/constants.ts` (codes → native display names). Read those for the source of truth — translate every locale that appears in `i18n-langs.json`. Don't carry forward older "out-of-scope" exclusions like `pt-BR` or `uz`; if it's in `i18n-langs.json` it ships, and it needs translation.
Readest is a cross-platform ebook reader built as a **Next.js 16 + Tauri v2** hybrid app. It's part of a pnpm monorepo at `/apps/readest-app/`. The app runs on web (CloudFlare Workers), desktop (macOS/Windows/Linux via Tauri), and mobile (iOS/Android via Tauri).
## Common Commands
```bash
# Development
pnpm dev-web # Web-only dev server (no Rust compilation needed)
pnpm tauri dev # Desktop dev with Tauri (compiles Rust backend)
# Building
pnpm build # Build Next.js for Tauri
pnpm build-web # Build Next.js for web deployment
# Testing (see [docs/testing.md](docs/testing.md) for full details)
pnpm test# Unit tests (vitest + jsdom)
pnpm test -- src/__tests__/utils/misc.test.ts # Run a single test file
pnpm test -- --watch # Watch mode
pnpm test:browser # Browser tests (Chromium via Playwright)
pnpm tauri:dev:test # Start Tauri app with webdriver
pnpm test:tauri # Run Tauri integration tests
# Linting & Formatting
pnpm lint # Biome (linter) + tsgo (type check)
pnpm format # Prettier (runs from monorepo root)
pnpm format:check # Check formatting without writing
Platform-specific code lives in `src-tauri/src/{macos,windows,android,ios}/`. Custom Tauri plugins are in `src-tauri/plugins/`.
## Git Worktrees
Always use `pnpm worktree:new <branch-name|pr-number>` to create worktrees. Never use `git worktree add` directly — the script handles submodule initialization (simplecc WASM, foliate-js), dependency installation, `.env` copying, vendor assets, and Tauri gen symlinks that are required for lint and tests to pass.
```bash
pnpm worktree:new feat/my-feature # New branch from origin/main
pnpm worktree:new 3837# Checkout PR #3837 with push access to fork
```
## Project Rules
Rules are in `.claude/rules/`: test-first, typescript, verification.
### i18n
See [docs/i18n.md](docs/i18n.md) for the key-as-content translation approach, `stubTranslation` usage in non-React modules, and extraction workflow.
### Safe Area Insets
See [docs/safe-area-insets.md](docs/safe-area-insets.md) for rules on handling top/bottom insets for UI elements near screen edges.
### Design System
UI/UX rules — surface tiers, action vocabulary, settings primitives (`BoxedList`, `SettingsRow`, `SettingsSwitchRow`, `SettingsSelect`, `NavigationRow`, `Tips`, etc.), boxed-list anatomy, RTL conventions, e-ink overlay, and anti-patterns — live in [DESIGN.md](DESIGN.md). Codify recurring decisions there so they persist for the team and future contributors. Reach for the primitives in `src/components/settings/primitives/` instead of inlining chassis classes.
### E-ink mode
Every new UI widget must look right under `[data-eink='true']`. E-ink screens have no shadows, no gradients, slow refresh, and need crisp 1px borders for delineation. The conventions live in `src/styles/globals.css` — reuse the existing classes instead of inventing new ones:
- **Surfaces / inputs** — add `eink-bordered`. In eink mode it swaps to `bg-base-100` + 1px `base-content` border. Use it on inputs, custom button backgrounds, ghost-styled cancel buttons, and any container that needs a visible boundary.
- **Primary action buttons** — add `btn-primary` (alongside whatever Tailwind classes you use for color themes). The `[data-eink] .btn-primary` rule inverts to `base-content` bg + `base-100` text so the primary CTA stays distinct from secondary actions.
- **`.modal-box`** picks up no-shadow + 1px border automatically; dialogs that use it don't need additions.
- **Don't rely on color/shadow alone for hierarchy.** Two same-tone buttons differ only by hover on color themes, and hover doesn't exist on e-ink touchscreens. Pair a borderless ghost (cancel) with a solid CTA (submit) so eink can invert one without flattening the difference.
When in doubt, toggle E-ink in Settings → Misc and check. The rules in `globals.css` cover most cases automatically, but composite components (custom buttons, layered cards) often need `eink-bordered` on the right element to stay legible.
Available gstack skills:
-`/plan-ceo-review` — CEO/founder-mode plan review
-`/plan-eng-review` — Eng manager-mode plan review
-`/plan-design-review` — Designer's eye review of a live site
-`/design-consultation` — Design system consultation
The `flex-row` direction is automatically reversed in RTL by the browser, so
you usually don't need to do anything for `flex` / `gap`. Only **explicit
edges** (padding, margin, borders, radius, absolute positioning) need
logical properties.
**Quick scan when reviewing a diff:** grep for `\b(pl|pr|ml|mr|left-|right-|text-left|text-right|border-l|border-r|rounded-l|rounded-r)-` in changed files. Any hit that isn't a deliberate LTR-only
case (rare — usually only icon glyphs that have a fixed orientation) should
be flipped to the logical equivalent.
#### 2.9 Every panel and sub-page starts with title + description (REQUIRED)
Every settings panel and every sub-page must open with:
1.**A title** — the panel name. Style: `text-lg font-semibold tracking-tight`. In a
top-level panel this is an `<h2>`; in a sub-page this is the `parentLabel /
currentLabel` breadcrumb in `SubPageHeader` (which uses the same typography so the
word stays anchored visually as the user navigates in/out).
2.**A one-line description** — a short sentence under the title explaining what this
surface does or how it fits in the user's workflow. Style: `text-sm
text-base-content/70 leading-relaxed`. Skip it only when the surface is so trivial
the breadcrumb already says everything (rare — when in doubt, write one).
Why: orientation, visual rhythm, and Adwaita parity (`AdwPreferencesPage` always has
both). The same vertical opening across every surface makes the system feel cohesive
and gives users a predictable place to learn what a screen does.
**Canonical components.** The `<SubPageHeader>` primitive in
`src/components/settings/SubPageHeader.tsx` accepts a `description?: React.ReactNode`
prop that renders the description in the canonical style — sub-pages should pass it
there rather than rolling their own `<p>` below the header. Top-level panels currently
inline the title + description; if a third or fourth panel needs the same pattern,
extract a `<PanelHeader>` primitive following the same shape.
**Examples.**
```tsx
// Sub-page (Integrations → OPDS Catalogs)
<SubPageHeader
parentLabel={_('Integrations')}
currentLabel={_('OPDS Catalogs')}
description={_('Browse and download books from online catalogs')}
| **Window** | `bg-base-200` | The outermost backdrop. Modal scrims, dialog content area, scroll containers. | `<Dialog>` body |
| **View** | `bg-base-100/60` or `bg-base-200/40` | Mid-tier surface inside a window. Tip boxes, secondary panels. | The "提示 / Tips" callout in CustomDictionaries |
| **Card** | `bg-base-100` | Top-tier content surface. Boxed lists, popovers, modal-box. | The dictionaries list card |
Border treatment:
- **Window** has no border (it IS the boundary).
- **View** uses no border or `border-base-200/60` for very soft delineation.
- **Card** uses `border border-base-200`. In e-ink, `eink-bordered` flips it to 1px
`border-base-content`.
Corner radius:
- **Card / View**: `rounded-lg` (8px) — Readest's house radius. Adwaita uses 9px; 8px is
Readest uses a **key-as-content** approach — English strings are the translation keys. The English locale (`en/translation.json`) is empty because keys serve as content. Other locales contain actual translations.
- Only `_('KEY')` and `_('KEY', options)` patterns are recognized by i18next-scanner
### Adding a New Translation Language
The supported language set has a single ground truth: [`i18n-langs.json`](../i18n-langs.json). Both the i18next runtime (`src/i18n/i18n.ts`) and the extractor (`i18next-scanner.config.cjs`) read from it, so adding a locale is a two-file change plus a translation pass.
1.**Add the locale code** to [`i18n-langs.json`](../i18n-langs.json). Use the exact code i18next will emit (e.g. `hu`, `zh-CN`). Do not add `en` — it's the source language and lives outside this list.
2.**Add a display label** to `TRANSLATED_LANGS` in [`src/services/constants.ts`](../src/services/constants.ts). The key is the locale code, the value is the language's native name (e.g. `hu: 'Magyar'`). This is what users see in the language picker.
3.**Generate the translation file**:
```bash
pnpm i18n:extract
```
This creates `public/locales/<code>/translation.json` with every key set to `__STRING_NOT_TRANSLATED__`.
4. **Translate** every `__STRING_NOT_TRANSLATED__` placeholder in the new file. The `/i18n` skill automates this; the singular `en/translation.json` only holds plural variants and proper nouns, so use the JSON keys themselves as the English source.
5. **Verify** with `grep -r "__STRING_NOT_TRANSLATED__" public/locales/<code>/` — the result should be empty.
6. **Translate the KOReader companion plugin** (`apps/readest.koplugin`). It pulls the locale set from the same `i18n-langs.json` via the scanner config, but the catalog format is gettext `.po`, not JSON. Steps:
- Add a `LANG_META` entry (label + Plural-Forms) for the new code in [`apps/readest.koplugin/scripts/extract-i18n.js`](../../readest.koplugin/scripts/extract-i18n.js). Without it the extractor prints `<code> skipped (no metadata in extract-i18n.js)` and the catalog is never created.
- Trigger the `/i18n-koplugin` skill to run extraction and fill every empty `msgstr ""` in `apps/readest.koplugin/locales/<code>/translation.po`.
### Rules
- `stubTranslation` is for extraction only — always apply `_()` from `useTranslation` in the component for runtime translation.
- Fallback: when no translation exists, the English key itself is displayed.
- Error messages: register keys with `stubTranslation` in utility modules (e.g. `src/services/errors.ts`), return the English key from helpers, wrap with `_()` in the component.
The app runs on devices with notches, status bars, and rounded corners (iOS, Android). UI elements near screen edges must account for safe area insets to avoid being obscured.
### Key Concepts
- **`gridInsets: Insets`** — Per-view insets derived from view settings (header/footer visibility, margins). Calculated by `getViewInsets()` in `src/utils/insets.ts`. Passed as a prop from `BooksGrid` → child components.
- **`statusBarHeight: number`** — OS status bar height (default 24px). Stored in `themeStore`.
- **`systemUIVisible: boolean`** — Whether the system UI (status bar, navigation bar) is currently shown. Stored in `themeStore`.
- **`appService?.hasSafeAreaInset`** — Whether the platform requires safe area handling (mobile devices).
### Top Inset Rules
For UI elements anchored to the **top** of the screen (headers, close buttons, overlays):
```tsx
// When system UI is visible, use the larger of gridInsets.top and statusBarHeight
// When system UI is hidden, use gridInsets.top alone
style={{
marginTop: systemUIVisible
?`${Math.max(gridInsets.top,statusBarHeight)}px`
:`${gridInsets.top}px`,
}}
```
For containers that need safe area padding at the top:
For UI elements anchored to the **bottom** of the screen (footer bars, controls, progress indicators), use `gridInsets.bottom * 0.33` as padding — a fraction of the full inset since bottom bars don't need as much clearance as the home indicator area:
When creating overlay components (image viewers, table viewers, zoom controls, etc.), always pass `gridInsets` as a prop so they can position their controls correctly:
Readest uses three test tiers, all powered by [Vitest](https://vitest.dev/).
## Unit Tests (`pnpm test`)
Runs tests in a **jsdom** environment. No browser or Tauri runtime required.
```bash
pnpm test# Run all unit tests
pnpm test -- src/__tests__/utils/misc.test.ts # Run a single file
pnpm test -- --watch # Watch mode
```
- **Config:** `vitest.config.mts`
- **Pattern:** `src/**/*.test.ts` (excludes `*.browser.test.ts` and `*.tauri.test.ts`)
- **Environment:** jsdom
- **Use for:** Pure logic, utilities, services that don't need real browser APIs or Tauri IPC.
## Browser Tests (`pnpm test:browser`)
Runs tests in a **real Chromium** browser via Playwright. Required for code that depends on Web Workers, SharedArrayBuffer, OPFS, or other browser-only APIs.
```bash
pnpm test:browser
```
- **Config:** `vitest.browser.config.mts`
- **Pattern:** `src/**/*.browser.test.ts`
- **Browser:** Chromium (headless, via `@vitest/browser-playwright`)
The `invoke()` helper accesses `window.top.__TAURI_INTERNALS__` (Vitest runs in an iframe, Tauri injects IPC into the main frame).
**Limitations:** Only custom invoke commands and plugin commands listed in the webdriver capability work. Standard Tauri JS APIs (e.g. `@tauri-apps/api`) that rely on `URL: local` may not work from the Vitest iframe.
## E2E Tests (WDIO)
Full end-to-end tests using WebDriverIO, for UI-level testing against the running Tauri app. Same two-step workflow as Tauri integration tests.
```bash
# Terminal 1: start the app (same as for Tauri integration tests)
`ViewSettings` is the per-book view state (layout, fonts, colors, TTS, etc.) composed from several sub-interfaces defined in `src/types/book.ts`. A matching `globalViewSettings` lives on `SystemSettings` and acts as the default for every book. The per-book value is derived by merging the global defaults with any overrides stored on the book's `BookConfig`.
This doc covers how to plumb a new config through the three layers:
1.**Types** — `src/types/book.ts`
2.**Defaults** — `src/services/constants.ts` and `src/services/settingsService.ts`
3.**Read/write** — components via `saveViewSettings` from `src/helpers/settings.ts`
### Pick a Pattern
**Pattern A — add a field to an existing sub-interface.** Use when the new option belongs to an existing bundle (`BookLayout`, `BookStyle`, `BookFont`, `ViewConfig`, `TTSConfig`, etc.).
**Pattern B — introduce a new sub-interface.** Use when several related fields cluster together, or when a single field is semantically its own concept (e.g. `ParagraphModeConfig`, `ViewSettingsConfig`). Then extend `ViewSettings` with it.
Both patterns follow the same three-layer flow. The only difference is whether you reuse an existing `DEFAULT_*` constant or add a new one.
### Step 1 — Declare the Type
**Pattern A** — add a required field to the sub-interface that owns this concern:
```ts
// src/types/book.ts
exportinterfaceViewConfig{
// ...existing fields
myNewToggle: boolean;
}
```
**Pattern B** — define a new interface and extend `ViewSettings`:
```ts
// src/types/book.ts
exportinterfaceViewSettingsConfig{
isGlobal: boolean;
}
exportinterfaceViewSettings
extends
BookLayout,
BookStyle,
// ...other bundles
ViewSettingsConfig{}
```
Fields should be **required**, not optional. Optional fields make downstream code defensive. Provide a sensible default in Step 2 instead.
### Step 2 — Provide a Default
Every field in `ViewSettings` must have a default, otherwise `getDefaultViewSettings()` produces an incomplete object.
**Pattern A** — add the value to the existing `DEFAULT_*` constant:
```ts
// src/services/constants.ts
exportconstDEFAULT_VIEW_CONFIG: ViewConfig={
// ...existing defaults
myNewToggle: false,
};
```
**Pattern B** — add a `DEFAULT_*_CONFIG` constant for your new bundle, then register it in `getDefaultViewSettings`:
To tweak the default on mobile, e-ink, or CJK locales, add the field to the matching `Partial<ViewSettings>` constant (`DEFAULT_MOBILE_VIEW_SETTINGS`, `DEFAULT_EINK_VIEW_SETTINGS`, `DEFAULT_CJK_VIEW_SETTINGS`). These are spread after the base defaults in `getDefaultViewSettings`, so they override them.
#### Migration
Old `settings.json` files on disk won't have your new field. `loadSettings` merges the stored blob over fresh defaults:
```ts
settings.globalViewSettings={
...getDefaultViewSettings(ctx),
...settings.globalViewSettings,
};
```
So existing users pick up your default automatically — no explicit migration is needed for adding a field. Only bump `SYSTEM_SETTINGS_VERSION` if you are reshaping existing data.
### Step 3 — Read and Write from Components
Read the current value by preferring the per-book settings, falling back to the global:
Write via `saveViewSettings` — never mutate the store directly. The helper handles the global-vs-per-book routing, persists to disk, and re-applies styles when needed.
The `useEffect`-on-local-state pattern is the established convention in `LayoutPanel`, `ControlPanel`, `ColorPanel`, etc. It keeps the UI responsive and batches store updates until the user stops interacting.
#### Signature
```ts
saveViewSettings<KextendskeyofViewSettings>(
envConfig,
bookKey,
key: K,
value: ViewSettings[K],
skipGlobal=false,// true → only update this book's settings
**Global vs. per-book routing.**`saveViewSettings` inspects `viewSettings.isGlobal` on the target book. When `true` (the default), it writes to `globalViewSettings`, loops through every open book, and saves to disk. When `false`, it writes only to the one book's config.
**Skip global.** Pass `skipGlobal=true` when the setting is meta — i.e. it describes the settings system itself, not book content. The canonical case is toggling `isGlobal` from `DialogMenu`: you want the scope flag to live on the specific book without propagating it to every other book.
**Skip styles.** Pass `applyStyles=false` for options that don't affect CSS rendering (toggles, flags, metadata). This avoids an unnecessary `renderer.setStyles` call.
### Step 4 — Support Reset
If your field should be resettable from the panel menu, register a setter in the panel's `handleReset` via `useResetViewSettings`:
```tsx
constresetToDefaults=useResetViewSettings();
consthandleReset=()=>{
resetToDefaults({
myNewToggle: setMyNewToggle,
// ...other setters
});
};
```
The hook resolves the default by reading from `getDefaultViewSettings(ctx)` and calls each provided setter with that value, which then fires your `useEffect` and persists the change.
### Step 5 — Register in the Command Palette
If your setting has a visible row in a panel, register it in the matching `*PanelItems` array in `src/services/commandRegistry.ts`. This wires it into the command-palette fuzzy search so users can jump straight to it.
-`id` must match the `data-setting-id` attribute on the panel row. The palette uses it to scroll/highlight the target control.
-`labelKey` uses `stubTranslation` (imported as `_`) so the extractor picks it up — the same string that appears in the panel.
-`keywords` broadens fuzzy-search hits beyond the label; include synonyms, related jargon, and the panel section name.
-`section` groups the entry in the palette results (matches the panel's sub-header: `Layout`, `Paragraph`, `Page`, `Header & Footer`, etc.).
Skip this step only for settings that don't surface as a user-visible row (hidden toggles, flags used by other settings).
### Don'ts
- **Don't make the field optional** just to skip providing a default. Add a default in Step 2 instead.
- **Don't mutate `settings.globalViewSettings` directly** in a component — `saveViewSettings` already handles global propagation when `isGlobal` is true.
- **Don't bump `SYSTEM_SETTINGS_VERSION`** for a plain additive field. The load-time merge handles it.
### Minimal Checklist
- [ ] Field or new interface added in `src/types/book.ts`
- [ ] Default value in `src/services/constants.ts`
- [ ] New `DEFAULT_*_CONFIG` spread into `getDefaultViewSettings` (Pattern B only)
- [ ] Optional mobile/eink/CJK override in the matching `Partial<ViewSettings>` constant
- [ ] Read via `getViewSettings(bookKey) || settings.globalViewSettings`
- [ ] Write via `saveViewSettings(envConfig, bookKey, 'key', value)`
- [ ] Reset setter wired into `useResetViewSettings` if the panel has a reset menu
- [ ] Command-palette entry added to the matching `*PanelItems` array in `src/services/commandRegistry.ts`, with an `id` that matches the panel row's `data-setting-id`
| Comic Book | `.cbz`, `.cbr` | First image in archive |
| Plain Text | `.txt` | Generated placeholder |
## Building
### Library Only
```bash
cargo build --release
```
### COM DLL (for Windows Explorer integration)
```bash
cargo build --release --features com
```
### CLI Tool
```bash
cargo build --release --features cli
```
## Installation
The thumbnail provider DLL is automatically registered when Readest is installed via the NSIS installer.
### Manual Registration (for development)
```powershell
# Register the DLL
regsvr32/starget\release\windows_thumbnail.dll
# Unregister the DLL
regsvr32/s/utarget\release\windows_thumbnail.dll
# Refresh Explorer to see changes
ie4uinit.exe-show
```
After registration, you may need to restart Windows Explorer or log out/in for changes to take effect.
## Usage (Development / Manual testing)
For local development and testing, build the Windows DLL (or the library) from the Readest Tauri app folder and register it manually. The legacy CLI test harness used to live in the separate `packages/tauri` workspace, but the thumbnail handler implementation now lives inside Readest's Tauri app.
The standalone CLI test harness is no longer distributed with the app. To test the thumbnail provider locally, build and register the DLL as shown above and use a small test harness that imports `readestlib`'s thumbnail code or use Explorer after registering the handler.
Manual registration for development (register the generated DLL):
```powershell
# Register the DLL
regsvr32/starget\release\windows_thumbnail.dll
# Unregister the DLL
regsvr32/s/utarget\release\windows_thumbnail.dll
# Refresh Explorer to see changes
ie4uinit.exe-show
```
This generates a thumbnail with the Readest overlay at the specified size.
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.