7d3065d9aebafcfe4ac4b625949ccbf0c8f60ab2
1909 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7d3065d9ae | feat(android): also upgrade webview from beta, dev and canary channels when the stable channel isn't updatable (#4149) | ||
|
|
fed8ab7b67 |
fix(tts): restore cross-section auto-page-turn during TTS playback (#4148)
When TTS playback crosses a section boundary, the page would stay on the last page of the previous chapter while audio continued reading the next chapter — leaving the user stuck behind the "back-to-TTS" button. Two compounding issues since the paginator adjacent-section preloading landed: 1. `handleSectionChange` called `view.renderer.goTo(resolved)` without awaiting. `TTSController.#initTTSForSection` does `await this.onSectionChange?.(sectionIndex)` precisely so the view can finish navigating before audio of the new section starts, but the missing await defeated that contract. 2. `handleHighlightMark` returned silently on a cross-section mismatch (`viewSectionIndex !== ttsSectionIndex`), so when the renderer.goTo above completed only partially — which can happen on the new paginator when the target section is already loaded as an adjacent view and the post-goTo state appears reused without a visible page flip — there was no second chance to drag the view to the TTS cfi. Fix: - Await `view.renderer.goTo` in `handleSectionChange`. - In `handleHighlightMark`, run the cross-section branch *before* the `followingTTSLocationRef` check and call `view.goTo(cfi)` directly, stamping `sectionChangingTimestampRef` so the back-to-TTS button stays suppressed while progress.location catches up. Skip only when the user is actively selecting text. Adds unit tests covering both the cross-section navigation path and the in-section scrollToAnchor path. |
||
|
|
9a05935caf |
feat(reader): improve Japanese selection UX by disabling furigana selection (#4137)
* feat: add default ruby rt styles with user-select: none
* fix: prevent furigana text from being copied via ruby transformer
* fix: register ruby transformer in FoliateViewer pipeline; use span wrapper for reliable ::before rendering
* refactor(reader): simplify furigana copy exclusion
Drop the ruby transformer and the .rt-text::before pseudo-element
wrapping. Instead, pass ['rt'] to getTextFromRange unconditionally so
furigana is excluded from annotator/translation/copy text extraction,
and let `rt { user-select: none }` handle the native selection cursor.
Avoids DOM rewriting and HTML-entity round-tripping in the data-text
attribute, and keeps <rt> text in the DOM for TTS, in-page find, and
screen readers.
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>
|
||
|
|
e8df651d5a | chore(deps): bump Next.js to version 16.2.6 (#4143) | ||
|
|
fc71ca9857 |
feat(android): upgrade in-process WebView on devices stuck on old system WebView (#4142)
Add tauri-plugin-webview-upgrade as a git submodule under apps/readest-app/src-tauri/plugins/. On Android devices whose system WebView is locked to an old Chromium build (Huawei phones, Moaan / Onyx / Kobo e-ink readers, AOSP forks without Play Store, etc.), the reader bundle renders as a blank screen. The plugin bootstraps before Application.onCreate via androidx.startup and redirects the in-process WebView loader to a recent com.google.android.webview when the user has one sideloaded — opening the only window in which WebViewUpgrade can swap the provider, before Tauri/Wry creates any WebView. Thresholds (minUpgradeMajor / minSupportedMajor) come from plugins.webview-upgrade in tauri.conf.json and are baked into Kotlin constants at Gradle build time. Below the supported threshold with no upgrade option, the plugin shows a localized AlertDialog (15 languages, English fallback) prompting the user to install Android System WebView. Plugin source: https://github.com/readest/tauri-plugin-webview-upgrade Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
058d58b4f2 |
fix(kosync): populate chapter field on synced annotations (#4134)
Annotations and bookmarks inserted into KOReader via the Readest sync plugin were missing the chapter field, which native KOReader highlights stamp at creation time. Downstream tools that group highlights by chapter (e.g. obsidian-koreader-highlights, KOReader's own Markdown exporter) treated these as orphans. Resolve the chapter title from the xpointer using the same TOC call that ReaderHighlight uses natively, and include it on both annotation and bookmark item tables. Closes #4133 |
||
|
|
615dc82c17 | fix(android): fixed .mdx/.mdd files not shown in file chooser on Android, closes #4124 (#4125) | ||
|
|
56d6aceb0d | release: version 0.11.1 (#4123) | ||
|
|
598eb77237 |
feat(library): redesign empty-library onboarding (#4122)
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>
|
||
|
|
d326e1c73d |
fix: hide popup triangle when inside popup + EPUB image-only paragraph rendering (#4121)
- 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> |
||
|
|
1705006b6b |
fix(mobile): iOS PIN keyboard UX + Safari font line-height in EPUBs (#4120)
- 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> |
||
|
|
952e436514 | i18n: update translations (#4118) | ||
|
|
772bb73b46 |
ui/ux: codify design system and migrate settings to shared primitives (#4116)
* 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> |
||
|
|
5774e00c09 |
feat(sync): opt-in Credentials toggle + keyring v4 migration (#4111)
* 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> |
||
|
|
f6f446e8a0 |
feat(applock): blinking PIN cursor + misc UI polish (#4110)
* 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> |
||
|
|
1eae2af23e |
feat(sync): batch replica sync into one /api/sync/replicas request (#4109)
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.
|
||
|
|
295a588988 |
feat(share): route annotation exports through the system share sheet (#4107)
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>
|
||
|
|
4110911011 |
fix(sync): keep dictionarySettings consistent across devices (#4105)
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>
|
||
|
|
e0b3a6fb0c |
fix(i18n): localize quota reset countdown time units (#4104)
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>
|
||
|
|
c30a59a9ed |
fix(epub): accept EPUBs with malformed first ZIP local file header (#4103)
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> |
||
|
|
ae42dcb53a |
fix(txt): parse author from txt filename and use edited metadata on fallback cover (#4095) (#4102)
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> |
||
|
|
302363a9fd |
feat(sync): per-category sync gates + Manage Sync UI (#4099)
* 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> |
||
|
|
cc8f917cdd |
fix(layout): silence viewport meta warning on non-Android browsers (#4097)
`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> |
||
|
|
51a553dd89 |
feat(sync): bundle dictionary settings into the settings replica kind (#4096)
* 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> |
||
|
|
6e7c9d1395 |
feat(sync): bundled settings replica kind for cross-device prefs and credentials (#4094)
* 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> |
||
|
|
2d5590ec1f |
feat(applock): 4-digit PIN gate at app launch (#4093)
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> |
||
|
|
dc58d985e7 |
fix(layout): center fixed modals above the on-screen keyboard on Android (#4091)
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> |
||
|
|
712d564e9d |
feat(sync): encrypted OPDS credentials + Tauri keychain (PR 4c + 4d) (#4090)
* 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> |
||
|
|
6bfeb295d2 |
feat(sync): add opds_catalog replica kind (plaintext fields) (#4087)
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>
|
||
|
|
aea3fda086 | chore: bump turso to the latest version (#4086) | ||
|
|
35227ecd61 |
feat(sync): wire crypto session for encrypted-field sync (#4084)
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>
|
||
|
|
cb30716830 |
refactor(hardcover): move note mappings to SQLite on web and native (#4083)
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>
|
||
|
|
4625e47a6d |
refactor(sync): extract shared adapter / pull-deps / legacy-migration primitives (#4081)
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> |
||
|
|
77a85cee09 |
feat(account): show daily reset countdown under translation quota bar (#4082)
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> |
||
|
|
53936ad17c |
chore(i18n): translate replica-sync File-toast strings (#4080)
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> |
||
|
|
de6529523f |
feat(sync): cross-device background texture sync (#4079)
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> |
||
|
|
ca674bb5e7 | chore(agent): update project memory (#4078) | ||
|
|
981579c255 |
feat(sync): cross-device custom font sync (#4077)
* 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> |
||
|
|
cbdc3b8f52 |
feat(sync): wire dictionary store through replica sync (follow-up to #4075) (#4076)
* 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> |
||
|
|
3b348c8f35 |
feat(sync): CRDT replica sync foundation (#4075)
* 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>
|
||
|
|
dd0ff6ae9d | chore(agent): bump gstack (#4073) | ||
|
|
30dee7b909 |
feat(dict): improve MDict rendering and dictionary management (#4072)
* 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> |
||
|
|
a272ba892a |
feat(reader): replace dictionary tabs with stacked result cards (#4071)
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> |
||
|
|
5dc2528455 |
fix(library): support dropping directories to import books (#4068)
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> |
||
|
|
c27245e980 |
feat(reader): support deeplink and web link in annotation export (#4067)
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> |
||
|
|
9b0072173c |
feat(metadata): parse Calibre series info from PDF and CBZ (#4066)
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> |
||
|
|
06aec0b597 |
fix(reader): revert footer to default visibility when tap-to-toggle is disabled (#4065)
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> |
||
|
|
15c0a7a2f2 |
fix(koplugin): render group cover previews in Library (#4064)
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> |
||
|
|
d66fedcab7 | feat(reader): manage rules shortcut in proofread popup (#4062) | ||
|
|
43f72720f2 |
feat(i18n): add Uzbek and Brazilian Portuguese translations (#4061)
* 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> |