Commit Graph

517 Commits

Author SHA1 Message Date
Huang Xin df2698fa0e feat(reedy): Appendix A · Phase 2.6 — AgentRuntime + abort helper (#4301) 2026-05-26 10:14:53 +08:00
Huang Xin a86b09dba5 feat(reedy): Appendix A · Phase 2.5 — PromptContextBuilder + layers + tokenBudget (#4300) 2026-05-26 10:01:57 +08:00
Huang Xin 5c71ccb908 feat(reedy): Appendix A · Phase 2.4 — built-in tools (non-memory families) (#4299) 2026-05-26 09:41:17 +08:00
Huang Xin 4d96c0d54a feat(reedy): Appendix A · Phase 2.1–2.3 — agent runtime foundation (#4298)
* feat(reedy): Appendix A · Phase 2.1 — ChatModel + model registry

First piece of the deferred agent runtime, building alongside the shipped
Phase 1 MVP per the locked decision (no rip-and-replace; legacy + Reedy
MVP + agent paths coexist).

ChatModel is a thin interface over Vercel SDK's LanguageModel that
surfaces contextWindow / reservedOutput / supportsTools — the metadata
the M2.5 PromptContextBuilder and M2.6 AgentRuntime need for prompt
budgeting and tool-calling decisions.

createReedyModels(settings) returns the active (chat, embedding) pair from
the user's currently configured AIProvider, rewrapped in the Reedy
interfaces without touching the legacy AIProvider. CONTEXT_WINDOW_TABLE
hardcodes per-model metadata (Gemini 2.5 → 2M, GPT-5/Llama-4 → 128K,
local Ollama llama → 4K with tools off, etc.) with a conservative 8K
fallback for unknown ids.

No runtime wired yet — Phase 2.6 consumes this.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(reedy): Appendix A · Phases 2.2–2.3 — runtime event/error taxonomy + ToolRegistry

Phase 2.2 — pure types and factories for everything AgentRuntime.runTurn()
will yield:

  turn_start | text_delta | tool_call | tool_result(ok/err)
    | citation | memory_write | step_finish | usage | error | abort | done

ReedyError covers turn-level failures (context_overflow, turn_timeout,
model_error, provider_unavailable, stream_parse, abort, ...) with default
retryability per kind so the M2.6 streamLoop's retry-with-backoff can
decide without string-matching. ReedyToolError covers tool-execution
failures with a narrower kind union so the streamLoop can re-emit them as
{ tool_result, ok: false, error } events without losing structure.

Phase 2.3 — ToolRegistry that holds the runtime's tool catalog and adapts
each tool to the Vercel ai-SDK ToolSet streamText consumes. Every
registered ReedyTool gets wrapped with:

  - Zod input validation     → tool_invalid_args on mismatch
  - Permission gate          → tool_permission_denied (read auto-approves)
  - Per-call wall-clock      → tool_timeout (default 10s)
  - AbortSignal propagation  → tool_aborted on turn cancel
  - Per-tool serialization   → parallelSafe=false tools queue

The local AbortController per call composes the turn-level signal with
the per-tool timeout so we classify timeout-vs-abort correctly based on
which controller fired first, and listener cleanup avoids leaks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 02:30:34 +02:00
Huang Xin 6bc4a96b99 feat(reedy): Phase 1B — wire Reedy into the chat, settings, and Sources UI (#4296)
* feat(reedy): wire RetrievalBackend interface + metrics into the chat adapter

Phase 1B backend integration. Adds a RetrievalBackend interface so
TauriChatAdapter holds a uniform reference instead of branching across the
file, with two impls — LegacyIdbBackend (wraps the existing IDB ragService
unchanged) and ReedyBackend (lazy-opens reedy.db, adapts the active
provider's embedding model to Reedy's narrower shape, exposes a Vercel
`lookupPassage` tool). selectBackend() gates Reedy behind both
aiSettings.reedy.enabled AND isTauriAppPlatform() per plan D15 so the MVP
cohort is desktop-only.

The Reedy path streams via streamText({ tools: { lookupPassage }, stopWhen:
stepCountIs(3) }) with a status-aware system prompt that tells the model
how to phrase responses for each RetrieverStatus value.

Replaces the module-global `lastSources` + 500ms poll with a per-instance
ReedySourceStore keyed by a synthetic per-turn id the adapter generates,
so the Sources dropdown stops racing on global state. Both legacy and
Reedy backends now feed citations through the same store; the UI is
backend-agnostic via a shared SourceItem shape both ScoredChunk and
RetrievedChunk satisfy.

Adds the reedy_metrics table to the reedy migration (versioned with
app_version + session_id + turn_id per row) plus a ReedyMetrics writer
that ReedyBackend uses to record indexing-lifecycle and tool-use events.
Always-on local; no network egress. NoopReedyMetrics keeps construction
cheap before the DB opens.

Tests: retrievalBackend selectBackend gates, ReedySourceStore semantics
(append/replace/subscribe/clear), ReedyMetrics debounced batching +
exportBundle, and a TauriChatAdapter contract test that asserts the
Reedy/legacy code-paths pass the right args to streamText.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(reedy): UI wiring — settings toggle, clickable sources, feedback bundle

Phase 1B UI integration. AIAssistant now constructs the active backend
(legacy or Reedy) via selectBackend with the platform gate from M1.7,
wires a ReedySourceStore for the chat adapter, and routes Sources-dropdown
clicks to `getView(bookKey)?.goTo(source.cfi)` when the source has a CFI.
Legacy-path sources still render as static rows because they have no CFI.

AIPanel grows a 'Reedy Retrieval (Beta)' BoxedList with the toggle and a
'Send Reedy feedback' button that calls exportReedyMetricsBundle and
triggers a JSON download of the last 90 days of events. The toggle is
disabled on web with an explanatory description per plan D15.

Thread accepts an onSourceClick callback and renders each source as a
button when its source carries a CFI, otherwise as a static div — so the
Sources dropdown is backend-agnostic via the shared SourceItem shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:41:21 +02:00
Huang Xin a1046f5684 feat(reedy): Phase 1A — MVP retrieval primitives (#4293)
* feat(db): add reedy schema migration with Tantivy FTS + lazy embeddings

Registers a new `reedy` migration set bound to reedy.db. Creates
reedy_book_meta + reedy_book_chunks with a Tantivy FTS index on
chunks.text (ngram tokenizer) and a per-book position index used by
BookRetriever. The vector embeddings table is intentionally NOT created
here — the indexer creates it lazily on first index so the vector32(<dim>)
column matches the active embedding model. Tests cover the migration
applies cleanly, is idempotent, and that the FTS index is queryable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(reedy): retrieval primitives — DB, chunker, indexer, retriever, lookupPassage tool

Wires the MVP retrieval pipeline behind reedy.db, all under src/services/reedy/
and with no integration into the existing AI module yet (Phase 1B will do that).

- ReedyDb wrapper over DatabaseService: book-meta CRUD, lazy embeddings
  table at the active model's dim, bulk chunk + embedding writes via batch(),
  hybridSearch (brute-force cosine + Tantivy FTS + reciprocal-rank fusion
  with 3× per-path over-fetch), per-book and global wipe. Internal write
  queue serializes batch() calls so Turso's single-writer transaction guard
  doesn't trip when BookIndexer runs across books in parallel.
- CfiChunker: TreeWalker over the section's DOM, ~maxChunkSize windows with
  paragraph > sentence > word break-points, full epubcfi(/6/N!/…) anchors,
  round-trip verified via CFI.toRange before each chunk lands.
- BookIndexer: per-book mutex, lazy embeddings-table creation, model.batchSize
  embedding batches with dim assertion, terminal status transitions
  (indexed | empty_index | failed). Re-indexing clears prior chunks via a new
  ReedyDb.clearBookChunks helper.
- BookRetriever: status-typed results (ok | not_indexed | empty_index |
  stale_index | degraded). Embedding has a 5s wall-clock budget; on timeout
  it falls through to FTS-only with status=degraded.
- lookupPassage Vercel ai-SDK tool: Zod-validated query/topK, per-turn
  composite-key dedupe, parallel-call serialization, 10s per-turn budget,
  6000-char result clamp, status-with-hint passthrough, and a separate
  serializeForModel that wraps each passage in <retrieved trust="untrusted">
  with XML-escaped content so book text cannot escape the envelope.

59 unit tests; pnpm test + pnpm lint 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>
2026-05-25 20:00:20 +02:00
Huang Xin 2d819b476c fix(db): forward DatabaseOpts to tauri-plugin-turso (#4292)
* fix(db): forward DatabaseOpts to tauri-plugin-turso

NativeDatabaseService.open ignored its opts parameter, dropping any
experimental feature flags (e.g. 'index_method' needed for FTS / vector
indexes) and any encryption config before they could reach the Tauri
plugin. Translate DatabaseOpts to the plugin's LoadOptions shape and
forward as the single argument Database.load accepts. Skip translation
when no relevant opts are set so the existing path-string call shape is
preserved for callers without experimental/encryption needs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(db): cover Turso vector primitives + add benchmark harness

Verifies the Turso functions Reedy retrieval depends on, with a
brute-force per-book kNN test that runs against every DatabaseService
backend (node, native, WASM):

  SELECT vector_distance_cos(embedding, vector32(?)) AS d
    FROM book_chunks
   WHERE book_hash = ?
   ORDER BY d ASC LIMIT k

This is the path Turso's own founder recommended in
tursodatabase/turso#3778 ("First, focus on efficient SIMD-accelerated
brute-force search") and what shipped at commit 1aba105df4f. Native
vector index modules don't exist in this engine: `libsql_vector_idx`,
`vector_top_k`, and `USING vector/hnsw/diskann/ivfflat` all parse-error
against @tursodatabase/database@0.6.0-pre.28 (libsql_vector_idx is a
libSQL/sqld fork feature; DiskANN was closed not-planned upstream in
#832). The test asserts cross-book isolation and nearest-first ordering
using only `WHERE book_hash = ?` and `ORDER BY` — no DDL, no identifier
interpolation, no index plumbing.

Also adds bench/ harness for manual perf checks:

  pnpm bench [name]            run benchmarks (refuses in CI)
  pnpm bench --list            list available benchmarks
  pnpm bench --no-record       skip results.jsonl append
  pnpm bench --force           override the CI guard

Uses Node 24's --experimental-strip-types so no tsx devDep is needed.
Appends one JSON line per run to bench/results.jsonl (gitignored, local
history; share by pasting tabular stdout into PRs/issues). Explicitly
NOT in CI — shared-tenant variance makes synthetic-benchmark regression
detection unreliable; production telemetry (reedy_metrics, plan §M1.9)
is the right tool for that.

First benchmark: vector-retrieval. Measured on M1 Pro:
  400 chunks ×  384 dim  →  0.35 ms / query
  400 chunks ×  768 dim  →  0.45 ms / query
  2000 chunks × 768 dim  →  2.23 ms / query
  10000 chunks × 768 dim → 14.00 ms / query
  400 chunks × 1536 dim  →  0.70 ms / query

Per-chunk cost ~1.1 µs at 768 dim = ~1.4 ns/dim. NEON-class on
Apple Silicon, ~50× faster than scalar — confirms SIMD acceleration is
active in 0.6.0-pre.28. Per-query latency stays sub-ms at Reedy MVP
corpus sizes; the ceiling is ~10K chunks per book before phone-class
hardware notices.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:51:49 +02:00
loveheaven 98049282eb feat(ai): add OpenRouter provider and unify provider HTTP transport (#4289)
* OpenRouter: new OpenAI-compatible provider with chat + embedding model
  routing, health check via /models, and a fetchOpenRouterModels() helper
  for the settings UI. API key, base URL and model fields are persisted in
  AISettings, surfaced in AIPanel, indexed by commandRegistry, and added
  to backupService's credential allow-list so the key round-trips through
  encrypted backups.

* utils/httpFetch: introduce getAIFetch() as the single decision point for
  outbound AI traffic. In Tauri it returns @tauri-apps/plugin-http's fetch
  (Rust/reqwest transport, no renderer CORS preflight, no Android cleartext
  block); on the web build it falls back to window.fetch. OllamaProvider
  is migrated end-to-end — both ai-sdk-ollama streaming and the /api/tags
  health probe — and the new OpenRouterProvider uses the same path, so any
  future provider only has to call getAIFetch().

* Tests: unit tests for OpenRouter provider behavior (model selection,
  availability, health check) and a backup-settings round-trip test
  ensuring openrouterApiKey is treated as a credential field.
2026-05-25 18:32:07 +02:00
loveheaven 7324b2b2bf fix(ios): don't crash app init when Storefront region code is unavailable (#4291)
`getStorefrontRegionCode` rejects when `Storefront.current` is nil
(unsigned simulator, signed-out device, StoreKit not yet ready,
parental controls, etc.). The call sits in NativeAppService startup
before `prepareBooksDir`, so an unhandled rejection here aborts the
rest of init and surfaces as a top-level unhandledRejection in the
webview console.

Wrap the call in try/catch and treat failure as 'unknown region',
leaving `storefrontRegionCode` null so downstream region-gated
features degrade gracefully. Also guard against an empty resolve
shape with optional chaining on `res?.regionCode`.
2026-05-25 18:28:26 +02:00
Huang Xin 5e366018df fix(cbz): ComicInfo metadata + CBZ page count + WebDAV i18n (#4282)
* fix(cbz,i18n): ComicInfo metadata + CBZ page count + WebDAV i18n

Closes #4253 (ComicInfo.xml not read) and #4255 (CBZ shows "1 page left").

CBZ / ComicInfo (foliate-js submodule + Readest derivation):
- comic-book.js: find ComicInfo.xml in subdirectories too, parse
  description / subject / identifier / published / series fields
  beyond the prior name+position pair. Series Count populates the
  canonical `belongsTo.series.total`; no top-level duplication.
- bookService.ts / readerStore.ts: derive `metadata.seriesTotal`
  from `belongsTo.series.total` in parallel to the existing
  series / seriesIndex derivation.
- ProgressBar / FooterBar / DesktopFooterBar: drop the hard-coded
  `pagesLeft = 1` for fixed-layout books and compute it from
  `section.total - section.current`. FooterBar uses
  `FIXED_LAYOUT_FORMATS.has(bookFormat)` so CBZ picks `section`
  (correct image count) instead of `pageinfo` (locations).
- ProgressBar: switch the remaining-pages text to "in book" for
  fixed-layout titles (no chapter structure) and keep
  "in chapter" for reflowable books.

WebDAV refactor for translation coverage:
- WebDAVBrowsePane / SyncHistoryPanel called `t(...)` (passed as a
  prop) instead of `_(...)`. The i18next-scanner only looks for
  `_`, so ~53 strings were unreachable and shipped in English to
  every locale. Switched both components to call
  `useTranslation()` themselves; helpers that aren't React FCs
  take `_: TranslationFunc` so the scanner sees the literal calls.
- WebDAVClient.checkConnection now returns a `code` discriminator
  (`SERVER_URL_REQUIRED` / `AUTH_FAILED` / `ROOT_NOT_FOUND` /
  `UNEXPECTED_STATUS` / `NETWORK`); raw English `message` is
  reserved for the dev console. New `formatConnectError` and
  `formatSyncError` helpers in WebDAVForm translate via a switch
  where each branch is a literal `_('...')`. Same treatment for
  the sync-failure path that previously surfaced raw e.message.
- "Syncing 0 / {{total}}" is now parameterized as
  "Syncing {{n}} / {{total}}" with n=0 at startup so the digit
  formats naturally and the template can be reused mid-sync.
- "Cleanup · {{count}} book(s)" hard-coded options used unsupported
  ternary; rewrote as plural-aware key.

i18n scanner fix (i18next-scanner.config.cjs):
- vinyl-fs walked into directories whose names end in source-file
  extensions (Next.js route folder `runtime-config.js/`, Playwright
  screenshot folder `*.test.tsx/`) and crashed with EISDIR.
  Resolved by expanding globs via `fs.globSync` and filtering to
  files only before handing to the scanner.

TypeScript-syntax sites that broke esprima during extraction:
- WebDAVBrowsePane / WebDAVForm: `(e as Error).message` and
  `failed[0]!.title` inside `_(..., options)` arguments. Replaced
  with `e instanceof Error ? e.message : String(e)` and
  `failed[0]?.title ?? ''` — also runtime-safer.

User-facing em-dash cleanup:
- Removed em-dashes from translation keys across SyncHistoryPanel /
  WebDAVForm / WebDAVBrowsePane / SyncPassphraseSection / send/page /
  replicaCryptoMiddleware / AIPanel. Tagline in `layout.tsx` kept.

Locale translations:
- ~2400 translations applied across all 33 locales for the keys
  that were either newly extractable, freshly worded, or
  pre-existing but untranslated. Zero `__STRING_NOT_TRANSLATED__`
  remain after the run.

Misc:
- next.config.mjs: drop `eslint.ignoreDuringBuilds: true` so build
  runs the same lint as CI.
- Collection type: add `total?: string` for ComicInfo series count.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(test): fix vitest invocation, run with 4 workers

`pnpm test:pr:web` was chaining `pnpm test -- --watch=false`, which
pnpm expanded into:

    dotenv -e .env -e .env.test.local -- vitest -- --watch=false

The second `--` made vitest treat `--watch=false` as a positional
file pattern, not a flag. Vitest then fell back to defaults (in CI's
non-TTY env that still meant a one-shot run, so the suite passed),
but the worker pool was effectively serialized for big chunks of the
243-file run — wall ~90 s on a 4-vCPU runner where the parallel-sum
of phases was ~236 s (≈2.6× effective parallelism).

Replace the chained pnpm invocation with a direct call to
`vitest run --maxWorkers=4`, matching the 4 vCPUs the GH Actions
ubuntu-latest runner provides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:59:29 +02:00
loveheaven 5c82351ab9 feat(integrations): add WebDAV sync to Reading Sync settings (#4204)
* feat(integrations): add WebDAV sync to Reading Sync settings

Adds a WebDAV entry under Settings -> Integrations -> Reading Sync with configure/browse UI, library-wide Sync now, and per-book sync of progress, annotations and (opt-in) book files + covers.

Reading progress and annotations are always synced when WebDAV is enabled; only Sync Book Files stays as a toggle since it's bandwidth-heavy.

* feat(webdav): add diagnostic sync history panel and document viewSettings invariant

Surface a per-run history for the WebDAV "Sync now" button so users can self-triage failures without rummaging through the dev console — a screenshot of the panel is now enough to file a useful bug report. The same change tightens the docs around viewSettings so the "device-local UI preferences" boundary is impossible to misread on the next refactor pass.

Sync history panel:

  * New WebDAVSettings.syncLog ring buffer (cap 10), persisted alongside the rest of settings so a screenshot survives across app restarts. WebDAVSyncLogEntry captures startedAt, finishedAt, status (success / partial / failure), trigger, the eight counters from SyncLibraryResult, the toast text, and an optional per-book failure list with a phase tag (download / upload-config / upload-file).

  * SyncLibraryResult gains a failedBooks: SyncFailureEntry[] field. The two existing failure points in syncLibrary (download catch, upload catch) now record per-book reason+phase via formatFailureReason(), which keeps the persisted blob small by stripping stacks/whitespace and capping length at 200 chars.

  * WebDAVForm.handleSyncNow now timestamps the run, builds an entry from the result on success/partial paths and from the caught error on failure paths, and appends through a fresh-read appendSyncLogEntry() so concurrent toggle changes can't clobber the log.

  * New SyncHistoryPanel + SyncStatusBadge + SyncHistoryDetails components render the log inline in the Settings page. The detail row groups counters into three semantic columns (activity, skipped, outcome) on a six-column grid so labels can wrap freely while numbers stay tabular and right-aligned. Per-book failures render as a separate stack below the counters.

viewSettings invariant:

  * buildRemotePayload and pullBookConfig already implement the right thing — only progress/location/xpointer/booknotes travel; viewSettings stays device-local. Comments now spell out the contract on both sides so future contributors don't reintroduce viewSettings on the wire by mistake.

* fix(webdav): preserve prior state across reconnect, drop stale closure in ensureDeviceId

Two bugs in the WebDAV sync flow surfaced during review:

1. WebDAVForm.handleConnect rebuilt the entire `webdav` settings block
   from the four credential fields the user just typed, dropping
   `deviceId`, `syncBooks`, `strategy`, `syncProgress`, `syncNotes`,
   `lastSyncedAt`, and `syncLog` on every reconnect. Most concerning is
   the deviceId rotation: a disconnect + reconnect made the next sync
   look like a brand-new device, defeating the cross-device clobber
   detection encoded in `RemoteBookConfig.writerDeviceId`. Extract a
   pure helper `buildWebDAVConnectSettings` that spreads the previous
   webdav object first so reconnect is non-destructive, matching the
   sibling pattern in KOSyncForm.

2. useWebDAVSync.ensureDeviceId merged the new deviceId into the closure
   variable `settings`, which can be stale when `pullNow → pushNow`
   fires back-to-back on book open or when the settings panel writes a
   sibling field concurrently. Read latest settings via
   `useSettingsStore.getState()` to match the pattern already used in
   `updateLastSyncedAt` and `persistWebdav`.

Adds three unit tests for the new helper, including the reconnect
preservation invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(webdav): address review observations on encodePath, pull skip, and remote GC

Three follow-ups from the review pass on top of 3f721d04. Each one was
called out as a smaller observation the reviewer noted but did not push:

* WebDAVClient.encodePath silently re-escaped literal % characters
  despite a comment claiming existing %-escapes are preserved. A caller
  that pre-encoded a space as %20 would see %20 become %2520 in the
  request URL, breaking any path that came in already escaped. Tokenise
  each segment into already-escaped %XX runs and everything-else, and
  only run encodeURIComponent on the latter. Add four unit tests
  exercising pure-unicode, pure-pre-escaped, mixed, and root-slash
  paths.

  Implementation note: two regexes are needed because a /g RegExp.test
  is stateful and would skip every other token in this map; the split
  regex has /g for the iteration, the classifier regex is anchored
  without /g for the per-token check.

* OPEN_PULL_SKIP_MS doc-comment claimed it catches the
  close-then-reopen flow, but useWebDAVSync unmounts on reader close so
  lastPulledAtRef resets to 0 — the new instance always passes the
  cooldown check on remount. The guard actually only fires on
  re-invocations of the open-book effect inside one hook lifetime
  (book-to-book navigation, double-render before hasPulledOnce flips).
  Rewrite both the constant's doc-comment and the call-site comment to
  match the real semantics.

* WebDAVSync push path doesn't DELETE the per-hash directory of a
  tombstoned book. The deletion *is* propagated through library.json
  so other devices hide the book, but storage on the WebDAV server
  grows monotonically. Add a TODO at the pushLibraryIndex call with a
  sketch of what a future garbage-collection sweep would need (a per-
  device acknowledgment field on RemoteLibraryIndex so we don't wipe
  data a peer hasn't seen the deletion for yet).

* refactor(webdav): extract WebDAVBrowsePane and SyncHistoryPanel from WebDAVForm

The WebDAV settings form was nearing 1500 lines and hosted three
loosely related surfaces — credential entry, sync controls + manual
trigger, and the in-app file browser — that didn't share much state.
Reviewer flagged it as a refactor candidate; this commit does the
actual split.

* WebDAVBrowsePane (new, 534 lines): owns currentPath, the directory
  listing, per-entry download status, the navigation handlers and the
  per-file icon / filename helpers. Reads credentials from the
  settings prop and otherwise reaches for envConfig / useLibraryStore
  / useAuth itself rather than threading them through props (matches
  how the rest of the integrations panels are wired).

* SyncHistoryPanel (new, 293 lines): the diagnostic history surface
  plus its three private helpers (SyncStatusBadge, formatSyncSummary
  Line, formatSyncTimestamp, SyncHistoryDetails). Moved verbatim from
  the inline definitions at the bottom of WebDAVForm — the component
  was already presentation-only and accepting the translation fn as a
  prop, so no API change.

* WebDAVForm (676 lines, down from 1456): keeps the mode switch
  (configured vs. not), the credential form, the sync sub-controls
  (Upload Book Files / Sync Strategy / Sync now button), and the
  large handleSyncNow effect — those last two are intrinsically tied
  to the settings store and would have just been pushed back up the
  prop chain by any extraction. The standalone SyncHistoryPanel and
  WebDAVBrowsePane are now mounted as siblings inside the configured
  branch.

No behavioural change — both new files run the same effects, build
the same JSX, and read/write the same store fields as before. All
existing webdav-related unit tests still pass.

Resolves the last of the reviewer's smaller observations on
3f721d04 (file length).

* fix(webdav): stream book uploads to avoid renderer OOM on large files

Both syncLibrary (manual Sync now in WebDAVForm) and useWebDAVSync (per-book auto/manual sync triggered on book open) materialised the full book binary as an ArrayBuffer in the V8 heap before PUTting it. With multi-hundred-megabyte PDFs / scanned books, the renderer either accumulates buffers across sequential pushes (library sync) or blows its heap ceiling on a single book (per-book sync), surfacing as a blank white screen on desktop and a binder-OOM kill of the WebView on Android.

Add a BookFileStreamingLoader option to pushBookFile that, on Tauri targets, hands the file path off to tauriUpload's Rust-side streamer so bytes never enter JS. The HEAD short-circuit is shared across both paths, so steady-state syncs still cost a single round-trip per book. Web targets keep the buffered fallback (no streaming HTTP primitive available there).

Wire the streaming loader through SyncLibraryOptions.loadBookFileStreaming for the library Sync now path, and inline it in useWebDAVSync.pushBookFileNow for the per-book path. Covers stay on the buffered loader — they're capped at a few hundred KB and don't justify widening the API.

* fix(webdav): keep Sync now state alive across Settings navigation/close

WebDAVForm tracked the library-wide Sync now run in component state, so any navigation that unmounted the form (drilling back to the Integrations list, or closing the SettingsDialog entirely) destroyed the in-flight indicator while syncLibrary's promise kept running off-thread. On return the user saw a re-enabled button with no progress affordance, an empty Sync History (until the run finally finished), and could trigger a second concurrent syncLibrary against the server.

Hoist isSyncing / progressLabel into a process-local zustand store (webdavSyncStore) and consume it from WebDAVForm. The store outlives any single mount, so re-mounting the form picks up the running sync's state on first render — button stays disabled, progress label keeps ticking, and the re-entrancy gate (now reading the live store rather than a stale closure) blocks duplicate clicks. Also surface 'Syncing…' in the IntegrationsPanel row so users get the cue without drilling into the sub-page.

Not persisted: the store dies with the renderer, which is the right semantic — a sync killed by app exit shouldn't look like it's still going on next launch.

* feat(webdav): cleanup mode for orphan book directories on the server

WebDAV pushes set Book.deletedAt as a tombstone but never DELETE the per-hash directory on the server, so the remote Readest/books/ tree accumulates dead entries from books the user deleted long ago. Add a dedicated cleanup mode in the WebDAV browser to evict them in batch.

Cleanup mode is reached via a new sweep button next to Refresh. Entering it pins the listing to Readest/books/, filters down to directories whose local Book carries deletedAt, and replaces the per-row icon with a checkbox. The footer carries a single right-aligned Delete from server action; selecting one or more rows and clicking it sends a confirm dialog (appService.ask, so it actually blocks on Tauri) and then runs sequential DELETEs against the server. Each row splices out of the listing the moment its DELETE returns, so the listing itself is the progress indicator; the button keeps a stable width by always reserving space for the spinner via the invisible class.

The local library is left untouched. Book.deletedAt is the authoritative deletion signal in readest's sync model — clearing or rewriting it here would cause sibling devices to either resurrect the book or lose the deletion event. Restore is therefore not offered: the per-entry download button already provides full recovery (tauriDownload + ingestFile streams the file back, ingestFile clears deletedAt as a side-effect, and the next sync round-trip merges remote progress and notes), and a metadata-only restore would leave users staring at unopenable shelf rows whenever the bytes had been GCed off local disk.

Browse mode is friendlier too. Per-hash subdirectory rows under Readest/books/ resolve their hash to the local library's title and short-form hash for skimmability; soft-deleted entries get a folder-off icon plus a 60% dimmed title (a redundant signal for touch platforms where the desktop-only hover tooltip doesn't fire). Cleanup runs are persisted into the existing sync history with a kind: 'cleanup' discriminator and a booksDeleted counter, so destructive batch operations are auditable alongside regular Sync now runs without polluting the common case (the new counter is zero-suppressed on plain sync entries).

* test(webdav): cover deleteDirectory and deleteRemoteBookDir

Pin the contract of the cleanup-mode delete plumbing: HTTP method, Depth: infinity header, Authorization header and target URL on the low-level deleteDirectory; success/failure/auth-failure routing and per-hash path construction on the high-level deleteRemoteBookDir. Status-code semantics are exercised end to end (200/204 ok, 404 idempotent, 401/403 AUTH_FAILED, 5xx generic, network throw NETWORK), so a future refactor can't silently drop the explicit Depth header or merge the auth-failure path into the per-book result struct without tripping a regression.

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:55:12 +02:00
Huang Xin f4de55e8f3 feat(send): twitter/x site rule + meta-tag fallback for stale rules (#4270)
Two layered additions to the clip-to-Readest pipeline:

* **Twitter / X article rule** — `[data-testid="twitterArticleReadView"]`
  for the body, `User-Name`/`UserAvatar-Container-*` testids for byline
  and avatar. Readability can't latch onto X's class-mangled CSS-in-JS,
  so the testid hooks are the only reliable anchors.

* **Universal meta-tag fallback** — new `META_FALLBACK` constant in
  `siteRules.ts` holding OpenGraph / Twitter Card selectors for title,
  byline, and author image. Site rules are now hints, not contracts:
  when their selectors miss (after a frontend redesign) the pipeline
  drops down to meta tags, then to Readability for content, before
  giving up. The fallback also fires when no site rule matched at all,
  so previously-unruled sites pick up byline + cover image they used
  to lack.

Wiring in `convertToEpub.ts`:
  - `extractWithSiteRule` consults `META_FALLBACK.{title,byline}` when
    rule selectors return empty.
  - The Readability branch does the same so non-ruled sites get a real
    byline/title instead of `''`.
  - `buildArticleCover` tries `og:image` / `twitter:image` between the
    rule's `authorImage` and the favicon fallback.
  - `extractHtmlTitle` prepends `og:title` before `<title>` / `<h1>`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:44:19 +02:00
Huang Xin 81bd5ee6b8 fix(send): address extension review findings (#4271) 2026-05-22 17:31:04 +02:00
loveheaven 9fa7cb266c fix(migration): skip migrate20251029 silently on fresh installs (#4268)
migrate20251029 unconditionally calls copyFiles() and deleteDir() on the legacy Images/Readest/Images path. On a fresh install where that directory never existed, both calls bubble up an OS error ("os error 2" / ENOENT) which is then caught one frame up by runMigrations() and printed as an Error migrating to version 20251029 in the console. Functionally harmless (migrationVersion is still advanced afterwards), but a misleading red herring for new users and anyone debugging startup logs.

Check fs.exists(oldDir) up front and return early if absent; also guard the deleteDir call in case copyFiles partially completed and the cleanup target is gone. No behavior change on machines that actually have the legacy layout.
2026-05-22 17:06:36 +02:00
Huang Xin 912e97cb82 feat(send): iOS share-extension picker + App Group queue + reliable host launch (#4267)
* feat(send): iOS share-extension picker + App Group queue + reliable host launch

Rework the iOS Share Extension to a Zotero-style sheet: URL preview row +
library group picker + "Save & Open". Queues each save into the shared
App Group container and best-effort launches the host app via the
Chrome-style responder-chain trick (IMP cast against
`openURL:options:completionHandler:`). The host plugin drains the queue
on `applicationDidBecomeActive`, so if the launch ever fails the article
still ingests next time Readest is opened.

A `WKScriptMessageHandler` named `readestShareBridge` lets the JS hook
post `{type:'ready'}` on mount, fixing the cold-start race when the
extension wakes the app before the React side has loaded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(send): cover generator, favicon fetcher, share-extension polish, locale sync

Builds on the previous commit (iOS share-extension picker + App Group queue +
reliable host launch) with three further additions to the send-to-Readest
pipeline:

* **Cover generator** — `services/send/conversion/coverGenerator.ts` renders a
  deterministic cover image into clipped EPUBs (favicon + page title + host).
  Hooked into `buildEpub.ts` so every clip path (desktop, mobile, browser
  extension) produces the same cover for the same source.
* **Favicon fetcher** — `services/send/conversion/faviconFetcher.ts` resolves
  the best-available site icon (Open Graph image → apple-touch-icon →
  /favicon.ico), with size + format normalization. Feeds the cover generator.
* **Unified page conversion** — `convertToEpub({kind:'page', ...})` replaces
  the older `convertPageToEpub(html, url)` so the share extension, /send page,
  and browser extension share one entry point. Test: `send-convert-page-unified`.
* **Share-extension project.yml comment** — clarifies why the ShareExtension
  target carries no `.lproj` files (system bar buttons + JS-supplied "Default"
  label, no per-locale strings to wire).
* **Locale sync** — 33 translation.json files updated with new "Default",
  "Saving article…", and cover-generator strings extracted by i18next-scanner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:46:13 +02:00
Huang Xin f6dfd09d82 feat(send): browser extension that clips pages into Readest as EPUBs (#4266)
Replaces the URL-only placeholder extension with a full MV3 page-clipper
that builds a self-contained EPUB on the user's machine and uploads it
to the inbox.

- Captures the rendered DOM in a content script, then runs Readability,
  asset bundling, and EPUB build through the shared
  `convertToEpub({kind: 'page'})` pipeline inside a Chrome offscreen
  document (the SW lacks DOMParser).
- Uploads the resulting EPUB directly from the offscreen page to the
  new `POST /api/send/inbox/file` endpoint — keeps the bytes in one
  realm because `runtime.sendMessage` JSON-serialises ArrayBuffer to
  `{}` between extension contexts.
- Adds a long-lived Port + ping handshake between SW, offscreen, and
  the on-demand capture content script so neither idle-eviction nor
  load-order races can hang the popup.
- Localised popup, badge feedback, key-as-content i18n (`_('English source')`)
  with an extract script that seeds locale stubs from i18n-langs.json
  and writes a static-imports map for the runtime. All 33 locales
  fully translated.
- Server: `pages/api/send/inbox/file.ts` accepts a raw EPUB body
  (Content-Type: application/epub+zip), enforces the inbox pending cap,
  stores to the existing send-inbox R2 bucket as `kind='file'`.
  `assetBundler` now sets `credentials: 'include'` in the non-Tauri
  branch so the extension SW carries paywalled-CDN cookies.
- 47 vitest cases for the extension shell (upload, badge, auth, lazy,
  popup state machine, auth-bridge token sync) + 8 cases for the new
  server endpoint. CI's `test_web_app` invokes both via the extended
  `test:pr:web` plus a `build-browser-ext` step that catches webpack
  alias / Tauri-stub regressions.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:45:29 +02:00
Amir Pourmand 9ad43aa8b2 feat(docker): add GHCR and Docker Hub image publishing (#4250)
* Add GHCR and Docker Hub image publishing with fully runtime-configurable pull-first Docker setup (#1)

* feat: add container image publishing workflow and pull-based compose setup

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/23c31167-9e15-4d44-ab89-f267b8cd6304

* refine docker publishing workflow and pull-first compose docs

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/23c31167-9e15-4d44-ab89-f267b8cd6304

* chore: temporarily expose docker publish run results for pr branch

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/c946a2f2-2219-4dea-a829-61b287bc4859

* fix: update pinned SHAs for setup-qemu-action and setup-buildx-action to v3 heads

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/0db6957e-476b-48e0-acf3-bee6964c3b32

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* chore: switch all workflow action refs from SHA pins to stable version tags

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/19334b37-9b4c-45df-9c3c-81a497cef8e8

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: restore missing `with:` blocks lost during SHA-to-tag substitution

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/f204f742-5b7d-4f05-9647-03b9db86ea3d

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix(ci): checkout submodules for docker image workflow

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/72309e8a-6c7c-4004-902a-565f67e5c15f

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* feat(docker): support runtime client env for pulled web image

* refactor(web): serve runtime config via script endpoint

* fix(web): escape runtime config script payload

* fix: align docker runtime config with internal/public backend urls

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/3bbcb608-6202-4f9e-b288-5c95a259da93

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* chore: align published workflow tags with docs

* docs: clarify runtime config precedence and linux host mapping

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/3bbcb608-6202-4f9e-b288-5c95a259da93

* refactor: move storage/quota config from build args to runtime env

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/da9b749e-0b1c-47b9-b474-5009765b6ea6

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: load runtime config in pages router app shell

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/b375132c-8317-4c98-b437-ae48c0153e3d

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: apply biome formatting for runtime config quota helpers

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/778f5d75-884e-401b-b7cb-4ab40bc64a11

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>
Co-authored-by: Amir Pourmand <pourmand1376@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: S3_PUBLIC_ENDPOINT, _document.tsx for beforeInteractive, runtimeConfig fallbacks, README port (#2)

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/4f92f818-c008-4caa-9684-d530500b5fb2

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: reformat runtimeConfig.ts to satisfy biome formatter (#3)

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/4eea77f4-67ab-4428-b59e-0b21be988037

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-21 19:57:35 +02:00
Huang Xin a1279a65ce feat(send): clip web URLs into self-contained EPUBs via Tauri webview (#4241)
Builds the URL-clipping path of the "Send to Readest" feature: paste a
link, the renderer ingests the rendered page, and a self-contained EPUB
lands in the library. No server proxy, no external CDN refs left in the
EPUB once it's saved.

Architecture

- New Rust `clip_url` command spawns a hidden Tauri WebviewWindow at the
  target URL with a real Chrome UA + WebKit fingerprint mask, so TLS-
  fingerprint and JS-challenge walls (Cloudflare, Medium, X, WeChat MP)
  resolve naturally instead of bouncing the server proxy.
- Capture transport is URL-payload navigation to a one-shot
  127.0.0.1:RANDOM_PORT/clip/{token}?d={url-safe-base64} listener.
  Top-level navigation isn't governed by CSP connect-src / form-action /
  WebKit Private Network Access — the four earlier transports
  (fetch, <form>, custom URI scheme, window.name) were each blocked by
  one of those.
- Page-to-EPUB bundler (`assetBundler`) walks <img>/<picture> with
  src → data-src → data-original → data-srcset → srcset fallback so lazy-
  loading sites don't ship a 60px LQIP; fetches assets in parallel with a
  per-asset timeout + per-asset/total caps; failed images degrade to alt-
  text placeholders. A per-site rules table (seeded with WeChat MP) + a
  selector fallback catches articles Readability misextracts. Builder
  prepends the article <h1> + byline so the EPUB has a proper opening.
- Nested EPUB TOC built from h1–h6.

UI surfaces

- "From Web URL" entry in the library Import menu, gated to Tauri; web
  build hides the URL field and points at the browser extension.
- `ImportFromUrlDialog` with auto-height (overrides Dialog's `sm:h-[65%]`
  default) and a dim placeholder for the URL field.
- Clip webview window styled to match Readest's main window — macOS
  decorations + overlay title bar; other desktops decorationless with a
  drop shadow; native background + in-page loading overlay pick up the
  caller's `themeCode.bg`/`fg` so light/dark/eink/custom themes all
  render correctly. Title localised, all five overlay/title strings
  translated across 33 locales.

Notes

- Gates the macOS traffic-light positioner to main/reader-* windows so
  the decorationless clip window no longer null-derefs in
  `position_traffic_lights`.
- Stricter validation across the path: schemes restricted to http/https,
  hex-color parsing rejects malformed values, server endpoint returns
  400 on missing/invalid base64.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:48:09 +02:00
loveheaven 5ac8564e41 feat(library): add Import from Folder dialog with format/size filters (#4229)
* feat(library): add Import from Folder dialog with format/size filters

Replaces the silent "import every supported file recursively" behaviour of the directory import menu item with an explicit dialog that lets users pick which formats to include, set a minimum file size, and choose between mirroring subfolders as nested groups (legacy behaviour) or flattening every match into the current library view.

The folder, the chosen Folder Structure radio, the ticked File Formats and the File Size threshold are all persisted in localStorage so re-opening the dialog seeds every field with the user's last choice. Cancelling the dialog does not write to storage so an aborted pick won't pollute the next session.

Also hides the native number-input spinner via a small .no-spinner utility in globals.css; on macOS WebKit the spin buttons were drawing over the rounded input border and looked broken. The KB suffix now lives inside the input's bordered shell instead of beside it.

Two correctness fixes the dialog flow exposed:

* The library importer + ingestService now treat groupId as a tri-state — undefined means "don't touch the existing group", '' means "explicitly the library root", any other string means a specific group. Previously a falsy check in both layers conflated '' with undefined, so re-importing a deduped book under flatten mode silently kept its stale groupId/groupName from the prior keep-as-groups run, making the book reappear in the old subfolder group instead of moving into the library root. New regression tests in ingest-service.test.ts cover both the empty-string case and the omitted case.

* Imports of arbitrary user paths (e.g. ~/Downloads) now go through a new allow_paths_in_scopes Tauri command that extends both fs_scope and asset_protocol_scope. The dialog plugin only auto-grants fs_scope, so reads through the asset protocol (RemoteFile / convertFileSrc) used to fail with "asset protocol not configured to allow the path". The shim is invoked after every selectFiles / selectDirectory call and once more at the start of runFolderImport so localStorage-restored paths are also covered. Granted scopes persist across restarts via tauri_plugin_persisted_scope.

* fixup(library): harden Import-from-Folder scope grant + RTL/dialog polish

Three review fixes on top of the Import-from-Folder feature:

* lib.rs: refuse to extend asset_protocol_scope for paths not already
  in fs_scope. Without this gate, any frontend code (XSS via book
  content, OPDS HTML, dictionary lookups, or a compromised dependency)
  could call allow_paths_in_scopes with '/' or '~/.ssh' and gain
  persistent read access to arbitrary user files via the asset
  protocol — the grant survives restarts thanks to
  tauri_plugin_persisted_scope. Mirrors the defensive check in
  dir_scanner.rs.

* ImportFromFolderDialog.tsx: migrate from a custom ModalPortal chassis
  to the project's shared <Dialog> primitive so eink mode auto-removes
  shadows, mobile gets the bottom-sheet treatment, RTL direction is
  applied, and focus management is correct.

* ImportFromFolderDialog.tsx: swap directional Tailwind utilities for
  the logical equivalents (text-start, ps-/pe-, rounded-s-, text-end)
  per DESIGN.md §2.8 — Arabic/Hebrew users were getting a mirrored
  number-input row with the KB suffix on the wrong side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* i18n(library): translate Import-from-Folder dialog strings across 33 locales

Translates the 13 new strings introduced with the Import-from-Folder
dialog (folder picker label, format-filter section, size-threshold
input, folder-structure radios, OK button, empty-result toast). All
33 supported locales — including RTL fa/he/ar — are now complete; no
__STRING_NOT_TRANSLATED__ placeholders remain in the catalog.

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>
2026-05-20 14:51:53 +02:00
Huang Xin ded64159b6 fix(send): library-clobber + perf: lazy-load conversion deps (#4238)
* perf(send): dynamic-import the conversion fallback so /library stays lean

conversionWorker.ts value-imported convertToEpub for the no-Worker
fallback path. That pulled mammoth, @mozilla/readability, DOMPurify and
@zip.js/zip.js into the main bundle — eagerly loaded on /library via
useInboxDrainer's static import of conversionWorker.

Switch the fallback to `await import('./convertToEpub')`. The worker
entry still value-imports convertToEpub for its own chunk; the
main-thread fallback only loads the heavy deps when Workers are actually
unavailable or fail.

Measured on the production web build:
- before: /library eagerly loads the 634KB conversion chunk
- after:  the 634KB chunk + its two ~627KB duplicates are all lazy

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(send): never clobber the library when /send writes before it has loaded

The /send page (and the inbox drainer when it races the library page's
load) called `useLibraryStore.updateBooks(envConfig, [book])` while the
store still held the empty initial library — `libraryLoaded: false`. The
merge ran against `[]`, so `saveLibraryBooks` persisted just the new
book as the *entire* library and sync pushed the clobbered copy to every
device.

Two-layer fix:

1. Harden `updateBooks`: if `libraryLoaded` is false, load the real
   library from disk first, then merge — `updateBooks` is now self-
   protecting against any future caller that forgets the load step.

2. Gate `useInboxDrainer` on `libraryLoaded`. The hook now subscribes to
   the flag and starts draining the moment the library finishes loading,
   instead of running the first pass against an empty in-memory copy.

Adds a regression test that fails without the store 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>
2026-05-20 13:29:46 +02:00
Huang Xin 0b18de0581 feat(send): Send to Readest — multi-channel capture into your library (#4230)
* feat(send): Send to Readest — multi-channel capture into your library

A Send-to-Kindle equivalent: email, web-upload, share, or one-click
capture books and articles into the cloud library; they sync to every
device.

Architecture (client-side processing): out-of-app channels drop a raw
payload into a per-user send_inbox; Readest clients drain it through one
shared ingestService.ingestFile(). The server never parses or converts.

- ingestService.ingestFile() — channel-agnostic import orchestration
  extracted from library/page.tsx (DI-based, forceUpload support).
- send_addresses / send_allowed_senders / send_inbox tables + RLS + 4
  SECURITY DEFINER claim/lease RPCs (migration 012_send_to_readest.sql).
- Conversion subsystem (DOCX/RTF/HTML/article/TXT -> EPUB) in a Web Worker.
- send-email Cloudflare Email Worker; inbox-drainer controller +
  useInboxDrainer hook; /api/send/* routes.
- Send to Readest settings panel: inbound address, approved-sender
  allowlist, recent activity, per-device drain toggle.
- /send web page (file drop + article URL) + SSRF-guarded fetch-url proxy.
- OS-shared files routed through ingestFile; Manifest V3 browser extension.

Security: inbox state changes only via SECURITY DEFINER RPCs (clients get
SELECT-only on send_inbox); approved-sender allowlist gates email;
SSRF guard on the one server-side URL fetch; inbox payload signed URLs
authorize against send_inbox.user_id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: run format:check in the pre-push hook

Biome format checking is fast (~0.4s), so gate pushes on it too — catches
mis-formatted files that bypassed the staged-only pre-commit hook before
they reach CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(send): address CodeQL security findings

- ReDoS (senders.ts): the email regex had ambiguous quantifiers around
  the literal dot. Rewrote it linear-time (domain labels exclude '.')
  and cap the input at 254 chars.
- XSS (convertToEpub.ts): run untrusted HTML through DOMPurify
  (sanitizeForParsing — keeps document structure) before DOMParser, so
  title extraction and Readability never parse executable markup.
- SSRF (fetch-url.ts): harden the host guard — block bare single-label
  hostnames, IPv4-mapped IPv6, CGNAT/benchmark/multicast ranges, and the
  unspecified address. DNS rebinding stays a documented residual risk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:06:52 +02:00
Huang Xin fe41c42ec5 chore: switch code formatter from Prettier to Biome (#4223)
Replace Prettier with Biome for formatting JS/TS/JSX/CSS/JSON. The CI
format check drops from ~23s to ~0.4s.

- Unify config into a single root biome.json (formatter + linter); the
  former apps/readest-app/biome.json was linter-only
- Mirror the old .prettierrc.json style: 100 line width, 2-space indent,
  LF, single quotes, trailing commas
- Enable the CSS tailwindDirectives parser for @apply in globals.css
- Convert // prettier-ignore comments to // biome-ignore format:
- Root scripts and lint-staged now run biome; apps/readest-app lint runs
  `biome lint` (lint-only) so formatting stays a separate CI step
- Drop prettier + prettier-plugin-tailwindcss dependencies

Markdown/YAML are no longer format-checked (Biome does not format them)
and Tailwind class sorting is no longer enforced.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:13:36 +02:00
loveheaven 05da6bdf43 feat(dictionary): add system dictionary provider for macOS, iOS, and Android (#4219)
Hand selected words off to the platform's native dictionary surface
when the user opts into the new "System Dictionary" entry under
Settings → Languages → Dictionaries. The setting is exclusive: enabling
it disables all other providers (and vice versa) so the in-app lookup
button either always opens the popup or always invokes the OS — no
mixed states.

Per platform:
- macOS: AppKit's -[NSView showDefinitionForAttributedString:atPoint:]
  via a top-level Tauri command in src-tauri/src/macos/system_dictionary.rs.
  Anchored at the selection's bottom-center (CSS pixels mapped into
  NSView coords), so the inline Lookup HUD appears just below the
  highlighted text without raising Dictionary.app to the foreground.
- iOS: UIReferenceLibraryViewController presented as a half-detent
  pageSheet on iPhone (medium → large drag-to-expand) and as a
  formSheet on iPad. Implemented in the native-bridge plugin.
- Android: ACTION_PROCESS_TEXT intent with EXTRA_PROCESS_TEXT_READONLY,
  dispatched without createChooser so users get the standard system
  disambiguation dialog with "Just once / Always" buttons. Reports
  unavailable=true when no app handles the intent so the TS layer can
  silently skip rather than open an empty chooser.

Web/Linux/Windows hide the row entirely. The provider is a sentinel —
the registry filters it out of the popup tab list (it has no in-popup
UI) and the annotator's handleDictionary checks isSystemDictionaryEnabled
to dispatch directly to the native bridge before opening the in-app
DictionaryPopup.
2026-05-19 07:03:52 +02:00
Huang Xin 52f9634810 feat(backup): include global settings in backup zip (#4211)
* feat(backup): include global settings in backup zip

Backup zips previously held only book files and library.json. Issue
#4098 asks for app configuration to be backed up too.

A `settings.json` snapshot is now written at the zip root. Restore
deep-merges it onto the current device's settings, so fields the
snapshot omits keep their current values.

`sanitizeSettingsForBackup` strips, via a blacklist, fields that are
device-specific or sync/migration bookkeeping (filesystem paths,
replica/kosync device ids, sync cursors, lastOpenBooks, screen
brightness, schema versions). Account credentials (kosync/Readwise/
Hardcover tokens, AI gateway key, OPDS catalog logins) are stripped
unless the user opts in via a new "Include account credentials"
checkbox in the Backup & Restore dialog — the zip is unencrypted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(backup): keep revived books visible after a cloud-synced restore

When the library is deleted (soft delete) and the deletion has synced
to the cloud, restoring an older backup un-deletes the books locally —
but the next sync's last-writer-wins merge re-applied the cloud's
deletion tombstone, so the restored books vanished again.

The deletion never bumps `updatedAt`, so a restored book and its cloud
tombstone share the same timestamp; `processOldBook` breaks the tie
toward the cloud.

`reviveRestoredBooks` now fixes up books that were soft-deleted locally
but present in the backup:

- Bumps `updatedAt` so the restore out-ranks the cloud tombstone. A
  single uniform offset is applied to every revived book, so their
  relative order — and the library's "Updated" sort — is preserved
  exactly; the newest maps to now, none land in the future.
- Clears `syncedAt` so the next push re-uploads them and corrects the
  cloud rows.
- Restores `downloadedAt` / `coverDownloadedAt` from the backup record
  (the local deletion had cleared them) so revived books are not shown
  as not-downloaded even though their files were re-extracted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:16:46 +02:00
Huang Xin 0fba5b7054 feat(config): version book config schema (#4208) 2026-05-17 18:23:46 +02:00
Huang Xin ba6e5899e5 feat(reader): RSVP CJK character mode and whole-word highlight (#4199)
* feat(reader): add RSVP CJK character mode and whole-word highlight, closes #4131

Add two CJK-only options to the RSVP overlay settings row:
- Character Mode: split CJK text per-character instead of by jieba/Intl
  word segmentation, restoring one-character-per-flash reading.
- Highlight Word: render a CJK word as a single centered, fully-colored
  span, fixing the focus-only highlight and even-length left-shift.

The focus point now skips trailing CJK punctuation so tokens like "是。"
highlight the character, not the punctuation. Both toggles appear only
for sections that contain CJK text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* i18n: extract RSVP CJK character mode and highlight word 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>
2026-05-17 15:30:11 +02:00
loveheaven 3620c61038 feat(reader): import annotations from Moon+ Reader (.mrexpt) (#4174)
* feat(reader): import annotations from Moon+ Reader (.mrexpt)

Add a new menu entry under the reader sidebar 'More' menu that lets users import highlights and notes exported from the Moon+ Reader Android app.

Implementation:

- utils/mrexpt.ts: parser for the .mrexpt plaintext format (entry id, NCX navPoint index b4, character offset b6, type marker, word and note).

- services/annotation/providers/mrexpt.ts: convert mrexpt entries to BookNote[] using bookDoc. Locate the chapter via b4 -> toc -> spine, then TreeWalker-search the section DOM for the highlighted word with English suffix tolerance (ing/ed/s/...). Falls back to a section-level CFI when the exact word can't be located. Re-imports are deduplicated by a stable id derived from entryId.

- BookMenu: add 'Import from Moon+ Reader' menu item dispatching the 'import-mrexpt' event.

- Annotator: handle 'import-mrexpt' — pick the file (Web File / Tauri path), parse, convert against the live bookDoc, merge into booknotes (latest updatedAt wins), persist via saveConfig, and apply to all live views so highlights appear immediately. User feedback via toasts (importing / imported N / N unmatched / nothing new).

* refactor(reader): simplify Moon+ Reader import notifications

Reworks the .mrexpt import UX so it shows exactly one toast per run
instead of up to two, and removes redundant intermediate notices.

- Drop the intermediate "Importing N annotations…" toast. The toast
  system shows one toast at a time, so it merely flashed and was
  replaced by the result toast.
- Drop the duplicate "Failed to read the selected file." toast in the
  read catch block; it falls through to the existing empty-content
  check which surfaces the same message.
- Collapse the three-way result toast (already imported / N unmatched /
  N imported) into one: "Imported {{count}} annotations" or
  "No new annotations to import".
- Fix a result-message bug: when every converted note was already
  imported and nothing was unmatched, the toast read "Imported 0
  annotations." It now reports "No new annotations to import".
- Pluralize the success message via i18n `count` (the previous `{{n}}`
  placeholder never pluralized, e.g. "Imported 1 annotations").
- Extract the dedupe/merge logic into a pure, unit-tested
  `mergeImportedBookNotes` helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(i18n): translate Moon+ Reader import strings

Run i18next extraction and translate the new .mrexpt import strings
across all 33 locales (340 keys). The import feature added in this PR
introduced translatable strings that had not yet been extracted.

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>
2026-05-17 05:37:26 +02:00
Huang Xin a20f68fc11 feat(readwise): allow overriding the Readwise sync base URL (#4196)
* feat(readwise): allow overriding the Readwise sync base URL

Add an advanced option to point Readwise sync/export at a custom,
Readwise-compatible endpoint instead of the hardcoded official API.
When the override is unset or blank, behavior is unchanged.

- ReadwiseClient resolves a custom `baseUrl` over `READWISE_API_BASE_URL`,
  trimming whitespace and trailing slashes.
- ReadwiseSettings gains an optional `baseUrl` field; it syncs as
  plaintext via the settings sync whitelist.
- ReadwiseForm exposes the URL under a collapsed "Advanced" disclosure
  on the connect screen, and surfaces a custom URL read-only once
  connected. Disconnect preserves the custom URL for easy reconnect.

Closes #4114

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* i18n(readwise): rename "Sync Base URL" label to "Custom URL"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:45:38 +02:00
Huang Xin f4483643f4 fix(tts): skip hidden footnotes in TTS, closes #4135 (#4193)
Footnotes/endnotes are hidden in the rendered page via `display: none`,
but TTS builds its blocks from its own document. For background
sections that document is raw XHTML loaded via `section.createDocument()`
without the page layout styles, so the footnotes were read aloud.

- `createRejectFilter` gains an `attributeTokens` option to match
  `aside[epub:type~="footnote|endnote|note|rearnote"]` (value-token
  match, like CSS `[attr~="x"]`), so footnotes are detectable on raw
  documents that lack the `epubtype-footnote` class.
- `TTSController` adds the footnote selectors to its reject filter.
- `getBlocks()` (foliate-js) skips the subtree of any block-level
  element the node filter rejects, ending the preceding block before
  it so footnote text doesn't leak in.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:23:54 +02:00
Huang Xin 8dfc0e945e fix(dictionary): normalize lookup query with trim + case fallback (#4192)
A double-click selection can carry trailing whitespace and most imported
dictionaries store headwords lowercased, so an exact match on the raw
selection often misses (e.g. `Hello` or `world ` fail to resolve
`hello`/`world`). Case-sensitive formats like mdict are hit hardest since
their reader compares the raw word.

Seed the lookup history with a trimmed word and try ordered query
variants (trimmed, lowercase, title-case, uppercase) per provider,
keeping the first hit. Closes #4176.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:52:48 +02:00
Huang Xin 787bbf2103 feat(reader): custom hardware-button page turning (#4177)
* feat(reader): add custom hardware-button page turning (#4139)

Lets users bind hardware remote keys (media keys, D-pad/arrow keys) to
previous/next page via a learn-mode capture UI in reader settings — an
accessibility feature for page-turner remotes.

- New global hardwarePageTurner system setting (enabled + key bindings).
- hardwareKeys.ts: key normalization, matching, and page-turn resolution.
- deviceStore: reference-counted media-key interception + learn mode.
- usePagination: flips pages from bound media keys (native bridge) and
  D-pad/keyboard keys (DOM keydown), scoped to the active book and
  suppressed while the toolbar is visible.
- Page Turner settings section on all platforms; web/desktop bind keys
  via DOM keydown only, native media-key interception stays mobile-only.
- Android: intercept media + learn-mode keys in dispatchKeyEvent.
- iOS: forward media keys via MPRemoteCommandCenter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(i18n): add and translate hardware page turner strings (#4139)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(reader): refine hardware page turner (#4139)

- Handle book-iframe key events (iframe-keydown messages) so custom
  bindings work as soon as a book is open, not only after the settings
  panel has been shown.
- Add Previous/Next Section bindings alongside the page bindings.
- Rename the hardwareKeys util to keybinding.
- Wire the Page Turner section into the settings Reset action.
- Drop the focus ring on the capture buttons; BoxedList gains an
  optional description.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(i18n): translate page turner section and key strings (#4139)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:57:33 +02:00
Huang Xin 4cd5d56b49 fix(tts): retry Edge TTS preload up to 3 times on failure, closes #4147 (#4171)
Edge TTS websocket requests fail intermittently, and a single transient
failure during preload silently dropped the cached audio chunk, which
could stall playback. Add a #createAudioUrlWithRetry helper that retries
createAudioUrl up to 3 attempts with a short backoff, bailing early when
the abort signal fires. Both the immediate and background preload paths
in speak() now use it.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:25:27 +02:00
Huang Xin 7716f189c3 fix(layout): keep header/footer transparent and fixed in scrolled mode, closes #4157 (#4168)
Remove the redundant "Apply also in Scrolled Mode" options for bars and
margins so scrolled mode renders the header/footer consistently with
paginated mode: transparent, fixed in position, and not obscuring content.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:30:43 +02:00
Huang Xin 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>
2026-05-10 17:46:25 +02:00
Huang Xin 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>
2026-05-09 19:09:57 +02:00
Huang Xin 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>
2026-05-09 16:58:59 +02:00
Huang Xin 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.
2026-05-09 16:18:46 +02:00
Huang Xin 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>
2026-05-09 13:50:08 +02:00
Huang Xin 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>
2026-05-09 10:40:19 +02:00
Huang Xin 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>
2026-05-08 22:52:09 +02:00
Huang Xin 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>
2026-05-08 20:42:10 +02:00
Huang Xin 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>
2026-05-08 19:03:23 +02:00
Huang Xin 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>
2026-05-08 18:10:34 +02:00
Huang Xin 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>
2026-05-08 13:22:49 +02:00
Huang Xin 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>
2026-05-08 05:33:18 +02:00
Huang Xin aea3fda086 chore: bump turso to the latest version (#4086) 2026-05-07 20:11:47 +02:00
Huang Xin 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>
2026-05-07 11:28:03 +02:00
Huang Xin 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>
2026-05-07 10:19:23 +02:00
Huang Xin 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>
2026-05-07 09:55:32 +02:00
Huang Xin 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>
2026-05-07 08:28:44 +02:00