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.
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>
* 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>
* 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>
* 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>
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>
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>
* 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>
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
- 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>
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>
* 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>
* 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>
* 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>
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>
* 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 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>
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>
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>
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.
* 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>