forked from akai/readest
f6dfd09d82ea360a41721a175412e2d079dff8e6
2137 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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>
|
||
|
|
9ad43aa8b2 |
feat(docker): add GHCR and Docker Hub image publishing (#4250)
* Add GHCR and Docker Hub image publishing with fully runtime-configurable pull-first Docker setup (#1) * feat: add container image publishing workflow and pull-based compose setup Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/23c31167-9e15-4d44-ab89-f267b8cd6304 * refine docker publishing workflow and pull-first compose docs Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/23c31167-9e15-4d44-ab89-f267b8cd6304 * chore: temporarily expose docker publish run results for pr branch Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/c946a2f2-2219-4dea-a829-61b287bc4859 * fix: update pinned SHAs for setup-qemu-action and setup-buildx-action to v3 heads Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/0db6957e-476b-48e0-acf3-bee6964c3b32 Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com> * chore: switch all workflow action refs from SHA pins to stable version tags Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/19334b37-9b4c-45df-9c3c-81a497cef8e8 Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com> * fix: restore missing `with:` blocks lost during SHA-to-tag substitution Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/f204f742-5b7d-4f05-9647-03b9db86ea3d Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com> * fix(ci): checkout submodules for docker image workflow Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/72309e8a-6c7c-4004-902a-565f67e5c15f Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * feat(docker): support runtime client env for pulled web image * refactor(web): serve runtime config via script endpoint * fix(web): escape runtime config script payload * fix: align docker runtime config with internal/public backend urls Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/3bbcb608-6202-4f9e-b288-5c95a259da93 Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com> * chore: align published workflow tags with docs * docs: clarify runtime config precedence and linux host mapping Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/3bbcb608-6202-4f9e-b288-5c95a259da93 * refactor: move storage/quota config from build args to runtime env Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/da9b749e-0b1c-47b9-b474-5009765b6ea6 Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com> * fix: load runtime config in pages router app shell Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/b375132c-8317-4c98-b437-ae48c0153e3d Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com> * fix: apply biome formatting for runtime config quota helpers Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/778f5d75-884e-401b-b7cb-4ab40bc64a11 Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com> Co-authored-by: Amir Pourmand <pourmand1376@gmail.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: S3_PUBLIC_ENDPOINT, _document.tsx for beforeInteractive, runtimeConfig fallbacks, README port (#2) Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/4f92f818-c008-4caa-9684-d530500b5fb2 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com> * fix: reformat runtimeConfig.ts to satisfy biome formatter (#3) Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/4eea77f4-67ab-4428-b59e-0b21be988037 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com> --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> |
||
|
|
ae81cd0151 |
feat(annotator): support global highlights that fan out across all matching positions (#4257)
Introduces a 'global' annotation flag so a highlight/note created on one occurrence of a phrase is automatically applied to every matching occurrence in the book (and stays applied across reloads). Renders these expansions as transient overlays without creating duplicate persisted notes. This flag will not show when the book is fixed layout like PDF or CBZ. - types: add 'global?: boolean' to BookNote and DBBookNote; transform layer round-trips the field, with regression coverage ensuring older clients do not clobber it on write-back. - db: new migration 013_add_book_notes_global.sql adds nullable 'global' column to public.book_notes; init schema.sql updated to match. - annotator: new utils/globalAnnotations.ts handles cfi expansion / text-match search across the spine and overlay synthesis. Annotator.tsx fans out global notes on load and on overlay creation; AnnotationPopup and HighlightOptions expose a toggle to mark a highlight as global. - sync path is transparent: a global note created on another device is fanned out locally on next render with no extra UI required. |
||
|
|
62b5ed8138 |
feat(send): handle shared URLs from system share sheets (iOS + Android) (#4256)
Users can now tap "Share → Readest" in Safari, Chrome, or any other browser on iOS / Android and the article URL flows through the same clip-and-import pipeline the in-app "From Web URL" entry uses. Android `MainActivity.handleIncomingIntent` already routed file shares via `ACTION_SEND` + `EXTRA_STREAM`. Extend it to also pick up URL shares via `ACTION_SEND` + `EXTRA_TEXT`: parse the first http(s) token out of the text payload and dispatch it on the existing `shared-intent` event channel. No new event channel needed — `useAppUrlIngress` already listens and re-broadcasts as `app-incoming-url`. The existing `<intent-filter>` for `ACTION_SEND` with `*/*` MIME type already accepts `text/plain` from browsers — no manifest change required. iOS `gen/apple/` gains a new ShareExtension target. The extension's `ShareViewController` extracts a URL from `NSExtensionContext.inputItems` (prefers `public.url`, falls back to first http(s) token in `public.plain-text`) and forwards it to the main app as `readest://clip?url=<encoded>` via the responder-chain `openURL:` selector — the standard share-extension trick used by Pocket, Instapaper, Matter, etc. `project.yml` adds the ShareExtension target and switches the main app's Info.plist / entitlements references to `INFOPLIST_FILE` / `CODE_SIGN_ENTITLEMENTS` build settings instead of xcodegen's `info:` / `entitlements:` blocks. That way the hand-tuned `Readest_iOS/Info.plist` (CFBundleDocumentTypes, UTExportedTypeDeclarations, locales, CFBundleURLTypes for readest://, applesignin, associated-domains for Universal Links) is treated as an opaque input — xcodegen won't regenerate it. JS New `useClipUrlIngress` hook subscribes to `app-incoming-url`, unwraps `readest://clip?url=<encoded>` into the inner URL (the iOS forwarding path), filters out file URIs and annotation deep links, and runs each remaining http(s) URL through `clip_url` → `convertToEpubWithWorker` → `ingestFile` — the same path `/send` uses. Mounted alongside `useOpenWithBooks` and `useOpenAnnotationLink` in both `app/library/page.tsx` and `app/reader/page.tsx` so shares arriving while the user is reading still process. Notes - The PR targets `feat/send-clip-mobile` (PR #4252) since the share pipeline depends on `clip_url` being available on mobile. - iOS Share Extension built locally via xcodegen; the regenerated pbxproj is tracked because gen/apple is gitignored. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
17749f7cc7 |
feat(send): mobile URL clipping via native-bridge plugin (#4252)
iOS and Android now run the same Web-URL clip flow as desktop. Paste
an article URL, the native side opens a full-screen WKWebView /
WebView with the same Chrome UA + fingerprint mask + "Saving to
Readest" overlay as the desktop hidden window, waits for load +
settle, captures `document.documentElement.outerHTML` via the
platform's `evaluateJavaScript`, and returns it through the existing
`convertToEpub` pipeline.
JS surface stays `invoke('clip_url', { url, options })` — no changes
in `library/page.tsx` or `send/page.tsx`. The platform branch lives
entirely in `clip_url.rs`.
Why not Tauri's `Window::add_child`
`add_child` is gated `#[cfg(any(test, all(desktop, feature =
"unstable")))]` in tauri 2.10. No public API for attaching a second
webview to the main window on mobile, so the clip flow can't be a
`#[cfg(mobile)]` branch of the existing `WebviewWindowBuilder` shape
— it needs native code. Extend `tauri-plugin-native-bridge` rather
than create a separate plugin: the Swift / Kotlin scaffolding +
Tauri IPC are already there.
Layout
- `src-tauri/src/clip_url.rs` — desktop branch unchanged; new
`#[cfg(mobile)]` `clip_url` command routes through
`app.native_bridge().clip_url(request)`. Shared `ClipOptions`
struct exposes its fields `pub` so the mobile branch can map into
the plugin's `ClipUrlRequest`.
- `plugins/tauri-plugin-native-bridge/src/models.rs` — `ClipUrlRequest`
+ `ClipUrlResponse` mirroring `ClipOptions` field-for-field so the
payload travels untouched from JS through to Swift/Kotlin.
- `plugins/tauri-plugin-native-bridge/src/{desktop,mobile}.rs` — desktop
returns an error (desktop has its own path); mobile dispatches via
`run_mobile_plugin("clip_url", payload)`.
- `ios/Sources/ClipUrlController.swift` — `UIViewController` hosting
`WKWebView` with the loading overlay drawn as native UIKit views
(not an injected user script, so the page's own hydration can't
wipe the spinner). 30 s hard timeout + 3 s settle window after
`didFinish`, same as desktop. Fingerprint mask injected as
`WKUserScript` at `.atDocumentStart`.
- `android/src/main/java/ClipUrlController.kt` — full-screen Dialog
hosting a `WebView`, mirrors the iOS controller's behaviour. JSON-
decodes the `evaluateJavascript` callback (raw return value is a
JSON-encoded string).
- `NativeBridgePlugin.{swift,kt}` — new `clip_url` method that parses
args via `invoke.parseArgs`, presents the controller, resolves the
invoke with `{ html }` on success or `invoke.reject` on failure.
Same rejection vocabulary as desktop (`"Invalid URL"`, `"Page took
too long to load"`, etc.) so the calling JS doesn't need a
platform branch.
- `build.rs` — adds `clip_url` to the plugin's `COMMANDS` array.
Notes
- The Swift overlay reserves the iOS safe-area-edge-to-edge so notch /
Dynamic Island devices don't see the underlying app peek through
during the brief capture window.
- The Android overlay's spinner tint follows the foreground theme
colour at 85 % alpha — same idea as the iOS controller.
- `WKWebView`'s JS keeps running while the controller is presented;
no off-screen / `isHidden` trick that would let iOS throttle the
page mid-capture.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
dabdcdcc53 |
fix(macos): fix traffic lights position on macOS 26 (#4247)
* fix(macos): place traffic lights via Tauri trafficLightPosition
Replaces the cocoa private-API positioning that drove traffic light placement through IPC with Tauri's supported trafficLightPosition window option, which routes through wry's macOS API and stays correct across versions including macOS 26 (Tahoe).
Position is now declared once at window creation: WebviewWindowBuilder.traffic_light_position in src-tauri/src/lib.rs for the initial main window, and trafficLightPosition on new WebviewWindow(...) in utils/nav.ts for reader windows and the recreated main window. The reader path mattered — those windows used to rely on the cocoa hack to place buttons after the on_window_ready hook fired, so any path that bypassed it left the buttons in AppKit's overlay default position (off-screen on macOS 26 until a resize).
The IPC surface narrows accordingly. set_traffic_lights now takes only visible: position is no longer a parameter and the WINDOW_CONTROL_PAD_X/Y static muts go away; setTrafficLightVisibility drops its position arg in trafficLightStore; useTrafficLight and HeaderBar drop their hard-coded { x: 10, y: 20 } magic numbers. position_traffic_lights stops touching the per-button NSWindowButton frames entirely and only collapses or restores the title-bar container view to hide / show buttons during reader chrome auto-hide. A short-circuit on the no-op transition keeps the cocoa setFrame from racing AppKit's own traffic-light tracking on every IPC call.
useTrafficLight stays — it still owns full-screen visibility synchronisation, the auto-hide visibility toggle, and feeds isTrafficLightVisible to the self-drawn <WindowButtons /> in the auth, library, OPDS, reader-sidebar, and user headers. None of those have an equivalent in the new declarative API. Only its 'where do the buttons sit' responsibility was moved out.
A single named constant TRAFFIC_LIGHT_RESTORE_Y_INSET is left behind in traffic_light.rs, used solely by the visible: false → true restore path to recompute the title-bar container height. It must agree with the y component of the two declarative trafficLightPosition values; a doc comment makes that contract explicit. Caching each window's natural title-bar height before the first collapse would let us delete the constant entirely, but the per-window state machine that requires is not worth the win for a single number.
y is tuned by eye to 24 to vertically center the buttons inside readest's ~48px header bar on macOS 26.1.
* fix(macos): center traffic lights from live AppKit offset, no version check
Restores the pre-PR cocoa-driven positioning that worked on macOS 15
while keeping the macOS 26 fix this PR was originally about: the
plugin owns `position_traffic_lights`, which now sizes the title-bar
container *and* sets each window button's frame.origin on every
on_window_ready / resize / theme-change / full-screen-exit event. Tao's
runtime `inset_traffic_lights` never fires (we never declare
`trafficLightPosition` or call `set_traffic_light_position`), so there
is no second code path fighting us on drawRect.
The y inset that visually centers the close button is computed at
runtime as
y = (header_height - button_height) / 2 + button_origin_y
where `button_origin_y` is the close button's natural rest position
inside the title-bar container. Apple shifted that rest position by
~2pt on macOS Tahoe (26), so the same formula yields y=22 on macOS 15.6
and y=24 on macOS 26.1 with a 48px header — no `NSProcessInfo` lookup
and no hardcoded per-OS offset. The natural origin.y is read once and
cached via `OnceLock` so any post-resize autoresize that AppKit might
apply doesn't feed back into the centering math.
Frontend plumbing: `set_traffic_lights` IPC now carries `headerHeight`;
the zustand store remembers it across visibility toggles; the
`useTrafficLight` hook accepts a header ref, mirrors `ref.current`
into local state (so the effect re-runs when LibraryHeader's
conditional render flips the ref from null to the live node), measures
the border-box height on mount, and observes via ResizeObserver to
re-push on responsive breakpoint / safe-area changes. LibraryHeader,
sidebar Header, OPDS Navigation, and the reader HeaderBar each pass
their own ref so y is computed against the chrome each page actually
renders.
Library header is normalised to h-[44px] desktop to match the reader's
h-11 and drops the `-2px` macOS marginTop workaround, since the runtime
centering removes the need for it.
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
|
||
|
|
1a2e43e659 |
chore(worktree): copy src-tauri/gen/apple per-worktree so iOS builds the worktree (#4251)
Worktrees set up by `pnpm worktree:new` symlinked `src-tauri/gen/apple` back
to the bare repo. The Xcode "Run Script" build phase
(`pnpm tauri ios xcode-script`) walks up from `Readest.xcodeproj` to find
`Cargo.toml`, but the symlink target resolves to the bare repo's
`src-tauri` — so `pnpm tauri ios dev` from a worktree silently built the
bare repo's Rust source, not the worktree's.
Switch to a filtered copy. `project.yml` references `../../src` and the
xcode-script walks up to `../../Cargo.toml`; with the project copied into
the worktree, both paths resolve to the worktree's source. Mirrors what the
script already does for Android (`tauri android init` per worktree).
Skipped on the copy:
- `build/` (~300 MB of Xcode derived output)
- `Externals/<arch>/{debug,release}/` (Rust static libs — rebuilt from the
worktree's `src-tauri` on first build; the `target/` symlink the script
already sets up keeps the Rust object cache shared)
- per-user Xcode state (`xcuserdata`, `*.xcuserstate`)
- `Pods/` (defensive; Readest's iOS uses SPM, not Cocoapods)
Result: ~2.7 MB copy, ~30 ms locally, no `pod install`, no
`tauri ios init`. Signing config, scheme, asset catalogs and Tauri's
generated Swift glue all preserved verbatim from the bare repo.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
b493cf7908 | chore(deps): bump the github-actions group with 4 updates (#4249) | ||
|
|
a1279a65ce |
feat(send): clip web URLs into self-contained EPUBs via Tauri webview (#4241)
Builds the URL-clipping path of the "Send to Readest" feature: paste a
link, the renderer ingests the rendered page, and a self-contained EPUB
lands in the library. No server proxy, no external CDN refs left in the
EPUB once it's saved.
Architecture
- New Rust `clip_url` command spawns a hidden Tauri WebviewWindow at the
target URL with a real Chrome UA + WebKit fingerprint mask, so TLS-
fingerprint and JS-challenge walls (Cloudflare, Medium, X, WeChat MP)
resolve naturally instead of bouncing the server proxy.
- Capture transport is URL-payload navigation to a one-shot
127.0.0.1:RANDOM_PORT/clip/{token}?d={url-safe-base64} listener.
Top-level navigation isn't governed by CSP connect-src / form-action /
WebKit Private Network Access — the four earlier transports
(fetch, <form>, custom URI scheme, window.name) were each blocked by
one of those.
- Page-to-EPUB bundler (`assetBundler`) walks <img>/<picture> with
src → data-src → data-original → data-srcset → srcset fallback so lazy-
loading sites don't ship a 60px LQIP; fetches assets in parallel with a
per-asset timeout + per-asset/total caps; failed images degrade to alt-
text placeholders. A per-site rules table (seeded with WeChat MP) + a
selector fallback catches articles Readability misextracts. Builder
prepends the article <h1> + byline so the EPUB has a proper opening.
- Nested EPUB TOC built from h1–h6.
UI surfaces
- "From Web URL" entry in the library Import menu, gated to Tauri; web
build hides the URL field and points at the browser extension.
- `ImportFromUrlDialog` with auto-height (overrides Dialog's `sm:h-[65%]`
default) and a dim placeholder for the URL field.
- Clip webview window styled to match Readest's main window — macOS
decorations + overlay title bar; other desktops decorationless with a
drop shadow; native background + in-page loading overlay pick up the
caller's `themeCode.bg`/`fg` so light/dark/eink/custom themes all
render correctly. Title localised, all five overlay/title strings
translated across 33 locales.
Notes
- Gates the macOS traffic-light positioner to main/reader-* windows so
the decorationless clip window no longer null-derefs in
`position_traffic_lights`.
- Stricter validation across the path: schemes restricted to http/https,
hex-color parsing rejects malformed values, server endpoint returns
400 on missing/invalid base64.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
3825f355a7 |
fix(sel): clamp declared fontSize when it disagrees with rendered height (#4244)
The macOS system-dictionary HUD samples the underlying paragraph's typography via getRangeTextStyleInWebview so AppKit can re-draw the small label using the same font / size as the page text. The sampler trusted getComputedStyle().fontSize directly, which works for the typical EPUB inline box but breaks badly on pdf.js text layers: each glyph span carries an intrinsic font-size that reflects the document's unit-em size before transform: scale(...) shrinks it back to page- coordinate pixels, so the value can be many times larger than the on-screen glyph. Forwarded as-is to NSFont, that gives AppKit a giant attributed string and the yellow highlight rectangle behind the HUD ends up engulfing neighbouring paragraphs while the laid-out text overflows off-screen. Cross-check the declared size against range.getBoundingClientRect(). height as a sanity bound. When the declared value exceeds the inline box height by more than 30 %, fall back to renderedHeight * 0.85 (roughly the cap-height-to-1.2-line-height ratio) so PDF lookups converge on a sane scale; otherwise keep the declared value untouched so normal EPUB body text is unaffected. |
||
|
|
5ac8564e41 |
feat(library): add Import from Folder dialog with format/size filters (#4229)
* feat(library): add Import from Folder dialog with format/size filters Replaces the silent "import every supported file recursively" behaviour of the directory import menu item with an explicit dialog that lets users pick which formats to include, set a minimum file size, and choose between mirroring subfolders as nested groups (legacy behaviour) or flattening every match into the current library view. The folder, the chosen Folder Structure radio, the ticked File Formats and the File Size threshold are all persisted in localStorage so re-opening the dialog seeds every field with the user's last choice. Cancelling the dialog does not write to storage so an aborted pick won't pollute the next session. Also hides the native number-input spinner via a small .no-spinner utility in globals.css; on macOS WebKit the spin buttons were drawing over the rounded input border and looked broken. The KB suffix now lives inside the input's bordered shell instead of beside it. Two correctness fixes the dialog flow exposed: * The library importer + ingestService now treat groupId as a tri-state — undefined means "don't touch the existing group", '' means "explicitly the library root", any other string means a specific group. Previously a falsy check in both layers conflated '' with undefined, so re-importing a deduped book under flatten mode silently kept its stale groupId/groupName from the prior keep-as-groups run, making the book reappear in the old subfolder group instead of moving into the library root. New regression tests in ingest-service.test.ts cover both the empty-string case and the omitted case. * Imports of arbitrary user paths (e.g. ~/Downloads) now go through a new allow_paths_in_scopes Tauri command that extends both fs_scope and asset_protocol_scope. The dialog plugin only auto-grants fs_scope, so reads through the asset protocol (RemoteFile / convertFileSrc) used to fail with "asset protocol not configured to allow the path". The shim is invoked after every selectFiles / selectDirectory call and once more at the start of runFolderImport so localStorage-restored paths are also covered. Granted scopes persist across restarts via tauri_plugin_persisted_scope. * fixup(library): harden Import-from-Folder scope grant + RTL/dialog polish Three review fixes on top of the Import-from-Folder feature: * lib.rs: refuse to extend asset_protocol_scope for paths not already in fs_scope. Without this gate, any frontend code (XSS via book content, OPDS HTML, dictionary lookups, or a compromised dependency) could call allow_paths_in_scopes with '/' or '~/.ssh' and gain persistent read access to arbitrary user files via the asset protocol — the grant survives restarts thanks to tauri_plugin_persisted_scope. Mirrors the defensive check in dir_scanner.rs. * ImportFromFolderDialog.tsx: migrate from a custom ModalPortal chassis to the project's shared <Dialog> primitive so eink mode auto-removes shadows, mobile gets the bottom-sheet treatment, RTL direction is applied, and focus management is correct. * ImportFromFolderDialog.tsx: swap directional Tailwind utilities for the logical equivalents (text-start, ps-/pe-, rounded-s-, text-end) per DESIGN.md §2.8 — Arabic/Hebrew users were getting a mirrored number-input row with the KB suffix on the wrong side. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * i18n(library): translate Import-from-Folder dialog strings across 33 locales Translates the 13 new strings introduced with the Import-from-Folder dialog (folder picker label, format-filter section, size-threshold input, folder-structure radios, OK button, empty-result toast). All 33 supported locales — including RTL fa/he/ar — are now complete; no __STRING_NOT_TRANSLATED__ placeholders remain in the catalog. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Huang Xin <chrox.huang@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
ff4c03919b |
refactor(alert): stack title above actions row to fix narrow-width layout (#4239)
Confirm Deletion (and the three other Alert callsites — clear annotations, delete files, book-detail delete) used to try to keep the icon/title/message and the Cancel/Confirm buttons in a single row. At narrow widths the row would flex-wrap and produce a cramped two-column shape with stacked buttons next to wrapped text — the case shown in the original PR thread's third screenshot. Rework the layout to always stack: * outer container is now `flex flex-col gap-3` instead of toggling between flex-row at sm+ and flex-col below. * top block: icon + title/message, items-start (icon nudged with `mt-0.5` so it baselines with the title). * bottom block: `flex items-center justify-end gap-2` — Cancel + Confirm always right-aligned on their own row. * Drop the daisyUI `alert` class. Its `display: grid` + `justify-items: center` was collapsing the actions row to content width and pulling it toward centre, which defeated `justify-end` the first time around. The styles I actually wanted (`bg-base-300 rounded-lg p-4 shadow-2xl`) were already explicit. * Replace the chain of viewport-relative max-widths with the more conventional `max-w-md sm:max-w-lg md:max-w-xl` cap so the capsule doesn't grow without bound on big monitors. * Drop the `text-center` flip — text stays left-aligned at every width, which matches the rest of the app. Color theme unchanged: blue `stroke-info` icon, `bg-base-300` surface, `btn-neutral` Cancel, `btn-warning` Confirm, `btn-sm` sizing. `useKeyDownActions` keyboard binding and `role='alert'` preserved. No callsite changes — the four consumers keep the same props. Verified visually at 1400 / 900 / 520 / 500 px viewports via `pnpm dev-web`; `pnpm test` (4389 passed) and `pnpm lint` (tsgo + biome) clean. 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>
|
||
|
|
7230981288 |
feat(send): auto-seed the allowlist with the user's account email (#4237)
The Email Worker rejects mail from senders that are not in the user's
approved-sender allowlist. With an empty allowlist the very first send
("email it to yourself") bounces with an approval-pending notice, which
is the wrong first impression for the feature.
Seed the caller's verified account email as `approved` the moment we
lazily create the user's send_addresses row. Best-effort: address
creation still succeeds if the seed insert fails (idempotent via the
existing UNIQUE (user_id, email) constraint).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
a30efe49c1 |
fix(send): make recent-activity status labels translatable (#4236)
The labels (Added to your library / Waiting to be processed / Processing… / Failed) went through `_(activityStatusLabel(item.status))`, a dynamic key the i18next-scanner cannot extract — so non-English locales rendered them in English. Inline the four literal `_()` calls into the JSX so the scanner picks them up. Translates the two missing keys in all 33 non-English locales. Also sweeps three pre-existing untranslated System Dictionary keys that were introduced in #4219. 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> |
||
|
|
97221b8d23 |
fix(ios): suppress native text-selection menu over annotation tools (#4231)
On iOS the system text-selection menu (Copy / Look Up / Translate /
Share) appeared on top of Readest's annotation toolbar. The previous
workaround removed and re-added the selection range on a timer
(makeSelectionOnIOS) to shake the menu off — flaky on iOS 16 and on the
first long-press of a word.
Suppress the menu natively instead, in the native-bridge iOS plugin.
ContextMenuSuppressor swizzles WKContentView so non-editable web
selections produce an empty menu that is never presented:
* editMenuInteraction(_:menuForConfiguration:suggestedActions:) — the
UIEditMenuInteraction delegate WebKit uses to build the menu on
iOS 16+ (the menu users actually see on modern iOS).
* presentEditMenu(with:) — a present-time backstop.
* canPerformAction(_:withSender:) — the legacy UIMenuController gate
for iOS 15 and earlier.
Editable HTML fields keep their native menu (Paste / Select All still
work) via a cut:/paste: probe. Text selection and drag handles are
unaffected, so the annotation toolbar still triggers.
With suppression handled natively, makeSelectionOnIOS is removed and iOS
selections take the same path as desktop.
Closes #4218
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
088f690c35 |
ci(release): use cargo tauri CLI for Linux bundler (#4225)
The release workflow installs the Rust-based tauri-cli from the feat/truly-portable-appimage branch for Linux builds, but the tauri-action step had no tauriScript input. Without it, tauri-action falls back to the npm @tauri-apps/cli, so the custom truly-portable AppImage bundler was never actually used. Set tauriScript to `cargo tauri` for the Linux matrix entries so the just-installed Rust CLI is used. macOS/Windows resolve to an empty string and keep using the npm CLI as before. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d25c41ee89 | chore(security): address code scanning findings (#4224) | ||
|
|
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. |
||
|
|
d35d2002c4 |
chore(security): add Scorecard workflow for supply-chain security (#4221)
* chore(security): add Scorecard workflow for supply-chain security * chore: ignore prettier on .github |
||
|
|
5688687011 |
perf(ci): cache Playwright browsers and apt packages in PR checks (#4215)
* ci(e2e): cache Playwright browsers and apt packages - cache `~/.cache/ms-playwright` keyed on the lockfile; on a hit only the OS deps are installed, skipping the browser download - cache apt archives in test_web_app, matching the rust/tauri jobs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: enable Turbopack persistent cache and key it for cross-PR reuse Enable `experimental.turbopackFileSystemCacheForBuild` so `next build` persists a real Turbopack cache (~640 MB at `.next/cache/turbopack`), instead of the ~340 KB of metadata the previous `.next/cache` cache held. Dev caching is already on by default in Next 16.1+. Redesign the cache keys so they actually pay off: - drop `${{ github.sha }}` from the key — it made every commit a unique entry that no other PR could exact-hit. The key is now `turbo-<mode>-<target>-<os>-<lockfile-hash>`, deterministic across branches, so every PR restores the same entry (in practice the one `main` last saved — the only cache sibling PRs can all see). - `build_web_app` (`next build`) caches `.next/cache`; `build_tauri_app` (`next dev`) caches `.next/dev/cache` — `next dev`'s Turbopack cache lives in a different directory. - drop the Next.js cache step from `test_web_app`; it runs no build. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
28a7785e5d |
test(e2e): add a Playwright web e2e lane (reading & annotation flows) (#4214)
* feat(e2e): add Playwright web e2e lane Adds a web-layer end-to-end suite that drives the Next.js web build (`pnpm dev-web`) in a real browser, complementing the existing WebdriverIO suite that drives the Tauri shell. - playwright.config.ts: single Chromium project, auto-starts dev-web - e2e/pages: BasePage/LibraryPage/ReaderPage page objects - e2e/fixtures/base.ts: suppresses demo-book auto-import for a deterministic empty library - e2e/tests: library shell + search, book import, reader open + pagination smoke specs - e2e/fixtures/books: synthetic sample book for import tests - scripts: test:e2e:web, test:e2e:web:ui, test:e2e:web:report Tests run unauthenticated against isolated browser contexts; authenticated/sync flows are out of scope until a test account is provisioned. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(e2e): cover reading and annotation flows Expands the Playwright web e2e lane beyond library/import smoke tests to exercise the major reading and annotation features against the real sample-alice.epub fixture (src/__tests__/fixtures/data/). Reading (reading.spec.ts): open + page turn, TOC chapter navigation, in-book search, font-size change via the settings dialog, bookmark toggle. Annotation (annotation.spec.ts): selection popup, create highlight, change highlight color, add a note, delete an annotation. - ReaderPage POM gains sidebar/TOC, search, settings, bookmark and annotation actions; text selection is driven inside the section iframe (synthetic drags do not produce a selection through nested paginated foliate iframes) - openBook fixture imports and opens a book so specs skip boilerplate - books.ts centralises fixture book paths - replaces the old reader.spec.ts smoke Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(e2e): add headed run script and always write HTML report - test:e2e:web:headed runs the suite in a visible browser, one test at a time, with traces captured - the HTML reporter now runs for local runs too, so every run writes playwright-report/ for test:e2e:web:report to open Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(e2e): fix headed-run flakes in reading and annotation specs The headed run (slower rendering) surfaced two races that the headless run happened to pass: - TOC navigation read reading progress before the section's async progress update landed — now polls with expect.poll. - visibleSectionFrame required a paragraph fully inside the viewport, which intermittently matched nothing — now accepts any paragraph intersecting the viewport and tolerates frames detaching mid-navigation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: run the Playwright web e2e suite in test_web_app Adds `pnpm test:e2e:web` to the test_web_app job, after the unit/browser tests. The job already installs the Chromium browser, and `.env.web` is committed so the auto-started `pnpm dev-web` server has its config. On failure the HTML report is uploaded as an artifact. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(e2e): exclude e2e specs from the vitest run vitest's default glob matches `*.spec.ts`, so it picked up the new Playwright `e2e/tests/*.spec.ts` files and crashed. Exclude `e2e/` from vitest — those specs run via `pnpm test:e2e:web`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(e2e): run the web e2e suite against a production build `next dev` renders a full-screen error overlay when the app emits its `next-view-transitions` "Transition was aborted" unhandled rejection, and the overlay intercepts pointer events — making the suite flaky on CI. CI now builds the web app (`pnpm build-web`) and the Playwright webServer serves it via `pnpm start-web`; local runs still use `pnpm dev-web`. Verified: 14/14 pass against the production build. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(e2e): run the web e2e suite in the build_web_app job build_web_app already runs `pnpm build-web`, so the e2e suite belongs there — it reuses that build (the CI Playwright webServer serves it via `pnpm start-web`) instead of building a second time in test_web_app. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(e2e): run the web e2e suite with 4 workers Specs are isolated (a fresh browser context per test), so they are safe to parallelize. `test:e2e:web:headed` keeps --workers=1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
f2a2d96938 |
fix(koplugin): honor remote annotation deletions, closes #4119 (#4194)
Pull skipped notes carrying a deleted_at tombstone but never removed the matching local annotation. A highlight deleted on Readest therefore lingered in KOReader, and a later push (notably a full sync) re-uploaded it, resurrecting the note on the server and making it reappear on every device. Add removeDeletedAnnotations, invoked at the start of the pull callback, to drop local annotations the server has tombstoned. Tombstones are matched by stored id, by the hash-derived id for native KOReader highlights, or by position/page xpointer. 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> |
||
|
|
411d3ad687 |
fix: export annotations even without TOC, closes #4186 (#4188)
* i18n(ios): add more localized languages in plist * fix: export annotations even without TOC, closes #4186 |
||
|
|
d0464c1031 | i18n(ios): add more localized languages in plist (#4187) | ||
|
|
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> |
||
|
|
1a3d393e74 |
feat(reader): add "Clear Annotations" entry to the book menu (#4175)
Adds a "Clear Annotations" item to the book menu. Picking it opens a confirm dialog and, on confirm, soft-deletes every type='annotation' booknote on the active book by stamping deletedAt, removes overlays from live views, persists via saveConfig, and resets sidebar browse state. Bookmarks and excerpts are untouched. The dialog lives in Annotator (per-book, long-lived) and is wired up via a new 'clear-annotations' event so it survives the dropdown menu unmounting. |
||
|
|
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> |