a1cb228d002f597c588fdb1f65cfed188ef6f4c2
2186 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a1cb228d00 |
fix(library): wrap select-mode action bar on small screens (#4329)
Long translations (e.g. German "Gruppieren", "Löschen") pushed the 6-button action bar past the right edge on typical phones since the grid fallback only triggered below 350px. Switch to a 3x2 grid below sm: and clamp the container to the viewport width. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
cf44e85180 |
fix(reader): fit duokan-page-fullscreen cover image without cropping (#4328)
Closes #3914. Switch the page-fullscreen image to object-fit: contain (and SVG preserveAspectRatio meet) so cover images fit the page and center without cropping. Also adds a duokan-image-gallery-cell layout helper and drops the mobile marginBottomPx override. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
64651a65ef |
fix(sync): skip replica upload when not authenticated (#4327)
Prevents 'Please log in to continue' error when importing fonts or images locally without logging in. The replica upload is now gated on getAccessToken() so unauthenticated users are never enqueued. |
||
|
|
d7b633d8f7 |
i18n: translate new strings for OpenAI-compatible LLM settings and in-place library (#4326)
Translates 26 newly added strings across 33 locales, covering the OpenAI-compatible LLM provider configuration, Reedy retrieval (beta), OpenRouter fields, in-place external library messages, and iOS folder import edge cases. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
315d144d8a | fix(library): suppress loading-dots flicker on reader→library return (#4325) | ||
|
|
1f5481c0e3 |
fix(fxl): align TTS highlight overlay with scaled iframe coords (#4324)
Non-PDF fixed-layout EPUBs visually scale the iframe via CSS transform while keeping native dimensions inside, so getClientRects() returns unscaled positions. The SVG overlayer was sized in CSS pixels with no viewBox, leaving annotations and TTS highlights drawn at scale-1 displacement from the actual text. Bumps foliate-js to set a matching viewBox on the overlayer SVG, and adds a regression test covering spread/scroll/PDF/empty frames. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
1294ace9ce |
fix(file-picker): unblock .mrexpt and other custom extensions on Android (#4323)
The Moon+ Reader import flow asks for .mrexpt files via the 'generic' preset. On Android, Tauri routes that to the Storage Access Framework picker, which filters by MIME type. Extensions without a registered MIME (such as .mrexpt) end up greyed-out and unselectable. Books and dictionaries already bypass this by using an unfiltered picker and re-applying the extension whitelist client-side. Extend the same treatment to the 'generic' preset so callers can request arbitrary extensions reliably on Android. iOS already had no SAF restriction. |
||
|
|
ff605e000d |
feat(library): in-place import from registered external folders (#4315)
* feat(library): in-place import with cloud sync and symmetric local delete
Adds an `inPlace` option to importBook so a source file inside a
registered external library folder is referenced directly via
`book.filePath` instead of being copied into Books/<hash>/. Sidecars
(cover, config, nav) still live under Books/<hash>/.
ingestService routes through shouldImportInPlace, which marks an
import in-place when the absolute source path lives under any of
`settings.externalLibraryFolders` and is NOT inside a per-root
`Books/` subtree. The Readest data dir (`customRootDir`) is
intentionally excluded — that directory is Readest's home and
should freely hold hash copies; in-place is for user-registered
roots (Duokan, Calibre, Moon+ Reader, an iCloud mirror, …).
Cloud sync treats in-place books as first-class:
- uploadBook reads bytes from (book.filePath, 'None') when set.
The cloud key is unchanged, so a peer downloading the book
lands it under Books/<hash>/ as a normal hash copy.
- useBooksSync strips `book.filePath` before pushing — it is a
device-local path that is meaningless on any other device.
- ingestService no longer skips upload for in-place books;
autoUpload / forceUpload behave like any other book. Only
transient imports opt out.
- deleteBook 'local'/'both' now physically removes the source
file at book.filePath (base 'None'). Local-delete semantics
are symmetric with hash-copy books: the local copy is gone,
the cloud backup remains, a future pull restores under
Books/<hash>/. removeFile errors are swallowed.
New `SystemSettings.externalLibraryFolders?: string[]` (no UI yet;
registration entrypoint lands in a follow-up). Added to
BACKUP_SETTINGS_BLACKLIST alongside `localBooksDir` /
`customRootDir` so device-local paths don't ride cloud backups.
Tests: cloud-service, ingest-service, and backup-settings suites
cover in-place delete, multi-root matching, per-root `Books/`
guard, and the backup-strip.
* feat(library): one-tap "read in place" toggle in folder import
Surface the in-place / copy choice as a single "Read books in place (don't copy)" checkbox in the Import-from-Folder dialog. When the user opts in, the chosen directory is registered in `settings.externalLibraryFolders` and ingestService's `shouldImportInPlace` will route the books straight to importBook with `inPlace: true` — no copy into Books/<hash>/, sync still works, local delete still removes the source file (the symmetry was set up in the previous in-place commit).
User experience:
- First-time users hit the toggle once per library folder. The choice is also persisted to localStorage so subsequent dialog opens default to whatever they picked last.
- Repeat imports from a folder that's already registered as an external library folder force the toggle ON and disable it, with a help line explaining that imports from this folder are always in-place. The check is exact-string (after path normalization) so registering /Users/me/Duokan only locks the toggle for that exact path — picking /Users/me/Downloads after Duokan still shows the toggle in its normal state.
- URL-ingress / drag-drop replays go through `runFolderImport` without the dialog and default `readInPlace: false`. They still benefit from in-place automatically when the dropped path lives under an already-registered root, because that decision is made by `shouldImportInPlace` based on settings, not by the dialog flag.
Mechanics:
- ImportFromFolderResult gains `readInPlace: boolean`. ImportFromFolderDialog gains an `initialReadInPlace` prop (seeded from the new `readest:lastImportFolderReadInPlace` localStorage key) and an `isRegisteredExternalRoot` predicate it uses to render the locked / unlocked toggle.
- runFolderImport calls a new `registerExternalLibraryFolder` helper that appends the chosen directory to `settings.externalLibraryFolders` and persists settings, but only when `result.readInPlace` is true. `isRegisteredExternalRoot` does the inverse lookup the dialog needs. Both helpers normalize paths the same way `shouldImportInPlace` does so the predicate matches the ingest layer.
- The new feature has no effect for users who never flip the toggle: `externalLibraryFolders` stays empty, the path-prefix check in `shouldImportInPlace` returns false for every import, and books continue to be copied into Books/<hash>/ exactly as before.
Self-healing for externally-removed in-place books:
Once the dialog lets users opt their library into in-place mode, the source file becomes a piece of state Readest doesn't control — another app may rewrite it (e.g. Duokan persisting reading progress into the epub), the user may move it in Finder, or an external drive may unmount between sessions. Previously, clicking such a book would navigate into the reader, fail inside loadBookContent's `fs.openFile(book.filePath, 'None')` with a low-level IO error, flash an "Unable to open book" toast, and auto-bounce back to the library — leaving the stale library record in place so the next tap reproduces the same dance.
BookshelfItem.handleBookClick now probes availability before navigating, but only for purely-local in-place books (`book.filePath && !book.uploadedAt && !book.deletedAt`). If `appService.isBookAvailable` returns false — which for in-place books means the recorded `book.filePath` no longer exists at the OS level — we dispatch `delete-books` for that hash and show an info toast explaining the removal, instead of opening the reader.
Scope is intentionally narrow:
- Cloud-synced books still flow through `makeBookAvailable`'s on-demand download path; missing local copies trigger a re-download, not a deletion.
- Hash-copy books (no `filePath` set) are not probed: a missing Books/<hash>/ file under normal use signals a bug or filesystem corruption, not user intent, and silently dropping the record would hide the real problem.
- The dispatched delete-books event reuses the existing Bookshelf deletion path, so sidecar metadata and selection state are cleaned up the same way as a user-initiated delete. For in-place books that path doesn't touch any file outside Books/<hash>/, so the now-missing source location (or whatever the user did with it externally) is left alone — symmetric with 165f15a6.
* fix(library): centralize book content resolution
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
|
||
|
|
b5d898a48e |
fix(document): accept zips whose magic bytes are mangled but have valid EOCD (#4321)
Some EPUBs (e.g. downloaded from Baidu Netdisk) have their first few bytes corrupted while the rest of the archive is still well-formed. The zip spec locates archives via the End-of-Central-Directory record at the file tail, so a corrupted local file header signature does not actually invalidate the file. When the leading magic check fails, fall back to scanning the last ~64 KiB for the EOCD signature (PK\x05\x06); if present, treat the file as a zip and let zip.js read it normally. |
||
|
|
647343eab3 | agent: update implementation scope (#4319) | ||
|
|
5a092f16f7 |
feat(ios): folder import with security-scoped bookmark persistence (#4314)
* feat(import): support folder picker on iOS via native-bridge
Tauri's dialog plugin rejects folder picks on both mobile platforms with FolderPickerNotImplemented, so previously only Android could pick an import directory (it already routed through the native-bridge plugin's ACTION_OPEN_DOCUMENT_TREE). iOS users had no working folder-import entry point at all.
Add an iOS implementation of the native-bridge select_directory command using UIDocumentPickerViewController(forOpeningContentTypes: [.folder], asCopy: false), with a dedicated FolderPickerDelegate that:
- holds a strong reference until the picker dismisses (UIKit keeps the delegate weak), and
- calls startAccessingSecurityScopedResource on the picked URL and retains it for the app's lifetime so plain Foundation/POSIX reads against url.path work for the rest of the session.
Route NativeAppService.selectDirectory through the bridge for both iOS and Android, then call allowPathsInScopes so the picked directory is reachable via fs_scope and the asset protocol. The library page's pickImportDirectory entry point now also takes the mobile branch on iOS, while keeping the Android-only MANAGE_EXTERNAL_STORAGE prompt gated behind isAndroidApp.
* feat(ios): persist security-scoped bookmarks for picked folders
iOS hands the folder picker back a security-scoped URL whose access
right is granted only to the running process. The previous
implementation kept the URL alive for the lifetime of the process via a
static `urlsToKeepAlive` array, which worked for the current session
but forced the user to re-pick the same folder after every relaunch.
Add a `FolderBookmarkStore` that:
- Right after the picker returns, calls
`URL.bookmarkData(.minimalBookmark)` and stashes the bytes in
`UserDefaults` keyed by the POSIX path.
- On every `NativeBridgePlugin.load(webview:)`, walks every persisted
bookmark, resolves it back into a URL, and calls
`startAccessingSecurityScopedResource`. Holds the URL alive in a
process-scoped dictionary so subsequent Foundation / POSIX reads
against `url.path` succeed.
- Handles `isStale` by re-encoding the bookmark against the resolved
URL, and drops permanently unresolvable bookmarks (folder gone,
provider uninstalled) from `UserDefaults` so the next launch
doesn't re-attempt them.
Pair this with a Tauri-side change so the same paths are reachable
through both `dir_scanner::read_dir` and the fs plugin's `readDir`:
- `allow_paths_in_scopes` now has an iOS branch that widens
`fs_scope` / `asset_protocol_scope` for any path the frontend hands
it, intentionally without the desktop-side "must already be in
fs_scope" gate. The OS sandbox + bookmark store is the real
access-control boundary on iOS; widening Tauri's in-memory scope
set cannot escalate access beyond what the OS already grants. The
security comment on the command was rewritten to spell this
contract out.
- `allow_file_in_scopes` is now compiled for iOS too (previously
desktop-only) so the file-grant path is available when needed.
|
||
|
|
4c539e6be1 |
fix(opds): show 'Open & Read' for publications already in the library (#4313)
* fix(opds): show 'Open & Read' for publications already in the library
PublicationView only flipped the acquisition button to 'Open & Read'
when the user downloaded the book within the current component
lifecycle — the downloadedBook state started as null on every mount
and was never reconciled against the actual library. So reopening the
detail page (after navigating away in the OPDS browser, or coming back
from the reader) always showed 'Download' / 'Open Access' again and
asked the user to re-download a book they already had.
Two real-world feeds (m.gutenberg.org and ManyBooks) exposed two
distinct failure modes that made naive title/author equality unusable:
(1) Gutenberg's OPDS entries never carry <dc:identifier>. The only work
identifier lives in <atom:id>urn:gutenberg:1342:2</atom:id>, which
foliate-js's opds.js parses into metadata.id — a field
getMetadataHashInfo never reads. So the identifier candidate list
was empty and identifier-overlap matching silently skipped every
book.
(2) Author strings disagree across the boundary: the feed emits
'<author><name>Austen, Jane</name>' (Lastname-first) while the EPUB
inside the same feed ships <dc:creator>Jane Austen</dc:creator>.
Plain string equality (even after normalization) never matched.
Add findExistingBookForPublication in app/opds/utils/findExistingBook.ts
with a layered matcher:
- Pass 1: full metaHash equality (the strongest signal — same fingerprint
the bookService uses internally).
- Pass 2: identifier overlap. collectPublicationIdentifiers() splices
metadata.id into the candidate list alongside metadata.identifier, so
Gutenberg's 'urn:gutenberg:1342:2' feeds into identifierKeys(), which
extracts the '1342' digit-tail (>=3 digits, so the trailing ':2'
version suffix is ignored) and matches the EPUB's
'http://www.gutenberg.org/1342' → '1342'.
- Pass 3: tolerant title + author match. hasAuthorOverlap() does a
token-set fallback: each name is split on whitespace/comma/semicolon,
single-letter tokens (initials) and year-range tokens ('1775-1817')
are dropped, and we require >=2 shared tokens by default. So
'Austen, Jane' ↔ 'Jane Austen' match via {austen, jane} but
'Author A' ↔ 'Author B' don't (both collapse to {author} after
dropping single-letter tokens — we track raw token count to refuse
the single-token shortcut when discriminative parts were filtered).
Genuine mononyms still match on a single shared token because raw
count is 1 on both sides.
OPDSPerson[] is fed through the library's own formatAuthors so the
produced author string matches Book.author byte-for-byte (Intl.ListFormat
output, locale-aware). Soft-deleted books are skipped so removing a copy
locally reverts the button.
Wire it in opds/page.tsx by subscribing to useLibraryStore.library
(rather than the snapshot reads inside handleDownload) and memoizing
existingBookForPublication. Pass it to PublicationView, which now
seeds downloadedBook from the prop and resyncs when the prop changes
(switching publications inside the browser) — but does not clobber a
download success it observed locally before the parent recomputed.
Covered by unit tests for the helper, including the real Gutenberg
URN-in-atom-id case, ':version' suffix on the URN, 'Lastname, Firstname'
vs 'Firstname Lastname', year-range stripping, and the 'Author A' vs
'Author B' non-match.
* fix(opds): dedupe downloads by source url
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
|
||
|
|
58791e5175 | agent: support cross-agent workspace, now it should work with claude and codex (#4318) | ||
|
|
4a5674ef4f | chore: bump turso to version 0.6.1 (#4312) | ||
|
|
29c88c0216 | fix(opds): make Android Back / Reader→Back behave correctly inside the OPDS browser (#4311) | ||
|
|
64492d6551 | feat(reedy): wire MemoryConsolidator + live memory providers into ReedyAssistant (#4310) | ||
|
|
6be7606da4 | feat(reedy): wire Phase 5 skills + Phase 3 memory tools into the agent runtime (#4309) | ||
|
|
e0ce6c8c22 | feat(reedy): Appendix A · Phase 4 — custom thread UI on AgentRuntime (#4308) | ||
|
|
4aaf416c43 | feat(reedy): Appendix A · Phase 5.1 — SkillRegistry + 3 seed skills (#4306) | ||
|
|
49664ecb75 | feat(reedy): Appendix A · Phase 3.2 — MemoryConsolidator (#4304) | ||
|
|
568b8c0a88 | feat(reedy): Appendix A · Phase 3.1 — Memory services + memory tools (#4302) | ||
|
|
df2698fa0e | feat(reedy): Appendix A · Phase 2.6 — AgentRuntime + abort helper (#4301) | ||
|
|
a86b09dba5 | feat(reedy): Appendix A · Phase 2.5 — PromptContextBuilder + layers + tokenBudget (#4300) | ||
|
|
5c71ccb908 | feat(reedy): Appendix A · Phase 2.4 — built-in tools (non-memory families) (#4299) | ||
|
|
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> |
||
|
|
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>
|
||
|
|
7bd3386c20 |
fix(perf): avoid Layerize storm caused by huge <pre> blocks on Android (#4295)
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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`. |
||
|
|
c5384b2a6b |
fix: respect Android Back / Esc inside Settings sub-pages and Import-from-Folder dialog (#4286)
* fix(library): cancel Import-from-Folder dialog on Android Back / Esc The dialog was previously relying on <Dialog>'s built-in `native-key-down` listener to handle Back / Escape, but `useKeyDownActions` (used here for the Enter-to-confirm shortcut) registers its own sync listener that returns `true` on every Back keypress, consuming the event before <Dialog> ever sees it. As a result Android Back and Escape were silently swallowed inside this dialog. Wire `onCancel` so the hook actually performs the cancel itself, and guard it (like Enter) while a folder pick is in flight to avoid canceling mid-pick. * fix(settings): step back to parent panel on Android Back / Esc inside sub-pages Several settings panels render an in-place sub-view based on local state (FontPanel -> Custom Fonts, LangPanel -> Custom Dictionaries, IntegrationsPanel -> KOSync / WebDAV / Readwise / Hardcover / OPDS / Send-to-Readest). Pressing Android Back (or Escape) while one of these sub-pages was open used to close the entire Settings dialog because only <Dialog>'s own `native-key-down` listener handled the event. Mount a `useKeyDownActions` hook at each parent panel, gated on the sub-page being open, that calls the existing "go back" handler and consumes the event. Because `dispatchSync` walks listeners LIFO, the panel-level hook (registered after <Dialog>'s) claims Back first while a sub-page is open; once the sub-page is closed the hook is disabled and Back falls through to <Dialog> as before, closing the whole Settings dialog. This keeps all logic in the three parent panels — no changes needed to the seven sub-page components — and a single hook in IntegrationsPanel covers all six integrations sub-pages. |
||
|
|
ca17131f2a |
fix(reader): keep New Chat button visible above Android nav bar and force theme contrast (#4287)
The floating 'New Chat' button in the chat history sidebar suffered from two issues on mobile: 1. On Android (e.g. Pixel 9 with the gesture pill) the button rendered underneath the system navigation indicator because its position used plain bottom-4 with no safe-area inset. 2. With bg-base-300 / text-base-content the pill could collapse to a nearly invisible solid black shape under some themes / contexts where text-base-content was inherited as a near-background color, hiding the icon and label entirely. Fixes: - Offset the wrapper by env(safe-area-inset-bottom) + 1rem so the button sits above the Android gesture pill and iOS home indicator. - Switch the button to bg-primary / text-primary-content with shadow-md to guarantee strong contrast across all themes. - Add pointer-events-none on the positioning wrapper and pointer-events-auto on the button itself so the floating layer never blocks list interaction. |
||
|
|
336a719e08 |
fix(library): seed custom texture store at boot so saved texture renders on first paint (#4284)
Fixes #4254. On app boot, Providers called applyBackgroundTexture immediately after loadSettings() resolved, but the customTextureStore was still empty — loadCustomTextures only ran later when ColorPanel mounted or useReplicaPull seeded it during library render. The hook's addTexture fallback re-derives the texture id from name and creates a new entry with a different id whenever the saved id wasn't computed from the current name (legacy imports, cross-device sync), so applyTexture silently bailed out and no texture was mounted. Seed customTextureStore.setTextures(settings.customTextures) in Providers right after loadSettings() resolves — preserving the saved ids — so applyTexture can resolve them at boot. Only custom textures were affected; predefined textures (concrete, paper, etc.) worked already because the lookup falls back to PREDEFINED_TEXTURES. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
49b171f5e5 |
fix(reader): restore right-column clicks and selection in dual-page mode (#4283)
* fix(reader): restore right-column clicks and selection in dual-page mode Bumps foliate-js to readest/foliate-js@1ea2996, which stops marking visible non-primary views as `inert` / `aria-hidden`. Adds a regression test for the underlying visibility helper. When a dual-page spread crosses a section boundary, the right column lives in a non-primary view. The previous a11y sync set both `inert` and `aria-hidden` on that view — `inert` blocked link clicks and text selection in the visible column, and `aria-hidden` hid it from assistive tech while sighted users could still read it. The fix drops `inert` entirely (screen-reader swipe-next is already handled by `aria-hidden`) and applies `aria-hidden` only to views whose bounding rect lies outside the visible container. Fixes #4243 (right-column links unclickable in dual-page mode). Fixes #4259 (text selection fails when an image section is on the left). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(reader): update paginator-multiview a11y assertions for new visibility-based aria-hidden Browser tests previously assumed every non-primary view carried both `inert` and `aria-hidden`. The fix in the preceding commit drops `inert` entirely and only `aria-hidden`s views that are off-screen. Update the two assertions to: - check that no wrapper ever has `inert`; - require `aria-hidden="true"` only when the wrapper's bounding rect is fully outside the visible container, and require its absence when the wrapper overlaps the viewport (the regression case from #4243 / #4259). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
b78daed562 |
feat(send): gate email-in to Plus, Pro, and Lifetime plans (#4280)
Email-in (`<user>@readest.com`) is now a paid feature. The other
Send channels — in-app /send page, mobile share-sheet, browser
extension — stay open to free users.
Three enforcement layers:
- `pages/api/send/address.ts` and `pages/api/send/senders.ts` return
403 with `{ code: 'plan_required', plan, requiredPlans }` for free
users. No `send_addresses` row is allocated on the blocked path.
`pages/api/send/inbox.ts` and `pages/api/send/inbox/file.ts` are
deliberately left open — they're shared with the file-upload and
extension channels.
- `workers/send-email` looks up `plans.plan` after resolving the
recipient and bounces (not silently drops) inbound mail for free
users with a one-sentence message pointing to upgrade plus the free
clip channels. Bounce rather than drop so a downgraded user
understands why their mail stops landing.
- `components/settings/integrations/SendToReadestForm.tsx` reads the
user's plan from the JWT before any API call. Free users see one
friendly card — headline, value prop, "View plans" CTA → /user, and
a softer line about the free alternatives — instead of address /
senders / activity sections of disabled controls. The
IntegrationsPanel NavigationRow stays visible so users can discover
the feature.
Single source of truth for the entitled tier set: `EMAIL_IN_PLANS` +
`isEmailInPlan(plan)` in `src/utils/access.ts`. Mirror copies live in
the Worker (no shared import surface) — keep them in sync.
Edge cases:
- Downgraded user: existing `send_addresses` row stays. All three
layers block; re-upgrading silently restores the same address.
- Loading flicker: `userPlan` starts as `null` so the loading skeleton
stays up rather than briefly flashing the upgrade card for a paid
user on a slow client.
12 new unit tests cover the gate on `/api/send/address` and
`/api/send/senders` (GET + POST blocked for free users, no Supabase
access on the blocked path, allowed for plus / pro / purchase).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
db1c474e77 | fix(layout): use 100vh fallback for .full-height to unbreak old Chromium WebView (#4278) | ||
|
|
b8d986cbd6 |
fix(docker): fix Docker image latest tag and production runtime errors; add dev compose file, Codespace support, and semver release tagging (#7) (#4277)
* fix: also tag Docker image as latest on main branch push Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/bbc0f66e-7488-4d9d-976b-434588b3faa8 * fix: production Dockerfile missing patches/.env.web; add compose.dev.yaml for dev hot-reload Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/e27bba4e-b8ea-4694-b44d-6308aa778d41 * fix: add devcontainer to init submodules; add clear Dockerfile error when submodules missing Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/16d24ae0-d2e8-4523-b47e-4969dd587c50 * simplify production-stage: COPY --from=build /app /app instead of selective copies Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/356fabe4-f944-48b6-a409-41640480d52f * fix: add CI=true to dev compose to prevent pnpm no-TTY abort Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/ddc30fef-2c76-452d-aad6-d688ca912a1a * fix: add semver Docker image version tags on release Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/18cbc3c6-ba92-4e63-bef9-93dcf6698c21 --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> |
||
|
|
66c198e575 | chore: bump tauri-plugin-webview-upgrade to c7c04ab (#4276) | ||
|
|
8a19c686cb | ci: address code scanning scorecard alerts (#4275) | ||
|
|
0564b4dd48 |
fix(eink): restore [data-eink='true'] button rule grouping (#4273)
In #4230 the new .btn-contrast block was inserted into the middle of the existing rule [data-eink='true'] button, [data-eink='true'] .btn { color: theme('colors.base-content') !important; } which left [data-eink='true'] button grouped with .btn-contrast (applying background-color/border/color: base-100) and orphaned [data-eink='true'] .btn as its own rule. Net effect on any button that also has class="btn" — toolbar icon buttons, popup actions, etc.: [data-eink='true'] button bg=base-content, color=base-100 (specificity 0,1,1) [data-eink='true'] .btn color=base-content (specificity 0,2,0) The .btn rule wins on color, so the button ends up with a base-content background AND base-content text color. react-icons children render with fill: currentColor → invisible icon on solid background → every toolbar/popup button becomes a solid black (light mode) or solid white (dark mode) square in e-ink mode. Fix is the minimal regroup that #4230 was apparently trying to make: put the new .btn-contrast block AFTER the existing eink button/.btn selector list rather than splitting it. Tested locally: in e-ink mode, all annotation toolbar / quick-action popup buttons recover their icon glyphs; .btn-contrast still renders as the intended solid-CTA in both modes. |
||
|
|
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> |
||
|
|
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>
|
||
|
|
0e97206907 |
ci: optimize build time for Docker and CI workflows (#4263)
* ci: optimize build time for Docker and CI workflows - Dockerfile: slim production-stage to copy only runtime artifacts (.next, public, node_modules, package.json, next.config.mjs), dropping src/, src-tauri/, patches/, packages source, etc. - Dockerfile: add sharing=locked to pnpm store cache mount to prevent concurrent-build cache corruption - docker-image.yml: pin actions/checkout to SHA (consistent with other workflows) - docker-image.yml: switch Buildx cache from type=gha (10 GB shared limit, poor for multi-arch) to type=registry on GHCR (no size cap, already authenticated, correct per-platform caching) - pull-request.yml: use pnpm install --frozen-lockfile --prefer-offline - release.yml: use pnpm install --frozen-lockfile --prefer-offline - next.config.mjs: skip redundant eslint pass during next build (lint already runs as a dedicated CI step) * fix(docker): eliminate QEMU emulation, fix pnpm version mismatch, patch artifact write CVE (#4) * fix(docker): eliminate QEMU arm64 emulation and fix pnpm version mismatch - Fix pnpm version in Dockerfile: 10.29.3 → 11.1.1 (matches package.json) Prevents corepack from re-downloading pnpm 11 on every build - Replace single QEMU job with matrix build (ubuntu-latest for amd64, ubuntu-24.04-arm for arm64) — eliminates ~21 min QEMU emulation overhead - Use per-platform build cache tags (buildcache-linux-amd64 / buildcache-linux-arm64) to avoid cache thrashing between architectures - Add merge job that assembles multi-arch manifest from platform digests Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/89c298b4-8a58-4517-ac7f-2f26c86bbcd6 Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com> * fix(ci): upgrade artifact actions to v7 to patch arbitrary file write vulnerability actions/download-artifact >= 4.0.0 < 4.1.3 allows arbitrary file write via artifact extraction. Pin both upload-artifact and download-artifact to v7 (SHA-pinned), consistent with the rest of the repo's workflows. Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/89c298b4-8a58-4517-ac7f-2f26c86bbcd6 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> * chore(docker): tighten build context Exclude local env, build output, credential, and tooling state files from Docker build context to reduce registry cache exposure. --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Huang Xin <chrox.huang@gmail.com> |
||
|
|
81bd5ee6b8 | fix(send): address extension review findings (#4271) | ||
|
|
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.
|
||
|
|
60203a8dc3 |
docs: add architecture and code-layout guides (#4265)
Add two complementary English documents under apps/readest-app/docs/ to help new contributors ramp up on the Readest codebase: - code-layout.md: directory-level inventory of the monorepo, covering apps/readest-app, packages/, the Tauri native shell, workers, and the conventions used for stores, services, and components. - architecture.md: high-level architecture with Mermaid diagrams. Maps the three runtimes (browser webview, Tauri Rust host, Next.js server), the dual API routers (pages/api legacy + app/api), Zustand stores, AppService / DatabaseService abstraction with its three backing implementations, foliate-js / pdfjs / simplecc / jieba integration, and cross-cutting subsystems (sync, cloud library, AI/RAG, translation, TTS, dictionaries, OPDS, Hardcover/Readwise, annotations, Send to Readest). Also documents the COOP/COEP middleware, allow_paths_in_scopes security gate, and the runtime re-config trick used for the single-image Docker deploy. No code changes; documentation only. |
||
|
|
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>
|
||
|
|
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>
|