forked from akai/readest
ded64159b6b44ea32935c71e8ae15ded4faab270
201 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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>
|
||
|
|
d943a1c146 |
fix(library): clear nested-folder groups when deleting from bookshelf (#4226)
* fix(library): clear nested-folder groups when deleting from bookshelf
Deleting a group from the bookshelf right-click menu used to leave the group on screen whenever the import had any sub-directories. The cause: getBooksToDelete matched only `book.groupId === id`, but the bookshelf renders a top-level group with id = md5("MyDir") while books imported from a sub-folder carry groupId = md5("MyDir/sub"). Sub-folder books never got marked for deletion, refreshGroups re-built the parent group from their groupName on the next render, and the user saw an undeletable folder.
Fix: when an id resolves to a known group via getGroupName, also collect every book whose groupName equals that path or starts with `${path}/`. Hash-based dedup keeps a book from being queued twice when both rules match. Single-book deletes and flat-folder group deletes are unaffected.
* refactor: expand group selections into book hashes at intake
Address review feedback on #4226: instead of re-deriving which books
belong to a group inside the deletion path with a path-prefix sweep,
resolve group ids into their constituent book hashes upstream where the
selection enters the deletion pipeline.
* New helper `expandBookshelfSelection(ids, items)` in libraryUtils:
group ids resolve to every (non-soft-deleted) book in the rendered
rollup; standalone book hashes pass through. Tested in isolation.
* `Bookshelf.deleteSelectedBooks` runs select-mode picks through the
helper before populating `bookIdsToDelete`.
* `BookshelfItem` right-click group delete dispatches the
constituent hashes from `group.books` directly, so the receiver
is a simple pass-through.
* `getBooksToDelete` collapses to a flat hash lookup — no prefix
sweep, no `getGroupName` call in the deletion path, no dedup set.
The nested-folder fix still holds because `generateBookshelfItems`
already rolls "MyDir/sub" books into the top-level "MyDir" group;
expanding via the rendered `group.books` picks them up automatically.
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
689537fd78 |
fix(ios): refresh appearance on system light/dark change, closes #4057 (#4210)
The window-level `overrideUserInterfaceStyle` applied by `set_system_ui_visibility` pins the WKWebView's trait collection, so the `prefers-color-scheme` media query never fires while the app stays foregrounded and `get_system_color_scheme` returned the stale pinned value. Detect appearance at the window-scene level instead — it sits above the per-window override — and push changes to JS via `window.onNativeColorSchemeChange`. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
952304a956 |
fix(sync): push books row alongside in-reader progress auto-sync (#4209)
The library sync lane (useBooksSync) only runs while the library page is mounted. While a reader stays open on one device, in-reader auto-sync pushes `configs` but never re-pushes the `books` row, so other devices' library pull-to-refresh keeps showing stale reading progress until the source reader is closed. useProgressSync.pushConfig now also forwards the in-memory library Book through the books lane after pushing the config. useProgressAutoSave has already merged config.progress into that Book via saveConfig, so the books push carries the up-to-date progress. Fixes #4198 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
0fba5b7054 | feat(config): version book config schema (#4208) | ||
|
|
1d4b7eed87 |
fix(txt): merge scene-break sections into the preceding chapter (#4063) (#4207)
The TXT-to-EPUB segment regex splits on dash dividers (`-{8,}`), which
authors commonly use as in-chapter scene breaks. Each heading-less section
after such a divider was emitted as its own chapter — a numbered paragraph
fallback chapter, or a chapter titled after a stray sentence — flooding the
generated TOC with entries that aren't real chapters.
Mark chapters with whether their title came from a detected heading, and
merge heading-less chapters into the preceding detected chapter instead of
pushing them as separate TOC entries. Fully heading-less text still chunks
into numbered fallback chapters as before.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
83607d14ea |
fix(opds): send Basic auth preemptively for optional-auth servers (#4206)
OPDS servers that allow anonymous access (e.g. Calibre-Web) return 200 without a WWW-Authenticate challenge. `fetchWithAuth` only attached credentials on a 401/403 retry, so a user who configured valid login details kept seeing guest-only content (own shelves missing). Send a Basic Authorization header on the first request whenever credentials are available. Digest auth still falls through to the challenge-driven retry since it can't be sent preemptively, and the retry is skipped when it would just repeat the preemptive Basic header. Fixes #4202 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
c8fabd331c |
fix(reader): resolve KOReader sync conflict against non-KOReader servers (#4205)
The sync-conflict dialog had two issues with servers other than KOReader (e.g. Kavita's KOReader-compatible sync endpoint): - "This device" preview rendered a bare "undefined" because reflowable books built the string from `sectionLabel`, which is empty for spine items with no matching TOC entry. It now falls back to the page count. - Choosing "use remote" closed the dialog but never moved the reader: `applyRemoteProgress` only knew how to navigate via CREngine XPointers, so non-XPointer progress strings were silently ignored. It now falls back to `view.goToFraction` using the reported percentage. Also fixes the section-title indentation in the dialog (SectionTitle bakes in `ps-4`, which misaligned the labels against their values). Closes #4200 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
9f0aa2f55d |
fix(tests): materialize zip.js blob before wrapping in File for case-mismatch fixture (#4203)
The case-mismatch EPUB fixture builds an archive with @zip.js/zip.js' BlobWriter and then wraps the resulting Blob into a File: const blob = await writer.close(); new File([blob], 'case-mismatch.epub', ...); Under vitest's happy-dom/jsdom, the File/Blob polyfill does not correctly pull bytes out of a nested Blob part produced by zip.js. The outer File reports a non-zero size, but the bytes BlobReader sees in DocumentLoader (libs/document.ts: 'new BlobReader(this.file)') are not a valid ZIP — getEntries() yields nothing, open() falls through with book = null, and the test crashes at: TypeError: Cannot read properties of null (reading 'sections') Materialize the zip bytes into a plain ArrayBuffer first, then construct the File from that. ArrayBuffer parts go through the polyfill cleanly because they don't require recursive Blob unwrapping, so zip.js reads a real archive and the test passes: const arrayBuffer = await blob.arrayBuffer(); new File([arrayBuffer], 'case-mismatch.epub', ...); This brings the fixture in line with the rest of the test suite (paginator-expand, page-progress-epub, toc-cfi-mapping, ...) which already use ArrayBuffer-based File construction. No production code is affected: real browsers handle nested-Blob File construction correctly. |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
ad1c2d6bb0 |
fix(reader): filter Magic Mouse wheel events to stop accidental page turns (#4195)
A touch-surface mouse like the Magic Mouse emits a flood of tiny, low- magnitude wheel events — plus an inertial momentum tail — for a single physical gesture, and even a light brush of the surface produces spurious deltas. The previous 100ms trailing debounce collapsed bursts but did not filter by magnitude, so isolated micro-touches and the momentum tail each turned a page, cascading into continuous accidental page turns in paginated mode. Add a wheel gesture detector that accumulates normalized wheel travel and only flips once it crosses a deliberate-intent threshold, then swallows the rest of the stream (the momentum tail) until the wheel goes idle — so one physical gesture flips exactly one page, mirroring native readers. Closes #4117 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
2d30868d23 |
fix(fonts): hydrate custom fonts on library page, closes #4178 (#4191)
Custom fonts vanished from the Font panel after an app restart unless a book was opened first. The custom-font store is hydrated only by the reader's FoliateViewer (on book open) or by useReplicaPull (gated on a signed-in user), so opening Settings straight from the library left the store empty. Add a useCustomFonts hook that loads persisted custom fonts on mount, unconditional of auth or book state, and mount it on the library page. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d2ff47029c |
fix(opds): detect XML feeds with leading whitespace, closes #4181 (#4190)
OPDS responses were classified as XML vs JSON with `text.startsWith('<')`.
Some servers (e.g. the Hungarian MEK catalog) return a valid Atom feed
prefixed with newlines/whitespace before `<feed>`, no `<?xml?>`
declaration, and a wrong `text/html` Content-Type. The naive check missed
the `<`, so the XML body was handed to `JSON.parse`, failing with
"Unexpected token '<' ... is not valid JSON".
Add a shared `looksLikeXMLContent()` helper that trims leading whitespace
(also stripping a UTF-8 BOM) before the check, and use it in both
`loadOPDS` and `validateOPDSURL`. Detection is now based purely on the
body, so formally-valid feeds with a bad Content-Type work.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
40b7c2c15e |
refactor(reader): harden saveConfig updatedAt refresh (#4189)
saveConfig refreshed config.updatedAt by mutating the config object in place. That only worked because every reader view shares one config object reference, and it bypassed Zustand change-detection entirely. Refresh updatedAt via an immutable setConfig store update instead, so it no longer depends on callers sharing the same reference, notifies subscribers, and never mutates the caller-provided object. Sync behavior is unchanged. Refs #4184 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
2acd08202b |
fix(a11y): use position absolute for skip-next-section link to prevent blank page (#4182)
* fix(a11y): use position absolute for skip-next-section link to prevent blank page * fix(a11y): nest next-section skip link inside last content element position:absolute alone does not fix the blank-page bug: a full-page illustration wrapper commonly carries `column-break-after: always`, and the skip link's static position after that break still renders in a fresh, blank column. Nest the link inside the deepest last content element so it shares the final content column, while remaining the last node in document order for NVDA's virtual cursor. Also use left:auto so it keeps its static position instead of pinning to the viewport edge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: leehuazhong <longsiyinyydds@gmail.com> Co-authored-by: Huang Xin <chrox.huang@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
f5e729a174 |
fix(reader): revert smooth mouse-wheel scrolling in scroll mode, closes #4130 (#4172)
The smooth-wheel feature (#3974, closing #3966) intercepts mouse-wheel events in scroll mode: it makes the wheel listener non-passive, preventDefault()s the native scroll, and replays the delta through a main-thread rAF animation against the renderer container. That regressed normal mouse scrolling on Windows (#4130): fast wheel bursts were discarded entirely, and the JS replay is structurally worse than native scrolling -- a non-passive wheel listener forces every wheel event (mouse and trackpad) off the compositor thread, and the postMessage hop plus main-thread animation add latency and jank that native compositor scrolling does not have. High-resolution scrolling (e.g. Logitech MX Master, the mouse in #3966) needs no special API: the OS/driver just delivers regular wheel events with smaller, more frequent deltas, and the browser scrolls them natively. #3966's own report ("smooth scrolling works with all applications apart from yours") points at the interception, not a missing capability. Restore native wheel scrolling in scroll mode. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
708e06a46e |
fix(opds): show summary as book description, closes #4156 (#4162)
The TypeScript types in `src/types/opds.ts` declared fresh
`Symbol('content')` / `Symbol('summary')` instances. foliate-js's
`opds.js` declared its own distinct ones, and since Symbols are unique
per call, `metadata[SYMBOL.CONTENT]` always returned undefined — even
though the parser had written the value under a same-named Symbol.
This broke silently in 0.11.1 after foliate-js #14 stopped also setting
a plain `content: string` fallback. For OPDS 1.x feeds (e.g. CWA) the
book description lives in `<entry><summary>`, which foliate-js exposes
only via `[SYMBOL.CONTENT]` — so the description vanished.
Re-export the SYMBOL from foliate-js so consumers read the same Symbol
identities the parser writes.
|
||
|
|
54aa20d4f8 |
fix(footnote): don't treat in-book numeric chapter/verse links as footnotes (#4152)
closes #4140 The bare-numeric-text heuristic added in #3894 to detect non-superscript footnotes (`/^.{0,2}\d+$/` over `anchor.textContent`) was too permissive: in-book TOCs that list chapter/verse links such as `<a>1</a>, <a>2</a>, ...` all match the regex, so clicking them sets `check=true` and the footnote handler renders the destination as a popup instead of letting the link navigate. The OSB v2 verse-index and OSB v4 chapter-index from the bug report both hit this. Reject the `check` heuristic when the clicked link sits inside a numeric link list (2+ sibling links with the same short-numeric pattern within three ancestor levels). A real body paragraph with a couple of footnote markers still passes; a flat TOC of numeric links does not. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
041af6859f | fix(sync): publish custom css settings after applying css, closes #4146 (#4151) | ||
|
|
fed8ab7b67 |
fix(tts): restore cross-section auto-page-turn during TTS playback (#4148)
When TTS playback crosses a section boundary, the page would stay on the last page of the previous chapter while audio continued reading the next chapter — leaving the user stuck behind the "back-to-TTS" button. Two compounding issues since the paginator adjacent-section preloading landed: 1. `handleSectionChange` called `view.renderer.goTo(resolved)` without awaiting. `TTSController.#initTTSForSection` does `await this.onSectionChange?.(sectionIndex)` precisely so the view can finish navigating before audio of the new section starts, but the missing await defeated that contract. 2. `handleHighlightMark` returned silently on a cross-section mismatch (`viewSectionIndex !== ttsSectionIndex`), so when the renderer.goTo above completed only partially — which can happen on the new paginator when the target section is already loaded as an adjacent view and the post-goTo state appears reused without a visible page flip — there was no second chance to drag the view to the TTS cfi. Fix: - Await `view.renderer.goTo` in `handleSectionChange`. - In `handleHighlightMark`, run the cross-section branch *before* the `followingTTSLocationRef` check and call `view.goTo(cfi)` directly, stamping `sectionChangingTimestampRef` so the back-to-TTS button stays suppressed while progress.location catches up. Skip only when the user is actively selecting text. Adds unit tests covering both the cross-section navigation path and the in-section scrollToAnchor path. |
||
|
|
598eb77237 |
feat(library): redesign empty-library onboarding (#4122)
First-run users opening Readest with no books now see a typographic
hero instead of the previous generic "Welcome to your library" hero.
Key UX changes:
- 64px PiBooks glyph at base-content/60 anchors a single-column
composition (max-w-md container, max-w-xs button stack)
- Headline "Start your library" — action-led, not "Welcome to X"
- Platform-aware description:
desktop: "Drop a book anywhere on this window, or pick one from
your computer."
mobile : "Pick a book from your device to add it to your library."
Branched on appService.isMobile so the touch-only flows don't see
drag-and-drop language.
- Auth-aware secondary action: a quiet underlined "Sign in to sync
your library" text link renders only when logged out; signed-in
users get just the Import CTA (sync runs automatically).
- Primary CTA "Import Books" unchanged; routes to existing file
picker. The surrounding hero drop-zone wrapper is preserved so
drag-and-drop import keeps working on desktop.
- TODO marker for a future "Browse free catalogs" entry above the
secondary action slot.
Implementation:
- Extracted as src/app/library/components/LibraryEmptyState.tsx
(~60 lines, single onImport prop) so the empty branch can be
unit-tested without mounting the full LibraryPageContent.
- src/app/library/page.tsx swaps ~17 lines of inline hero JSX for
one <LibraryEmptyState onImport={handleImportBooksFromFiles} />.
- Four unit tests cover desktop render, mobile render, auth-aware
sync-button hide, and import-click callback.
i18n: four new strings translated across 33 locales; en/translation.json
untouched per the project convention (non-plural strings live in
code). Stale "Welcome to your library..." key removed by the scanner.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
d326e1c73d |
fix: hide popup triangle when inside popup + EPUB image-only paragraph rendering (#4121)
- Popup: hide the inner triangle when its anchor point lands inside the popup body. Extracted as a generic `isPointInRect` helper in `sel.ts` (with a default 1px padding so edge cases stay visible). - style.ts: handle `<p[width][height]><img></p>` (common in some MOBI conversions) — clear hardcoded width/height and apply multiply blend for dark themes so the image doesn't sit on a colored box. - Annotator: shrink dict popup height from 480 to 360 to fit smaller screens. - foliate-js: submodule bump. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
1705006b6b |
fix(mobile): iOS PIN keyboard UX + Safari font line-height in EPUBs (#4120)
- AppLockScreen: pad the lock screen bottom by the on-screen keyboard height tracked via visualViewport, so the flex-centered PIN sits above the keyboard on iOS WKWebView where dvh does not shrink. - AppLockScreen: skip stickyFocus on mobile. iOS will not pop the keyboard from a programmatic .focus(), so the cursor would blink with no input — wait for the user's tap instead. - PinInput: forward autoFocus to the input when autoFocus or stickyFocus is set, for more reliable mount-time focus. - style.ts: give legacy <p><font>...</font></p> its own block context so iOS Safari applies the inherited line-height. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
772bb73b46 |
ui/ux: codify design system and migrate settings to shared primitives (#4116)
* ui/ux: codify design system and migrate settings to shared primitives Document Readest's design language in DESIGN.md (Adwaita-aligned, e-ink-first, RTL-correct) and migrate every settings panel onto a small set of primitives (BoxedList, SettingsRow, SettingsSwitchRow, SettingsSelect, SettingsInput, NavigationRow, Tips, SubPageHeader). AGENTS.md links to DESIGN.md so contributors land there before inventing new chassis classes. Replace the standalone KOReader/Readwise/Hardcover Config dialogs with a single Integrations panel (Reading Sync + Content Sources sub-pages). The reader's BookMenu now hides each provider until it's configured, and Hardcover's per-book "Enable for This Book" toggle is dropped — there's no auto-sync to gate, so the flag was just extra clicks. Refresh highlight colors (two-trigger swatch + label, translatable default names), background texture / theme color selectors (border-current keeps selection legible on any backdrop), CustomFonts/CustomDictionaries (quiet list-extension style + shared Tips primitive), the OPDS catalog manager (debounced auto-download, right-aligned Browse), Set PIN, and the KOSync conflict resolver. Translate the ~30 new strings across all 33 locales. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ui/ux: responsive typography, OPDS card polish, deep-link return paths Restore the .settings-content responsive cascade (14px desktop / 16px mobile) the legacy panels relied on by dropping hardcoded `text-sm`/`text-xs` from the new primitives. Secondary text moves to em-relative `text-[0.85em]` so it scales with the parent. Form controls (`<input>`, `<select>`) re-apply the cascade explicitly via the `settings-content` class since browsers don't inherit font-size onto form elements. Extract `<SectionTitle>` primitive (caseless-language aware via `isCaselessUILang`/`isCaselessLang`) and route every uppercase tag-style header through it: BoxedList groups, Reading Sync, Content Sources, Theme Color, Background Image, integration form labels, KOSyncResolver device labels, and the OPDS My Catalogs / Popular Catalogs sections. CJK / Arabic / Hebrew / Indic / Thai / Tibetan locales bump to `1em` since `uppercase` is a no-op on those scripts. Redesign the OPDS My Catalogs cards: whole card becomes the browse trigger (role='button'), edit/delete collapse into a 3-dot dropdown menu, and the sync-status moves to a sub-line under Auto-download so the card height stays constant whether the toggle is on/off or sync data has arrived. Plumb a `from=settings-integrations` URL marker through the OPDS browser so both manual close and auto-close-on-failure (preserved as `router.back()` for transient failures, paired with a new `stashOPDSReturnTarget` helper) return the user to Settings -> Integrations -> OPDS Catalogs sub-page rather than the dialog's top level. Backed by new `requestedSubPage` deep-link store field. Skip the OPDS catalog passphrase prompt when credentials sync is disabled -- `replicaPublish` already drops encrypted fields at the wire, so prompting was both pointless and confusing. Fix `SettingsDialog` calling `setRequestedPanel(null)` inside a `useState` lazy initializer (zustand setter during render -> React warning); move the clear into a one-shot `useEffect`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ui/ux: opt Settings into OverlayScrollbars + caseless typography polish Add an opt-in `useOverlayScroll` prop to `<Dialog>` that swaps the body's native `overflow-y-auto` for `<OverlayScrollbarsComponent>` (autohide, click-scroll, no native overlaid bars). SettingsDialog flips it on so the long Layout / Color panels keep a visible, theme-aware scroll track on Android / iOS webviews where native scrollbars auto-hide entirely. Other short-modal callers stay on the native scrollbar. Drop the `uppercase tracking-wider` SectionTitle styling for caseless scripts and pair it with body-weight `font-medium` instead — those typographic effects are no-ops on Han / Hangul / Devanagari / Thai etc., so a plain medium-weight body-size title reads more correctly than a shrunken pseudo-uppercase one. SettingsRow / NavigationRow primary labels follow the same rule (drop `font-medium` in caseless locales since the inherited body weight already carries; CJK fonts bold poorly at body size). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ui/ux: SettingLabel primitive + KOSyncForm select polish + Tips alignment Add `<SettingLabel>` primitive — caseless-aware row/field label that pairs with `<SectionTitle>` (groups) for per-item labels. Cased scripts get `font-medium`; caseless scripts (CJK / Arabic / Hebrew / Indic / Thai / Tibetan) drop the weight since Han / Hangul / Devanagari etc. bold poorly at body size. No font-size class so it inherits the `.settings-content` 14/16 cascade. Routed through `SettingsRow`, `NavigationRow`, and the ~12 ad-hoc inline `text-sm font-medium` callsites in AIPanel / FontPanel / ColorPanel / IntegrationsPanel / KOSync / Readwise / Hardcover forms. Refactor KOSyncForm's Sync Strategy + Checksum Method rows onto the shared `<SettingsSelect>` primitive — the inline 17-line div/select/ MdArrowDropDown chassis becomes a single SettingsSelect call with an options array. Drops the unused MdArrowDropDown import and ~25 lines. Fix Tips list-item alignment: callers traditionally pass `<li>` elements (semantic) but the primitive was double-wrapping into `<li><span><li>...</li></span></li>` — invalid HTML, and the inner `<li>`'s `display: list-item` broke line-wrap alignment on multi-line items. Unwrap caller `<li>` to its content; add `flex-1` on the text span so wrapped lines align under the first line instead of falling back to the bullet column. Bullet container switches to `h-[1.4em]` so it tracks the text line-height and pins to the first line's optical center via `items-center` regardless of how much the content wraps. DESIGN.md §5 typography updated to point primary-label callers at `<SettingLabel>`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
5774e00c09 |
feat(sync): opt-in Credentials toggle + keyring v4 migration (#4111)
* feat(sync): add opt-in Credentials toggle to Manage Sync Adds a new Credentials category (default OFF) that gates the encrypted fields (OPDS / KOSync / Readwise / Hardcover usernames, passwords, and tokens) at both the publish and pull pipelines. When off, sensitive fields never leave the device, the proactive passphrase prompt never fires, and the Sync passphrase panel is hidden entirely. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * security: bump keyring to version 4 --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
f6f446e8a0 |
feat(applock): blinking PIN cursor + misc UI polish (#4110)
* feat(applock): show blinking cursor on PIN input Empty PIN slots used to render nothing, leaving no cue for which position is active. Add a thin underscore that blinks under the next-to-fill slot while the input is focused. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(ui): misc settings polish + i18n refresh - applock screen: switch fixed positioning to full-height so the lock screen sits inside the safe area - applock dialog: split the recovery sentence so it reads cleanly without an em dash - settings: rename "Interface Language" to "Language" - translators: drop the "(Unavailable)" suffix from disabled providers; the row already greys out - ruler color picker: keep swatches clickable when ruler is off so users can still set a color before enabling - refresh translations across all locales for the changed strings Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
1eae2af23e |
feat(sync): batch replica sync into one /api/sync/replicas request (#4109)
Auto-sync triggers (boot non-settings, focus, visibilitychange, online,
periodic) used to fan out N parallel `GET /api/sync/replicas?kind=…`
requests, one per replica kind. With 5 kinds today and the focus path
firing on every foreground transition, that's 5x the Cloudflare Worker
invocations of what the work actually requires.
Server: extend POST /api/sync/replicas to accept a batched-pull body
(`{ cursors: [{kind, since}, …] }`) alongside the existing push
(`{ rows: […] }`). Per-kind queries fan out via Promise.all — Supabase
calls inside the Worker aren't billed as Cloudflare requests, so DB
load is unchanged while Worker invocations collapse from N to 1.
Client/manager: add `client.pullBatch` and `manager.pullMany` that
share the existing cursor/HLC machinery. The boot path's `since=null`
override carries over via `pullMany(kinds, { since: null })`.
Orchestrator: `triggerIncrementalPullAll` now does ONE pullMany call
then fans out per-kind apply via Promise.allSettled. Boot does the
same for non-settings kinds (settings stays a single call to preserve
its apply-first ordering invariant).
Foreground triggers: listen to BOTH `focus` and `visibilitychange`,
sharing one throttle. focus is fastest on iOS Tauri WKWebView (~T=0,
~400ms ahead of visibilitychange). visibilitychange is the only
signal that fires on browser tab switching — focus does not. Drops
the Supabase user-ref-change listener (was the slowest of the three
foreground signals; redundant with the DOM events).
Bonus: `useBooksSync` now serializes `handleAutoSync` against
`pullLibrary` via the shared `isPullingRef` gate. The two paths used
to fire two concurrent `/api/sync?type=books` requests on the same
`since` value at startup; now whichever runs first claims the gate
and the other skips (throttle's `emitLast` retries afterwards).
Per session: boot 5→2 Worker calls. Per foreground trigger: 5→1.
|
||
|
|
295a588988 |
feat(share): route annotation exports through the system share sheet (#4107)
Adds a `share` flag and `sharePosition` to `saveFile` across the app
services. On iOS/Android/macOS/Windows the annotation export now calls
the sharekit `shareFile` (writing the markdown/txt to `$TEMP` first when
no `filePath` is provided), so users get the system "Share via…" sheet
that drops the export into Mail, Notes, Messages, etc. Linux desktop
keeps the existing save dialog, since sharekit has no Linux backend.
On the web, `saveFile` now prefers `navigator.share({ files })` when the
browser advertises support via `canShare`. AbortError (user dismissed)
is treated as a deliberate "don't share" choice; any other rejection
(e.g., Chrome desktop's `NotAllowedError` despite a positive `canShare`)
falls through to the `<a download>` fallback so a save still happens.
Also fixes the macOS share popover anchoring: `preferredEdge: 'top'`
maps to `NSMaxYEdge`, which is the rect's bottom edge in WKWebView's
flipped coords, so the picker rendered below the trigger button. The
annotations export only got away with it because its dialog has no room
below — macOS auto-flipped above. Switching to `preferredEdge: 'bottom'`
(`NSMinYEdge` → top edge in flipped coords) anchors the popover above
the button consistently. Adds `$TEMP/**/*` to the Tauri fs capabilities
so the writable temp share file is permitted.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
4110911011 |
fix(sync): keep dictionarySettings consistent across devices (#4105)
The bundled `settings` replica's `dictionarySettings.providerOrder`
and `providerEnabled` repeatedly drifted on multi-device setups: a
fresh-install Device B would overwrite Device A's authoritative
order with its own local default, dict tombstones referenced via
the settings replica left "skipped" gaps in the UI, and providerEnabled
keys missing from providerOrder rendered as silently lost imports.
Six related fixes (mostly orthogonal):
- **Disk-priming** in `initSettingsSync(initialSettings)`: seeds
`lastPublishedFields` from the just-loaded disk settings so the
first `setSettings(disk_default)` at boot diffs against the disk
baseline (no diff → no push), instead of diffing every whitelisted
field against `undefined` and clobbering the server with locals.
- **Settings boot pull is awaited first** in `useReplicaPull` (with
a shared `settingsBootPullPromise`) so the dict/font/texture/opds
pulls' auto-saves see server-primed `lastPublishedFields` rather
than disk defaults — implicit even when the caller didn't request
the `settings` kind.
- **Visibility / online / periodic auto-pull** in `useReplicaPull`:
module-level listeners with a 30s visibility throttle and a 5-min
interval keep long-lived foreground tabs in sync (previously the
hook only did the once-per-session boot pull and `ReplicaSyncManager.startAutoSync`'s
comments lied — it only flushed dirty pushes).
- **Tombstone scrubbing for no-local rows**: `softDeleteByContentId`
scrubs `providerOrder` / `providerEnabled` by contentId regardless
of whether a local dict matches, and `applyRow` always invokes it
on tombstones — so Device B fresh-installs that pulled tombstoned
contentIds via the settings replica without ever having a local row
still get the provider-side entries cleaned.
- **Orphan rescue** in `loadCustomDictionaries`: providerEnabled keys
that have no slot in providerOrder (per-field LWW splits a settings
push) get spliced before the first builtin so user-imported dicts
stay contiguous near the top of the list, not stranded after the
builtins where users miss them.
- **`addDictionary` prepends** to `providerOrder` so a fresh local
import shows up at the top of the list. Reviving a soft-deleted
entry preserves its existing slot.
- **Explicit-publish gate for `providerOrder`**: `markExplicitProviderOrderPublish()`
in `replicaSettingsSync` is the only way for `publishSettingsIfChanged`
to ship `dictionarySettings.providerOrder`. UI handlers that
intentionally reorder (drag-drop, dict import, dict delete,
web-search add) opt in via `saveCustomDictionaries(env, { publishOrderChange: true })`.
Auto-mutations from replica pull / orphan-rescue / tombstone-scrub
no longer ever republish the local view of order.
12 new tests across `replicaSettingsSync`, `replicaPullAndApply`,
`useReplicaPull`, and `customDictionaryStore`.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
c30a59a9ed |
fix(epub): accept EPUBs with malformed first ZIP local file header (#4103)
Some EPUB writers (e.g., "ebookredo") emit a non-standard local file header signature on the first entry — bytes like PK\x03\x02 instead of PK\x03\x04. The archive is still readable via the End of Central Directory record, and @zip.js/zip.js handles it without complaint, but DocumentLoader.isZip() rejected the file at the magic-bytes gate before zip.js ever ran. The user saw "Unsupported or corrupted book file" on a perfectly readable EPUB. Drop the strict 4th-byte equality check. PK\x03 alone identifies a local file header — no other ZIP record signature starts that way — so loosening the check is safe and aligns with what tolerant ZIP parsers already do. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
ae42dcb53a |
fix(txt): parse author from txt filename and use edited metadata on fallback cover (#4095) (#4102)
When importing a `.txt` file the author field stayed empty unless the text content itself contained an `作者:…` header, even when the filename already encoded it. Common Chinese naming patterns like `《书名》作者:张三.txt`, `《书名》[张三].txt`, or `《书名》张三.txt` now contribute the author when the file body doesn't. - Added `extractTxtFilenameMetadata` in `utils/txt.ts` and replaced the ad-hoc `extractBookTitle` regex used by both convertSmallFile and convertLargeFile. Content-extracted author still wins; the filename author is the next fallback before the caller-provided one. - `BookCover` now reads `book.author || book.metadata?.author` so the author typed into the metadata edit dialog shows on auto-generated fallback covers when the original `book.author` was empty. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
302363a9fd |
feat(sync): per-category sync gates + Manage Sync UI (#4099)
* feat(sync): add per-category sync gates + Manage Sync UI The user can now enable / disable each sync category independently in Settings → Data Sync (User page). The map syncs across devices via the bundled `settings` replica, defaults to enabled so the preference is opt-out, and applies on both push (`replicaPublish`, legacy `useSync`) and pull (`useReplicaPull`, `useSync`) without backfilling on re-enable. Categories: - `book` / `progress` / `note` — gate the legacy `SyncClient` paths - `dictionary` / `font` / `texture` / `opds_catalog` — gate the replica-sync pulls + publishes for those kinds - `settings` — togglable, but force-on while `dictionary` is enabled because the dictionary's `providerOrder` / `providerEnabled` / `webSearches` live in the bundled settings replica. The UI shows the locked toggle as blue (enabled) with a hint instead of greying it out, since the underlying state IS on. UI: - New `SyncCategoriesSection` lists every category with a description and a daisyUI toggle. - New `Manage Sync` blue action on the User page (second slot, right after `Manage Subscription`); also surfaces inside the library `Advanced Settings` menu, deep-linking via `/user?section=sync`. - `SyncPassphraseSection` moved into the Manage Sync panel alongside the categories list. `Unlock now` button removed — the gate fires automatically on first encrypted push/pull and the manual unlock affordance was confusing. Adjacent cleanups: - `LangPanel` Dictionaries card gets `overflow-hidden` so the hover highlight clips to the card's rounded corners. - `FontPanel` gear icon replaced with a `Manage Fonts` row that matches the `Manage Dictionaries` pattern. i18n: extracted + translated 31 in-scope locales for the new strings (`Manage Sync`, `Data Sync`, `Manage Fonts`, plus the category copy block). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(i18n): translate new sync-categories strings across 30 locales Adds translations for the four strings extracted after the latest SyncCategoriesSection iteration: - `App settings` — toggle label for the bundled-settings sync gate - `Theme, highlight colours, integrations (KOSync, Readwise, Hardcover), and dictionary order` — description under that toggle - `Required while Dictionaries sync is enabled` — hint shown when the toggle is locked because dictionary sync depends on settings - `Unavailable` — `(Unavailable)` suffix on disabled translator providers; was missing from most locales until i18next-scanner picked it up this run Product names (KOSync, Readwise, Hardcover) left in Latin script. `Dictionaries` references in the third string reuse each locale's existing translation. `pt-BR` and `uz` deliberately untouched (out of the in-scope set). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(i18n): fill in pt-BR for the new sync-categories strings `pt-BR` is a registered, shipped locale (Portuguese (Brasil)) that fell through the gaps in earlier batch runs because it isn't listed in the i18n skill's locale-reference table. The fallback chain `pt-BR → pt → en` softened the impact, but BR-specific phrasing needs its own translations for the 20 new keys this PR added. `uz` stays excluded — that locale isn't registered anywhere (missing from i18next-scanner.config.cjs, src/i18n/i18n.ts, and TRANSLATED_LANGS), so its translation file is dead code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(i18n): translate uz for the new sync-categories strings `uz` is a registered locale (listed in `i18n-langs.json` and `TRANSLATED_LANGS` as `'Oʻzbek'`) but earlier batch translation runs excluded it because the i18n skill's static locale-reference table was incomplete. Filling in the 20 strings this PR added. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(agent): update i18n skill --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
cc8f917cdd |
fix(layout): silence viewport meta warning on non-Android browsers (#4097)
`interactive-widget=resizes-content` was set in the SSR viewport metadata so Android Chrome would shrink the layout viewport when the on-screen keyboard opens (matching iOS default behavior). Other browsers — Safari on macOS / iOS, desktop Chrome, Firefox — log a console warning every page load because they don't recognize the key. Move the attachment client-side, gated on a UA sniff for Android, so the meta tag stays clean for everyone else. The Android-specific behavior (modals centered above the keyboard) is preserved on the platform that actually needed it. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
51a553dd89 |
feat(sync): bundle dictionary settings into the settings replica kind (#4096)
* feat(sync): bundle dictionary settings into the `settings` replica kind Adds three entries to the SETTINGS_WHITELIST so `providerOrder`, `providerEnabled`, and `webSearches` flow through the bundled settings replica with whole-field LWW. `defaultProviderId` (last- used tab) is deliberately excluded — it's per-device state. The customDictionaryStore exposes `applyRemoteDictionarySettings` so pulled values propagate into the in-memory mirror that the reader popup and the dictionary settings panel read from. Without this the mirror would stay stale until the next panel mount. Also fixes `saveCustomDictionaries`: it mutated the existing settings object in place and called `setSettings` with the same reference, so the replicaSettingsSync subscriber saw no change and never published. Build a fresh settings reference instead. Collapses the original PR 6 (`dict_provider_position`) and PR 7 (`dict_web_search`) plans, which proposed per-element CRDT rows with deterministic actor-id tiebreaks. Whole-field LWW is the right call given how rarely users edit these on two devices at once — same precedent as `customHighlightColors` and `customThemes` shipping through the bundled kind in PR 5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sync): make dict.id stable across devices (= contentId) Replaces the per-device `Math.random()` bundleDir as `dict.id` with the cross-device-stable `contentId`. `bundleDir` keeps tracking the device-local on-disk path, so on-disk layout is unchanged. Touch points: - 4 import paths in `dictionaryService.ts` + `buildLocalDictFromRow` in `replicaDictionaryApply.ts` set `id: contentId` instead of `id: bundleDir`. - Every `providerOrder` / `providerEnabled` entry that came from the dict store is now uniformly contentId-keyed, so the bundled `settings` replica syncs them across devices without any seam translation. Dicts with no contentId (very old, never synced) keep their bundleDir as id and remain local-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
6e7c9d1395 |
feat(sync): bundled settings replica kind for cross-device prefs and credentials (#4094)
* feat(sync): add bundled `settings` replica kind for cross-device prefs and credentials Adds a single-row `settings` replica that syncs a whitelist of `SystemSettings` fields across devices via per-field LWW (one entry per dot-namespaced path). Plaintext for theme / highlight colour / TTS configuration; encrypted (AES-GCM under the user's sync passphrase) for kosync / Readwise / Hardcover credentials. Highlights: - Push-side diff against an in-memory snapshot for plaintext paths and a localStorage SHA-256 hash for encrypted paths, so a refresh doesn't re-publish or re-prompt for the passphrase. - Pull-side cipher-fingerprint dedupe + per-row passphrase gate; decryption failures surface as toasts (wrong passphrase / orphan cipher) instead of silent drops. - Auto-recovery for orphaned ciphers: when a row references a saltId no longer in `replica_keys`, clear the local hash and re-encrypt under the current salt on the next save. - Single in-flight `/sync/replica-keys` fetch with a value cache to coalesce the boot-time burst of concurrent unlock callers. * fix(sync): guard settings dot-path helpers against prototype-polluting keys Reject `__proto__`, `constructor`, and `prototype` segments in the settings adapter's `readPath` / `writePath`. Every caller currently passes a constant from `SETTINGS_WHITELIST`, so the guard is purely defensive — but it silences the CodeQL prototype-pollution warning on PR #4094 and keeps the helpers safe if a future call site ever forwards an untrusted path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
2d5590ec1f |
feat(applock): 4-digit PIN gate at app launch (#4093)
Closes #2285. Adds an opt-in 4-digit PIN that gates the library and reader on app launch. Threat model: casual physical/browser access by another person on a shared device — peace of mind, not defense against an attacker with filesystem access. The PIN is stored as a salted PBKDF2-SHA256 hash (100k iterations) in settings.json; the plaintext PIN is never persisted. Configured from Settings → Advanced Settings → "Set PIN…" (and "Change PIN…" / "Disable PIN…" once enabled). The lock screen and the set/change/disable dialog share a single 4-dot input component (PinInput) for a consistent UI; the dialog auto-advances focus from Current → New → Confirm. Lock-on-resume, biometric unlock, and account-based reset are out of scope for this MVP — disable for now is "clear app data". Bundles the previously-missed sync-passphrase i18n strings (PR #4090) across all 33 locales so no `__STRING_NOT_TRANSLATED__` placeholders remain in the tree. New - src/libs/crypto/applock.ts (PBKDF2 hash/verify; reuses derivePbkdf2Key) - src/store/appLockStore.ts (gate + dialog state) - src/components/PinInput.tsx (shared 4-dot input) - src/components/AppLockScreen.tsx (full-screen lock gate) - src/components/settings/AppLockDialog.tsx (set/change/disable) - src/__tests__/libs/crypto/applock.test.ts Modified - src/types/settings.ts (pinCodeEnabled / pinCodeHash / pinCodeSalt) - src/services/constants.ts (default off) - src/components/Providers.tsx (mount gate + dialog above app shell) - src/app/library/components/SettingsMenu.tsx (Advanced submenu entries) - src/styles/globals.css (animate-pin-shake keyframe) - public/locales/*/translation.json (21 PIN keys + 17 leftover passphrase keys × 33 locales) Verified - pnpm test (4018 pass) - pnpm lint (clean) - Manual web smoke: Set/Reload-locks/Wrong-PIN/Unlock/Change/Disable Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
712d564e9d |
feat(sync): encrypted OPDS credentials + Tauri keychain (PR 4c + 4d) (#4090)
* feat(sync): encrypt opds_catalog credentials end-to-end (TS path) Wires encrypted-credential sync for opds_catalog via the CryptoSession shipped in PR 4a (#4084) plus a new publish/pull crypto middleware. TS-only — native still uses ephemeral storage (re-enter passphrase per launch); PR 4d wires the OS keychain. - ReplicaAdapter gains optional `encryptedFields: readonly string[]`. Adapters stay sync; the middleware handles the crypto round trip. - replicaCryptoMiddleware.ts: encryptPackedFields drops the named fields from the push when the session is locked (no plaintext leak); decryptRowFields drops them on pull failure (local plaintext preserved by the store merge). - replicaPublish / replicaPullAndApply invoke the middleware. - OPDS adapter declares encryptedFields = [username, password] and now pack/unpack them as plaintext. - passphraseGate.ts: ensurePassphraseUnlocked coalesces concurrent calls, prompts via the registered prompter with kind=setup|unlock, throws NO_PASSPHRASE on cancel. - PassphrasePromptModal mounted at the Providers root; registers itself as the gate prompter. - CryptoSession.forget() wipes server-side envelopes + salts. - Migration 010 + replica_keys_forget RPC; DELETE /api/sync/replica-keys + client wrapper. - SyncPassphraseSection on the user page: status / Set / Unlock / Lock / Forgot. - CatalogManager pre-save: ensurePassphraseUnlocked when credentials are present; user cancel saves locally without sync. Plan updated: PR 4 split documented as 4a/4b/4c/4d. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(sync): persist sync passphrase via OS keychain (Tauri) Replaces the EphemeralPassphraseStore stub on native with real OS-keychain storage so users don't re-enter their sync passphrase every launch. Web stays on the in-memory ephemeral store by design. Native bridge plugin gains 4 commands wired across all platforms: - Rust desktop (`keyring` crate): macOS Keychain on apple-native, Windows Credential Manager on windows-native, Linux libsecret/ Secret Service on sync-secret-service. Per-target features so each platform compiles only the backend it needs. - iOS Swift: Security framework Keychain (kSecClassGenericPassword, SecItemAdd / Copy / Delete). - Android Kotlin: androidx.security EncryptedSharedPreferences (AndroidKeystore-derived AES-GCM master key, AES256_SIV / AES256_GCM key/value encryption). TS layer: - TauriPassphraseStore wraps the bridge calls. set is fail-loud (surfaces keychain rejection); get is fail-soft (returns null on any error so the gate prompts). - createPassphraseStore returns ephemeral synchronously; upgradeToKeychainIfAvailable swaps the singleton to TauriPassphraseStore on Tauri after probing the bridge. CryptoSession resolves the store via createPassphraseStore() each touch so the swap is transparent. - CryptoSession.tryRestoreFromStore: silent unlock at boot. Stale- entry recovery clears the store when the account has no salt server-side. unlock/setup persist; forget also clears the store. - Providers boot effect: upgrade keychain → silent restore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sync): make encrypted-credential pull actually decrypt + UX polish PR 4c shipped the encrypt path but the pull side silently dropped ciphers when locked, the modal was busy with double-rings, and web re-prompted on every page refresh. This rolls up the post-test fixes + UX polish: Pull-side decrypt: - decryptRowFields takes an `onLocked` callback the orchestrator wires to the passphrase gate; encountering a cipher field with a locked session now triggers the lazy-prompt path instead of dropping the field. - replicaPullAndApply re-applies the unpacked row for metadata-only kinds even when a local copy exists, so the now-decrypted creds reach the store (the binary-kind skip-if-local optimization doesn't apply). - Cipher fingerprint comparison: capture the row's `cipher.c` for each encrypted field, compare against the local record's lastSeenCipher. Same → skip prompt + decrypt entirely. Different (rotation / value change on another device) → prompt to re-decrypt. Fingerprint persists via OPDSCatalog.lastSeenCipher. Web persistence: - SessionStoragePassphraseStore: passphrase survives page refresh within the same tab, dies on tab close. Replaces EphemeralPassphraseStore as the default on web. Avoids localStorage / IndexedDB to keep the tab-scoped trust boundary. UI: - Renamed PassphrasePromptModal → PassphrasePrompt; modernized: filled input style with single subtle focus border, btn-primary + btn-ghost replaced with leaner custom buttons. eink-bordered + btn-primary classes give the dialog correct e-paper rendering. - globals.css: suppress redundant outline/box-shadow on focused text inputs / textareas (the element's own border is the focus indicator). - AGENTS.md: documents the e-ink convention (`eink-bordered`, `btn-primary` for inverted CTAs, etc.) so future widgets ship with e-paper support. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
6bfeb295d2 |
feat(sync): add opds_catalog replica kind (plaintext fields) (#4087)
Wires OPDS catalogs through replica sync as a metadata-only kind.
Plaintext fields only in this PR — encrypted credentials (username,
password) ship in the follow-up alongside the SyncPassphrasePanel UI
and Tauri keychain backend.
- Migration 009 extends the kind allowlist with 'opds_catalog'.
- replicaSchemas adds opdsCatalogFieldsSchema (name, url, description,
icon, customHeaders, autoDownload, disabled, addedAt) with a 50-row
per-user cap.
- New opdsCatalogAdapter is metadata-only (no `binary` capability).
Stable cross-device id from md5("opds:" + url.lower()) so two
devices that import the same URL converge to one row instead of
duplicating.
- New customOPDSStore (zustand) hydrates from SystemSettings,
publishes upserts/deletes through the replica pipeline, preserves
local-only username/password when overlaying remote updates, and
strips tombstones at the persistence boundary so existing
useSettingsStore readers (useOPDSSubscriptions, pseStream,
app/opds/page.tsx) need no migration.
- replicaPullAndApply branches on adapter.binary so metadata-only
kinds skip the bundleDir requirement and the manifest/binary path.
- CatalogManager rewires Add / Edit / Remove / Toggle / Add-popular
through the new store.
Plan update bundled in: tenet 8 (scalar settings sync via a bundled
row; collections sync per-record), per-kind allowlist now includes a
`settings` singleton that will collapse PRs 5 + 6+ into one bundled
adapter, and PR 4 is split into 4a (already merged) / 4b (this) / 4c
(encrypted credentials + UX).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
aea3fda086 | chore: bump turso to the latest version (#4086) |