* fix(kosync): send and read FB2 positions in crengine's FictionBook form
Readest sent KOReader an EPUB-style /body/DocFragment[N]/body/... XPointer
for FB2 books. crengine reads FB2 natively and builds no DocFragments, so
it couldn't resolve the pointer and KOReader went to the cover. The other
direction rejected KOReader's /FictionBook/... pointers and fell back to
the percentage.
xcfi now maps FB2 both ways: foliate's section for each child of the first
<body> (image, title, epigraph, section) to /FictionBook/body[1]/<tag>[N],
counted per tag the way crengine does, each notes body to body[N], and
foliate's renamed elements back to their FB2 tags (kept as each element's
first class). The push sites (KOSync, Readest progress and notes sync,
BookOrbit notes) pass the book format; range note pulls go through the new
getCFIFromXPointerRange so FB2 highlights from KOReader resolve too.
isXPointerProgress accepts /FictionBook pointers.
Verified against crengine itself: xpointer-oracle.lua (now FB2-aware) run
in the KOReader emulator on a new FB2 fixture dumped all 108 visible words;
xcfi.crengine-oracle.test.ts converts every word both ways exactly as
crengine writes it. Without the change xcfi.fb2.test.ts fails with
"expected '/body/DocFragment[4]/body/section/p[1]/text().4' to be
'/FictionBook/body[1]/section[1]/p[1]/text().4'", the notes-body pull
throws "Unsupported format for spine index extraction", and
kosyncProgress.test.ts fails with "expected false to be true". Related
xcfi, KOSync, progress, notes and BookOrbit suites pass (333 tests);
pnpm lint passes. Not run: a KOSync round trip on a device, and the
reporter's book.
Reported by email to support.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(kosync): cover one-body FB2 books and a leading body-level image
CREngine in the KOReader emulator writes /FictionBook/body/... (no index)
for an FB2 book with one body, and /FictionBook/body[1]/... when it also
has a notes body. Either form resolves in either book. It puts a leading
body-level image at /FictionBook/body/image.0.
A new one-body fixture (an image and a title before two sections) and its
oracle dump (19 words) add the following checks:
- every word converts both ways exactly as CREngine writes it;
- Readest writes body without an index for the one-body book;
- the leading image is /FictionBook/body/image in the one-body book and
/FictionBook/body[1]/image in the book with notes;
- /FictionBook/body/image.0, /FictionBook/body[1]/image.0 and
/FictionBook/body[1]/image[1] open section 0 in both books;
- both body forms open the same paragraph in the one-body book.
The code already did all of this, so nothing fails on this branch. With
the body step forced to body[1], three tests fail, e.g. expected
"/FictionBook/body/section[2]/p/text().2", received
"/FictionBook/body[1]/section[2]/p/text().2", and the oracle reports
"Readest -> KOReader: 19/19 mismatches".
Related xcfi, KOSync, progress, notes and BookOrbit suites pass
(164 tests), and pnpm lint passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Opening some books showed "Failed to execute 'getComputedStyle' on
'Window': parameter 1 is not of type 'Element'" from the handwriting
overlay; Back or Try again then opened the book. The ink anchoring
effect runs on the first location, and a section is XHTML parsed as it
streams in: while it still loads, its <html> root exists but <body>
doesn't yet. createInkAnchoring only checked for the root, then read the
body's computed style. It now waits for the body too, like the rootless
case from #6758; the effect runs again on the next relocate.
createInkAnchoring is the only path from the overlay into the section
document's body (all three useHandwriting callers go through it).
Verified:
- inkAnchoring.test.ts "returns null while the section document has no
body yet" fails without the change with "TypeError: The provided value
is not of type 'Element'", and passes with it.
- pnpm dev-web + Playwright Chromium, the reporter's FB2 imported into a
fresh profile: without the change, two of two opens show the error
page (stack at createInkAnchoring); with it, two of two opens render the
book with no error.
- Related suites (inkAnchoring, HandwritingOverlay, reader hooks: 34
files, 286 tests) and pnpm lint pass.
Not run: Windows/WebView2 itself (Chromium in Playwright instead); a book
with existing ink notes on first open.
Reported by IlyaSofronov on GitHub
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(annotator): sanitize the export preview HTML
The Markdown export preview rendered the parsed Markdown as HTML without
sanitizing it, while book titles, highlighted text and notes (including
imported ones) flow into it. Run it through the same DOMPurify setup the
note list and popup already use.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(reader): sanitize book markup copied into the app window
The table viewer and paragraph mode render markup copied out of the book
document directly in the app window. Sanitize it on the way in with a
shared helper, and only accept open-media messages posted by the reader's
own handlers in the app window.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(reader): keep data: URLs out of links in book fragments
sanitizeBookFragment allowed data: URLs on every URI attribute, so a
data:image/svg+xml link in a table or paragraph-mode clone survived into
the app window. The table viewer doesn't intercept link clicks and the
main window's navigation guard lets data: through, so a tap could
replace the app with the book's document. Fragments now use the book URI
allowlist without data:, and DOMPurify's own rule keeps data: URLs only
on media tags (img, SVG image, source, audio, video, track). Book
documents keep data: links: they render in the book iframe, where
foliate intercepts link clicks.
Verified: book-fragment-sanitizer.test.ts "keeps data: URLs on images
but not on links" fails without the change with "expected true to be
false" (the anchor kept its data: href) and passes with it. Sanitizer,
transformer, TableViewer and paragraph-mode suites pass (8 files, 179
tests); pnpm lint and Biome format pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The sidebar writes its tab to the book config only, while Read Aloud
saves each sentence's location through setViewSettings, which replaced
a primary view's config viewSettings with the live copy and its stale
sideBarTab. Choosing Annotations during playback snapped back to the
table of contents on the next sentence. Any other live settings save
(toggling scroll mode, for one) did the same. setViewSettings now keeps
the config's sideBarTab, the only copy the sidebar reads or writes.
Verified: reader-store.test.ts "keeps the sidebar tab chosen in the
book config when a primary view saves settings" fails without the
change (expected 'toc' to be 'annotations') and passes with it; the 23
test files touching setViewSettings or sideBarTab pass 534/534; pnpm
lint is clean. In the web build (pnpm dev-web, Chromium, fresh profile):
pick Annotations, then Shift+J; on main the sidebar returns to the
table of contents, with the change it stays on Annotations. Read Aloud
itself was not played (no TTS in headless Chromium).
Reported by CaFeZn on GitHub (#6836).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(library): park a finished book as unread when an updated build is re-keyed
Importing a different build of a book already in the library re-keys the
row to the incoming file's hash (#5959), and readingStatus travels
through untouched: a book finished under the old build keeps its
Finished badge under the new one, whose content was never opened.
Nothing ever demotes it - the reader's automatic status only upgrades
(unread clears on first open, >=100% sets finished) - so the stale badge
persists until a manual "Mark as Unread".
Finished is the one status that asserts about the retired file's content
("read to the end of those bytes"), and importing another build of a
finished book means starting that build. So on a re-key whose hash
actually changes, flip finished to unread: the same field-only park as
the manual action, since the merged config still carries the reading
position, notes and paired audio - opening the new build resumes where
the old one left off, and the first relocate rewrites progress against
the new layout. readingStatus merges at sync peers by its own field LWW
timestamp, so the flip stamps readingStatusUpdatedAt.
Scoped deliberately:
- Only finished flips. reading/abandoned/unread assert intent, not
content extent, so a mid-read book re-keyed by a cosmetic rebuild
keeps its still-accurate percentage and its spot on the reading shelf.
- A same-bytes reimport resolves byHash and never reaches the re-key
branch, so re-adding the identical file keeps its finished mark.
The tombstone row is snapshotted before the flip, so the retired hash
keeps the pre-import status, and a failed import rolls the row back from
the rememberBookState snapshot, flip included.
* fix(library): stamp the re-key status flip strictly ahead of the retired status
CodeRabbit Minor on #6871: readingStatusUpdatedAt may already sit ahead
of this device's clock (the finished status was stamped on a peer), so
parking the re-keyed row unread with a plain Date.now() gives the flip
an older timestamp than the retired status. Both field-level merges then
restore finished on the next sync: the file-sync merge adopts the remote
status only on a strict > (sync/file/merge.ts), and the cloud pull's
pickFresherReadingStatus hands the win to the strictly fresher stamp.
Take max(prev + 1, now): the normal case still stamps fresh, and a
future-dated retired status is beaten by exactly one tick. Covered by a
test importing a build over a future-dated finished status.
* docs(library): document importBook's dedup and re-key contract
CodeRabbit's pre-merge docstring-coverage check fails on this PR: it
scopes coverage to functions the diff touches, and the one it counts -
importBook - carries no function-level docstring. Summarize the identity
and dedup contract where a reader lands first: exact-hash refresh,
metaHash/stableHash re-key with config migration and the retired-hash
tombstone, and the finished-to-unread park.
* fix(reader): normalize section media types before sanitizing
Book manifests declare media types by hand. Compare the bare lowercase
type in the reader's content transform, so casing or parameters no longer
decide whether an XHTML or SVG section is sanitized, and serve spine
sections of any type the sanitizers don't handle (generic XML, missing or
unknown types) as plain text instead of letting them render as documents.
Bumps foliate-js to normalize manifest media types at parse time.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(reader): serve +xml section types as plain text
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Reviews the 19 commits from 3cc39ad7a to 61ef15e00. Nothing was reverted,
reopened or fixed again, and CI was green and finished before every merge.
- New: test a selector or matcher against inputs, not its own text (#6828).
- New: keep a fix in shared rendering code to the reported trigger
(#6712, #6797), seen on a second day.
- New: replies post as the maintainer, so never mention "the maintainer"
(three Reply Desk edits on 10-14). The triage skill's verdict table that
asked for that wording is reworded.
- Seen again: runnable real-use checks listed as "not run" (#6791, #6801).
- Promoted rule, new evidence: community PRs #6799, #6830, #6833, #6834 and
#6835 merged with no record of what ran.
- Done right: #6813 (Nix hashes in the PR), #6828 (fixed the sibling
selectors too).
Checked: no behaviour change (agent files only). Every sha and PR number
cited was looked up on main or GitHub; Reply Desk wording was compared
draft against edited.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(translate): validate DeepL requests and check quota per request
Validate that `text` is an array of strings and cap a request at 100 items
and 50,000 characters. Read the cache first, then check the daily quota
against the whole request's uncached characters before any upstream call,
instead of checking each item against the same starting usage in parallel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ai): require the caller's gateway key on the AI proxy routes
The chat and embed routes fell back to a server-side AI Gateway key when the
request carried none. Every client already sends the user's own key, so
drop the fallback and its env examples, and cap request size (500 messages /
500k chars for chat, 100 texts / 500k chars for embed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ai): reject a non-string system prompt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(epub): bound native zip entry reads during import
The native EPUB parser sized its read buffer from the entry size declared
in the zip header and read the entry to the end with no limit. Cap both:
the buffer is sized from the declared size clamped to a 128 MiB per-entry
limit, and the read stops past that limit with an error, so a malformed
archive fails the import instead of exhausting memory.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(epub): grow zip entry buffers with the bytes read
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(desktop): drag the window from an unfocused title-bar search field
The library and OPDS search fields fill most of the title bar and were
excluded from window dragging, so on a wide window only a narrow gap
beside the buttons moved it. While the field isn't focused, a press that
moves more than 4px now drags the window through the platform's usual
path (startDragging, or the pointer-driven move on Linux CEF); a click
without movement focuses the field as before, and once it is focused a
drag selects text as usual. Touch input is unchanged.
Verified:
- WindowButtons.test.tsx: "drags the window from an unfocused search
field once the pointer moves" (expected true to be false: the press
wasn't taken), "focuses an unfocused search field on a click without
dragging" (focus stayed on body) and "moves the window by pointer from
the search field on Linux CEF" (startPointerWindowMove called 0 times)
fail without the change and pass with it; 9/9 in the file.
- WindowButtons, HeaderBar, app/library and app/opds suites: 68 files,
491 tests pass. pnpm lint and pnpm format:check pass.
- Linux CEF debug build (Oct 13 binary, this worktree's frontend,
isolated HOME), synthetic CDP mouse events on the library search
field: a drag on the unfocused field moved the window 90px and left
it unfocused, a click focused it without moving the window, and a
drag in the focused field didn't move the window; same in four runs.
One earlier run moved the window on a plain click and didn't focus;
it didn't recur after a reload or in three back-to-back runs.
- Not run: macOS and Windows (startDragging now starts after the
pointer moves instead of on the press), a real mouse.
Requested by the maintainer on #6874, reported by viasux on GitHub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(desktop): keep the arrow cursor on a search field that drags the window (owner: "keep the cursor not an input cursor shape 'I' when dragging at the input box")
While the title-bar search field isn't focused it drags the window, but
daisyUI's .input gave it the text cursor, so a drag showed an I-beam. An
unfocused window-drag-input now shows the arrow, like the bar around it;
once focused for typing it shows the text cursor again. Styling it by
focus state rather than toggling it during the press keeps it right on
macOS/Windows too, where the native drag can swallow the mouseup.
Verified:
- window-drag-input-cursor.browser.test.tsx "shows the arrow, not the
text cursor, while the field drags the window" fails without the rule
(expected 'text' to be 'default') and passes with it; the focused case
passes before and after.
- WindowButtons, app/library, app/opds suites: 67 files, 484 tests;
HeaderBar + cursor browser tests: 12 tests; pnpm lint and
pnpm format:check pass.
- Not run: the CEF app or macOS/Windows with a real mouse.
Requested by the maintainer on #6874, reported by viasux on GitHub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(desktop): show the arrow on a search field only where it drags the window
The arrow cursor on an unfocused window-drag-input applied everywhere the
class is set, but the field only drags the window while WindowButtons
has installed its title-bar listener (Tauri with a window bar). On web,
on iOS/Android with a mouse, and in library select mode (which unmounts
WindowButtons), the field showed the arrow but a press just focused it.
WindowButtons now marks its header with data-window-drag while the
listener is installed, and the cursor rule applies only under it.
Select mode keeps main's behavior: no title-bar drag.
Verified:
- WindowButtons.test.tsx "marks the header as draggable only while its
drag listener is installed" fails without the change (expected false
to be true) and passes; window-drag-input-cursor.browser.test.tsx
"keeps the text cursor where nothing drags the window" fails without
it (expected 'default' to be 'text') and passes.
- WindowButtons, app/library, app/opds: 67 files, 485 tests; HeaderBar
and cursor browser tests: 13 tests; pnpm lint, pnpm format:check.
- Not run: the CEF app, macOS/Windows.
Found by CodeRabbit's review of #6893.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(desktop): drop a search-field press whose release was missed
A press on the unfocused title-bar search field waits for the pointer to
move past 4px before dragging the window. If the mouseup was missed
(released outside the window), its listeners stayed and the next move
past the threshold started a window drag with no button down. A move
with the primary button up now ends the press.
Verified:
- WindowButtons.test.tsx "drops a press on the search field whose
release was missed" fails without the change (startDragging called 1
time) and passes with it.
- WindowButtons, app/library, app/opds: 67 files, 486 tests; HeaderBar
and cursor browser tests: 13 tests; pnpm lint, pnpm format:check.
- Not run: the CEF app, macOS/Windows.
Found by CodeRabbit's review of #6893.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(desktop): drop a pending search-field press when the window loses focus
If the window loses focus mid-press on the title-bar search field (e.g.
Alt+Tab), the release can be swallowed, and a later press made elsewhere
that comes back into the window with the button down reached the stale
listener and dragged the window. A window blur now ends the press.
Verified:
- WindowButtons.test.tsx "drops a pending search-field press when the
window loses focus" fails without the change (startDragging called 1
time) and passes with it.
- WindowButtons, app/library, app/opds: 67 files, 487 tests; HeaderBar
and cursor browser tests: 13 tests; pnpm lint, pnpm format:check.
- Not run: the CEF app, macOS/Windows.
Found by CodeRabbit's review of #6893.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tts): keep a note's focus when a navigation ends during Read Aloud
The reporter found two paths the note-editor guard did not cover: a
navigation already under way when the note was focused, and Read Aloud
reading on into the next chunk of a large section. Both ended in the
paginator handing focus back to the book. Moves foliate-js to the fix
that leaves focus in a text field when a navigation ends.
Reading into the next chunk still moves the page, since that chunk's
text exists only once it is rendered.
Test that fails without it: paginator-focus-text-field.browser.test.ts,
"leaves focus in a text field focused while moving to a loaded section"
and "... when navigating to an anchor" (expected the textarea, got
foliate-paginator). The no-text-field case still focuses the book. The
src/__tests__/document browser suite (212 tests) and the PR's unit
tests pass.
Reported by CaFeZn on GitHub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(tts): assert the target section is loaded before the loaded-view case
Review follow-up: if the preload polling ran out, goTo would take the
section-load path and the loaded-section test could pass without
exercising it. The helper now fails instead.
Verified: with foliate-js at f03c24f the loaded-section and anchor cases
still fail on the focus assertion (expected <foliate-paginator> to be
<textarea>), so the section was loaded; at 18a5df0 all 3 pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(deps): move foliate-js to the merged readest/foliate-js#132
foliate-js squash-merged #132 as 6d74298. That commit also carries
readest/foliate-js#131 (skip an unparsable display-options file), which
landed on foliate-js main after this branch's base.
Re-ran at 6d74298: paginator-focus-text-field.browser.test.ts 3/3,
src/__tests__/document browser suite 38 files / 212 tests, and the
foliate-*.test.ts unit tests 20 files / 105 tests, all passing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
In a CBZ or fixed-layout EPUB in vertical scroll or Webtoon Mode, nothing
brought the toolbar back on a touch screen. A center tap on a fixed-layout
page image opens the image viewer (since f1d17bcf6), and the title, the
other tap target for the toolbar, hides whenever a fixed-layout page runs
past the viewport (adf3b9521), which it always does in scrolled flow.
When the title is hidden for that reason (the same isPageOverflowing check
BooksGrid uses, horizontal writing only), the center tap toggles the
toolbar again. It covers a zoomed paginated page that pans vertically too.
A fixed-layout page that fits keeps its visible title and its center tap
still opens the viewer; reflowable books are unchanged.
Verified:
- usePagination-mediaTap.test.ts: "a scrolled comic page toggles the
toolbar instead of opening the viewer" and "a zoomed page that pans
vertically toggles the toolbar" fail without the change (expected
setHoveredBookKey to be called with 'book-1') and pass with it; 12/12.
- Related suites (usePagination*, iframeEventHandlers, pageOverflow):
88 passed. pnpm lint clean.
- dev-web with a generated CBZ, Playwright Pixel 7 touch preset: Webtoon
Mode center tap opened the image viewer before, shows the toolbar after;
paginated mode still opens the viewer with the title visible.
Not run: an Android device or emulator build.
Reported by nickipp on GitHub (#6866).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The reader takes the underline off links (and underlines them in E-ink
mode) with an `a:any-link` rule added after the book's CSS. It has the
same specificity as a book's `a:hover`, so a hover underline the book
asked for never showed unless it was marked !important. The underline
default now leaves hovered links alone, wrapped in :where() so its
specificity is unchanged and every non-hovered link resolves as before.
The link color rule is untouched.
Test that fails without it: link-hover-style.browser.test.ts, "a book's
a:hover underline applies on hover" (expected 'none' to be
'underline'). "a more specific book link rule still wins when not
hovered" fails if the rule loses :where() (plain :not(:hover)). The
other style tests pass (275 unit, utils browser suite).
Reported by openlayers0 on GitHub.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
transformStylesheet swaps hardcoded black text for the theme's text
color, but its `#000` pattern also matched the start of longer hex
values: `a { color: #0000FF }` became `color: var(--theme-fg-color)0FF`,
which is invalid when computed, so the links fell back to the inherited
text color and showed black. Navy (#000080) and other #000-prefixed
colors went the same way, while #00FFFF survived. Match the whole hex
value instead: #000, #000f, #000000 and #000000ff are still replaced,
anything longer is left as the book wrote it.
Verified:
- style.test.ts "keeps a color that only starts like black" fails
without the change (expected 'a { color: var(--theme-fg-color)0FF; }'
to contain 'color: #0000FF;'), passes with it; "replaces black written
with an alpha digit" also fails without it.
- style, style-get-styles, style-dom (277) and transformers (107)
suites pass; pnpm lint passes.
- Reporter's sample on dev-web (Playwright Chromium, light theme,
Override Book Color off): links computed rgb(23, 23, 23) before,
rgb(0, 0, 255) after; dark theme still shows lightblue as before.
- Not run: e-ink mode in the app, Tauri desktop/mobile builds.
Reported by openlayers0 on GitHub (#6858).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(reader): break the page for page breaks in inline styles
In paginated mode each column is a page, and Chromium doesn't break a
column for `page-break-before: always` or `break-before: page`.
foliate-js rewrites those to column breaks in linked stylesheets only,
so a break in a `style` attribute or a `<style>` block was ignored and
the paragraph stayed in the same column. The style transformer now
applies the same rewrite to both. Linked stylesheets are unchanged, and
`page-break-after` keeps its existing margin handling.
Verified:
- inline-page-break.browser.test.ts fails without the change (5 of 6,
"expected +0 to be 1") and passes with it (6/6).
- The reporter's sample on dev-web (Playwright Chromium, two-column
spread): the inline-styled paragraph moved from column 12 (same as the
previous one) to column 13; stylesheet breaks unchanged.
- transformers and style unit suites (219) and pnpm lint pass.
- Corpus of 1878 local EPUBs: 15 documents in 14 books carry such a
break in a style attribute (12 `always`, 6 `avoid`); no other output
changes.
Reported by openlayers0 on GitHub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(reader): rewrite only real page-break declarations in style attributes
Review follow-up. The rewrite also renamed a custom property such as
`--page-break-before`, leaving its `var()` reference dangling, and it
rewrote text that only reads like a style attribute, such as
` style="page-break-before: always"` shown in a <pre>. It now requires a
property-name boundary and touches only style attributes inside opening
tags.
Verified: inline-page-break.browser.test.ts "a custom property named
like a page break is left alone" and "text that reads like a style
attribute is left alone" fail without this change and pass with it
(8/8); transformers and style suites (219) and pnpm lint pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(reader): skip style= text inside another attribute's value
Review follow-up. A tag like <p title="Use style='page-break-before:
always'"> had its title text rewritten although it has no style
attribute. The rewrite now walks each quoted attribute in order and
changes only the one named style.
Verified: inline-page-break.browser.test.ts "another attribute whose
value mentions a style is left alone" fails without this change and
passes with it (9/9); transformers and style suites (219) and pnpm lint
pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(reader): match the page-break property boundary without lookbehind
The boundary added in review used a lookbehind, which the web build
rejects (`check:lookbehind-regex`: build_web_app failed on df14ae23a).
Capture the preceding character instead.
Verified: pnpm build-web then pnpm check:lookbehind-regex prints "No
lookbehind regex found"; inline-page-break.browser.test.ts (9/9),
transformers and style suites (219) and pnpm lint pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Markdown books were parsed without math support, so `$$ ... $$` blocks
and `$...$` inline math showed as raw TeX. They now go through the same
KaTeX setup as annotation notes (MathML output, nonStandard delimiters so
CJK text like `(记为 $N_T$)` renders), and the Markdown sanitizer keeps
the MathML elements KaTeX emits. Other sanitizeHtml callers are unchanged.
Verified: md.test.ts "renders $$ display and $ inline math as MathML"
fails without the change (expected [] to deeply equal [ null, null,
'block' ]) and passes with it; md, md-footnotes, md-frontmatter,
send-conversion suites pass (120); pnpm lint passes. The reporter's text
imported as a .md in dev-web (Chromium) renders every formula.
Reported by netrondong on GitHub.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
With Instant Dictionary on, the lookup drops the popup selection as it
opens and, while it is up, drops any new popup selection and ignores a
cleared one. That guard is for iOS, which re-confirms the dropped
selection on release. On desktop the rest of the footnote popup stays
clickable under the lookup, so double-clicking another word there kept
the first word's definition, and clicking empty space in the popup
never closed it. A new press in the popup now ends the guard: it is the
reader's next gesture, not a re-confirmation. Android, which re-arms
from the native touchstart, is unchanged.
Reported by noideaforeal on GitHub (#6869).
Verified: AnnotatorLookupSurfaces.test.tsx "a word selected in the popup
under the lookup is looked up" fails without the change (expected
'fortune' to be 'lanterns') and "a click on empty space in the popup
closes the lookup" fails (the dictionary surface stays); both pass with
it, as do the iOS re-confirm case and the rest of the file (24). The
annotator, FootnotePopup and footnote-event suites pass (15 files, 158
tests); pnpm lint passes. On dev-web in headless Chromium with a mouse,
an EPUB footnote and Instant Dictionary: on main, double-clicking a
second word in the popup still showed the first word and a click on
empty popup space left the dictionary open; with the change the second
word is looked up and the click closes the dictionary, the footnote
staying open.
Not run: Windows WebView2 itself, the Linux Tauri app, and iOS/Android
touch with this change.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(reader): don't make a table that fits scroll because of a link at its edge
A link's enlarged hit area (an absolutely positioned ::before, 10px past
the link) counts in the table wrapper's scrollWidth. A right-aligned link
in a table's last column, with no right padding, reached 8px past the
wrapper, over the 4px tolerance, so a table that fits its column stayed a
scroll container and showed scroll bars. updateTableFit now treats
overflow no wider than the hit area as fitting when the table's own box
is within the column, and findScrollableBox skips fit wrappers, which
are overflow:visible and can't scroll.
Verified:
- scrollable-link-hit-area.browser.test.ts fails without the change
("table marked fit: expected false to be true", and findScrollableBox
returned the wrapper instead of null) and passes with it; a table 8px
wider than its column still scrolls.
- The reporter's sample in the Linux CEF app (CDP): on main the first
table shows both scroll bars (scrollWidth 517, clientWidth 509, table
509); with the change it is fit, overflow visible, no scroll bars.
Same on dev-web in Chromium (477/469 before, fit after).
- scrollable.test.ts and the getStyles suites (332), the paginator table
browser tests (7), pnpm lint.
Reported by openlayers0 on GitHub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(reader): give the hit-area allowance only to overflow a link causes
Review follow-up. The extra allowance applied to any table with a link,
so overflow from something else in the table (up to 14px) or in a
document without enlarged hit areas also counted as fitting and lost its
scroll bar. It now needs hit areas on and a link whose hit area reaches
past the wrapper's right edge by the overflow.
Verified: scrollable-link-hit-area.browser.test.ts "keeps overflow that
is not a link hit area scrollable" and "gives no hit-area allowance in a
document without enlarged hit areas" fail without this change (table
marked fit: expected true to be false) and pass with it; the #6857 case
still fits (5/5). scrollable and style suites (317), src/__tests__/utils
browser suite (158) and pnpm lint pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(reader): apply the link hit-area allowance at the left edge in RTL
Review follow-up. In a right-to-left table a link flush with the left
edge pushes its hit area past the start of the scroll range, and only
the right edge was checked, so that table kept its scroll bars. The
check now uses the edge overflow can scroll past: the left one in RTL,
the right one otherwise.
Verified: scrollable-link-hit-area.browser.test.ts "does the same for a
link at the left edge of a right-to-left table" fails without this
change (table marked fit: expected false to be true) and passes with it;
6/6, including the LTR case where a first-cell link must not excuse
other overflow. scrollable and style suites (317), src/__tests__/utils
browser suite (159) and pnpm lint pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(reader): follow a link inside a table instead of zooming the table
A tap anywhere in a table opened the table zoom, including a tap on a
link in one of its cells, so the zoom covered the place the link leads
to. A link inside the table is now followed; a tap elsewhere in the
table still zooms it, and a table or image wrapped in a link still zooms
as before (#4757).
Test that fails without it: iframeEventHandlers.test.ts, "reflowable:
tap on a link inside a table follows the link, not the table zoom"
(expected the posted messages not to include iframe-open-media). The
new wrapped-table case and the other 30 tests in the file pass.
Reported by openlayers0 on GitHub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(reader): check a table link's click reaches the link handler
The table-link test only checked that no media message was posted. It
now runs the click through the capture and bubble handlers and checks
neither calls preventDefault or stopImmediatePropagation, so foliate's
own link listener still gets the click.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(reader): open external links with their query string
A link out of the book lost everything after the path: foliate-js
resolved it like a path inside the book, which drops the query, so
"https://duckduckgo.com/?q=Readest+review" opened
"https://duckduckgo.com/". Moves foliate-js to the fix that returns an
external URL unchanged. Reader taps, paragraph mode and footnote popups
all resolve links through it.
Test that fails without it: foliate-epub-encoded-href.test.ts, "keeps
the query and fragment of an external URL" (expected
"https://duckduckgo.com/?q=Readest+review", received
"https://duckduckgo.com/"). The other href tests, book-nav-cache,
paragraph-mode and toc-cfi-mapping pass (77 tests).
Reported by openlayers0 on GitHub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(deps): move foliate-js to the merged readest/foliate-js#130
The submodule pointed at the PR branch head 1d8277378; the squash merge
f03c24f48 has the same tree (git diff empty). The external-link, href,
book-nav-cache, paragraph-mode and toc-cfi-mapping tests pass on it (78).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tts): keep the sentence being read across the paragraph look-ahead
preloadNextSSML steps the live foliate TTS with next() and rewinds with
prev() to gather upcoming paragraphs, and both clear the TTS's current
mark, even when there is no next block to step to. Since the look-ahead
moved to the first event of each utterance, it runs after that
utterance's first mark is set, so the sentence being read stays unknown
until the next mark. A "next sentence" tap in that window (any second
tap in a row, since each tap starts a new utterance) went to the next
block: the next paragraph in most books, and the next chapter in a book
whose chapter is one <div> of <br>-separated text. "Previous sentence"
went back a block the same way. The controller now remembers the mark
it set last and puts it back after the look-ahead when the TTS was on
it, with the highlight suppressed.
Verified: tts-controller-preload-mark.test.ts runs the controller over
the real foliate TTS; its three tests fail without the change (one-block
chapter: expected undefined to be 'Three.'; paragraphs: expected
'Four.' to be 'Three.'; previous sentence: expected undefined to be
'One.') and pass with it. The reporter's EPUB: each chapter is a single
TTS block (808 sentence marks in chapter 3). The TTS service, document
and useTTSControl test files pass 940/940 (13 preload mocks gained the
getLastRange the real TTS has); pnpm lint is clean. Not run: Read
Aloud on Android with real playback.
Reported by rentruewang on GitHub (#6850).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(tts): cover the windowed look-ahead across many paragraphs
A windowed engine looks ahead by duration rather than by count, so the
look-ahead steps across many paragraphs before it rewinds. The new test
runs that path over eight paragraphs and checks "next sentence" still
moves by one sentence. It fails with the mark restore disabled
(expected 'P0a.' to be 'Three.') and passes with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tts): hold the view still while a note is being typed during Read Aloud
Following the voice called renderer.scrollToAnchor(range), which in
foliate-js takes the 'navigation' path and focuses the spoken text inside
the book's iframe. That took the caret, and any IME composition, out of
a note being typed; a page or section change from following also
relocated the view, and Annotator's relocate handler then closed the
note popup. Opening the note editor clears the book selection, so the
existing "user is selecting text" guard never applied.
While a text area in the app has focus, the sentence mark, word,
page-follow and section-change paths now leave the view where it is.
The saved Read Aloud location still advances, and the next mark after
the text area loses focus follows again. A scrubber-drag preview still
navigates. Explicit navigation's focus behaviour is unchanged.
Verified: five new tests in useTTSControl.test.tsx under "while a note
is being typed (#6837)" fail without the change (e.g. "does not scroll
to the next sentence but still saves its location": expected "vi.fn()"
to not be called at all, but actually been called 1 times) and pass
with it; hooks, app/reader and tts-controller suites pass 1468/1468;
pnpm lint is clean. Not run: real Read Aloud playback with a Chinese
IME on Windows; in the headless web build Edge TTS could not connect
and Web Speech has no voices, so playback was not exercised.
Reported by CaFeZn on GitHub (#6837).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tts): hold the view still only while a note editor has focus
The guard matched any focused textarea, so a field that is not a note
editor (the custom template in Export Annotations, for one) also
stopped Read Aloud from following the voice. TextEditor, the textarea
behind every note editor, now carries data-note-editor, and the guard
checks that marker instead of the element type.
Verified: "keeps following while a text field that is not a note editor
has focus" in useTTSControl.test.tsx fails without the change (expected
"vi.fn()" to be called 1 times, but got 0 times) and passes with it;
"marks its text area as a note editor" in TextEditor.test.tsx fails
without the attribute (expected false to be true); the TTS control,
TextEditor and note editor test files pass 107/107; pnpm lint is clean.
Reported by CodeRabbit on #6849.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Recent ReadEra versions no longer put a library.json in their .bak
backups. The documents are split across docs_1.json, docs_2.json, …,
each a plain array of the same entries, so Import Annotations rejected
the backup as "not a ReadEra file". extractReadEraLibrary now joins
those files, in order, into the same { docs } shape when there is no
library.json; parsing and matching are unchanged.
Verified: "reads a backup whose documents are split across docs_N.json
files" fails without the change (expected undefined to deeply equal
[ 'The Little Prince', 'Sequel' ]) and passes with it, alongside a
library.json case and a non-ReadEra zip; readera.test.ts and
readera-import-real-book.test.ts pass 34/34. The reporter's own backup
(ReadEra 26.09, 8 entries, no library.json) now parses into 1078
documents, 14 with highlights or bookmarks, EPUB and PDF. pnpm lint is
clean. Not run: the Import Annotations dialog in the app against the
reporter's books, which we don't have.
Reported by Garoga591 on GitHub (#6831).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(annotator): run quick actions on a selection made in the footnote popup
A selection in the footnote popup reaches the Annotator through the
footnote-selection event rather than the section listeners that latch
isTextSelected, so with a quick action enabled (copy, dictionary,
translate, search, share) nothing ran there and only the toolbar opened.
The popup now marks a report settled once no pointer is down (on release,
or for a touch selection after the system long-press takes the gesture),
and the Annotator latches such a selection like one made in the book.
Highlight and read-aloud quick actions keep the toolbar, as before:
instant highlighting paints from the section pointer handlers, and
read-aloud is off for popup selections. Paragraph mode and data-attribute
footnotes send no settled flag and are unchanged. Part of #6804.
Reported by noideaforeal on GitHub.
Verified: the new tests fail without the change (popup reports carried no
quickAction flag; a settled popup selection opened the toolbar, not the
dictionary) and pass with it; the mid-drag and highlight/tts cases keep
the toolbar. 105 annotator, reader and paragraph-mode test files and
pnpm lint pass. On dev-web in Chromium with an EPUB footnote, Instant
Dictionary on: double-clicking a word in the popup opens the dictionary
for it.
Not run: iPad touch (long-press) and Windows WebView2. The iPad report
that Quick Highlight blocks selecting text in the popup is not changed
here: iOS suppresses the system long-press natively while the highlight
quick action is on, and instant highlighting isn't wired to the popup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(annotator): run the highlight quick action on a footnote popup selection
Instant highlighting paints from the book's section pointer handlers,
which the footnote popup document doesn't have, so a settled popup
selection with the highlight quick action only opened the toolbar.
Highlight it from the quick action instead, as the toolbar's highlight
button does (#6804). Read-aloud stays on the toolbar.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(annotator): drop the popup selection when the instant dictionary opens
The instant dictionary deselects the book so the system selection
handles, drawn above web content, don't sit on the lookup (#5585), but
view.deselect() can't reach the footnote popup's document, so a popup
selection kept its handles over the dictionary. Clear the popup
selection too, and don't let the cleared report it echoes back close
the lookup. With keepSelectionAfterLookup, the range handed back on
dismiss is not reported again as a new selection.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(annotator): re-arm the quick action for each gesture in the footnote popup
The instant quick action runs at most once per gesture, and only a
pointerdown in the book's sections re-armed it on iOS and desktop. The
popup document never did, so only the first popup selection ran the
quick action until the book was touched again (#6804). The popup now
reports its pointerdown so the Annotator re-arms the quick action and
times the long-press hold, as for the book. Android re-arms from the
native touchstart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(reader): let iOS select and look up text in the footnote popup
With the highlight quick action on, iOS turns off the system long-press
selection for the whole web view so it can't race the hold-to-highlight
gesture, which left nothing selectable in the footnote popup (#6804).
The popup's overlay keeps touches off the book, so lift the suppression
while a popup is shown; the settled popup selection is then highlighted
by the quick action.
An iOS popup selection also settles with the finger still down, and iOS
re-confirms it with its handles on release, so the handles sat on the
instant dictionary. Run a popup quick action at the native touchend, as
book selections already are on iOS, and drop a selection re-confirmed
while the lookup is up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(reader): leave a footnote marker out of a double-click or long-press word
A browser's native word selection runs a word into the footnote marker right
after it (letters followed by digits are one word), so double-clicking or
long-pressing "fox" before a marker published "fox1" to the popup, the
dictionary, copy and highlights. A selection made by a double-click, or by a
long-press that didn't move, now drops a marker at either end: a noteref link,
or a superscript or link holding only a number or symbol, as Read Aloud
skips. A drag that ends on the marker keeps it (#6566), and the on-screen
selection is left as the browser drew it, like the whitespace trim.
The selectionchange that re-applying the selection fires republished the raw
range; it now reuses the normalization for the same range. Closes#6803.
Not a regression from #6569: Chromium selects "fox1" with or without
Readest's link CSS.
Reported by noideaforeal on GitHub.
Verified: new sel.test.ts cases (marker after or before the word, a range
ending on the parent, 1<sup>st</sup> kept, a marker-only selection kept) and
useTextSelector-doubleClick tests (double-click, still long-press, the
selectionchange after a double-click) fail without the change and pass with
it; the drag-keeps-marker test passes both ways; the reader hook tests (34
files, including useTextSelector-textSelectedClass from #6569), sel.test.ts
and pnpm lint pass. dev-web in Chromium with a test EPUB: double-clicking
"fox" before a noteref and "elephant" before a bare <sup> copied "fox1" /
"elephant2" before, "fox" / "elephant" after.
Not run: iPad long-press (WebKit), Windows WebView2, Android.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(reader): trim the footnote marker from an Android long-press word
Android publishes a long-press selection from selectionchange while the
finger is down, and the native bridge reports the release without an
event, so the pointerup word-gesture check never ran there (#6803).
Detect a touch word gesture from the selection instead of finger
movement: a touch gesture that starts with nothing selected and whose
selection appears in one step is the browser's word selection. A handle
drag starts from a selection, and a long-press that goes on to drag
changes the range, so both keep the marker (#6566).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The filter editor built its author, publisher, tag and Calibre value
suggestions from the raw library, so removed and orphaned books kept
showing up. Discover fields from the same visible book list the dialog
already uses for its preview.
Fixes#6832
Signed-off-by: Gaurav Jadhav <gaurav.vjadhav01@gmail.com>
Bumps the tauri-plugin-webview-upgrade submodule to c861233, which adds
an App Startup initializer (tauri-plugin-webview-upgrade#4) pinning
MODE_NIGHT_NO at process attach on e-ink devices: the outdated-WebView
alert - and every other appcompat dialog in the process - stays light
regardless of the system dark mode. Device detection mirrors the
eink_identity.rs whitelist (tightened in #6833).
The initializer logic was verified on a Hanvon Clear7 (Android 14,
WebView 109): with it present, the outdated-WebView alert renders light
in both system appearances, where it previously flipped dark with system
night mode on.
eink_identity.rs (introduced in #6392) misclassifies three mainstream
non-e-ink device families as e-ink, which drives the e-ink device paths
(forced-white startup/WebView background, isEink flag, e-ink selection
styles) on LCD/OLED hardware in normal dark-mode usage:
- Amazon Fire tablets: "amazon" matched on manufacturer/brand alone with
no model confirmation.
- Samsung Galaxy A9 (AMOLED): the model token "a9" was trusted from
unknown OEMs, and SM-A920F contains it.
- Huawei nova (OLED): same for "nova" (collides with the BOOX Nova).
amazon joins the model-gated manufacturers (like hisense/xiaomi) - real
Kindles still match through "kindle"/"paper"/etc. - and a new
EINK_AMBIGUOUS_MODELS set (a9, nova) is only trusted when a known e-ink
manufacturer matched, which is how the real Hisense A9 and BOOX Nova are
detected anyway.
Test table extended with the Fire tablet / Galaxy A9 / unknown-OEM-a9 /
Huawei nova negatives and a Hisense A9 gated positive (7 tests).
This also unblocks mirroring the corrected table in the Kotlin port for
tauri-plugin-webview-upgrade (e-ink native-dialog theming, follow-up).
* fix(layout): keep the indent on paragraphs with a footnote icon
The indent exemption for image-only paragraphs used selectors like
p:has(> a:only-child > img:only-child). :only-child ignores text nodes, so
a body paragraph holding a footnote icon (<a class="duokan-footnote"><img>
</a>, common in Chinese EPUBs) matched too and lost its indent. The linked
variants came in with #6281 (v0.12.10); the older span variant had the same
flaw for <p>text<span><img></span></p>.
applyImageStyle now marks a paragraph whose only content is images
(class image-only, no text) and the rule uses that class in place of the
four :only-child selectors.
Verified:
- New test in style-dom.test.ts runs the rule's selectors after
applyImageStyle. On the old code it fails with the footnote, footnote-end
and span-icon paragraphs in the exempted list; with the fix only the
figure, linked and wrapped image paragraphs are exempt.
- Reporter's book on the dev server (web, Chrome): 24 footnote paragraphs
in chapter 1 went from 0px to 32px indent, same as plain paragraphs. The
book has no image-only paragraph, so the exemption was checked by adding
one to the rendered chapter: 0px.
- pnpm test (13641 passed), pnpm lint clean.
Not run: other platforms (CSS-only change in a shared path).
Reported by @sungt on X.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(layout): exempt only paragraphs that start with an image from the indent
Review follow-up on #6828. The two selectors kept from #1568 ignore text
too: `p:has(> img:not(.has-text-siblings))` matched `Text<br><img>` and
`p:has(> a:first-child + img:last-child)` matched `Text<a></a><img>`,
dropping the indent from text. The image-only class from the first commit
also stopped exempting `<p><img>Text</p>` (a drop cap), which
`img:only-child` used to cover.
The indent only moves the first line, so the class now marks a paragraph
with no text before its first image (`leading-image`) and is the rule's
only selector.
Verified: the selector test now also covers a captioned figure, a drop cap,
`Text<br><img>` and `Text<a></a><img>`. Against main's style.ts it fails
with footnote, footnote-end, icon, text-break and text-anchor exempted;
against the first commit it fails with dropcap missing and text-break and
text-anchor exempted; with this change exactly figure, linked, wrapped,
captioned and dropcap are exempt. Reporter's book on the dev server: the
24 footnote paragraphs keep the 32px indent. pnpm test (13641 passed) and
pnpm lint clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(reader): highlight quoted dialogue longer than 500 characters
Dialogue highlighting capped every paired quote at 500 characters, so a
longer speech in “”, „“, "", «», 「」 or 『』 was left untinted. Each
pair already stops at its next opening mark (ASCII "" at the end of the
block), so an unclosed quote can't swallow the next one without the cap;
it is removed for those pairs. ‘’ keeps its 300-character cap: a
mis-curled apostrophe (‘80s) opens a quote that nothing closes.
Verified: "wraps a quote longer than 500 characters" fails on main with
`expected [ '“', '"' ] to deeply equal [ …(6) ]` and passes with the
fix, as does the rest of dialogue-highlight.test.ts (38 tests). In the
web build (pnpm dev-web, Chromium), an EPUB with the reporter's text
(a 619-character “” quote and a « » one) had only the short quote
highlighted on main; with the fix all three are.
Reported by noideaforeal on GitHub (#6811).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(reader): highlight nested quotes within a paragraph and cap quotes at 2048 characters (owner: allow nesting only inside one paragraph, with a safe belt like 2048 chars)
A speech that quotes something in the same marks (“… my spiritual
“children”; in my little dog, Lizoo.”) only had the inner quote
highlighted: each “ stops the scan for the previous one. A second
pattern now matches an outer “”, „“ or «» quote that holds complete
inner pairs, and the existing overlap pass keeps the outer region. It
doesn't cross a paragraph break, where an unclosed “ usually opens the
next paragraph of a long speech.
Every quote is also capped at 2048 characters, in case a malformed
book pairs two marks pages apart. A rejected match is rescanned from
the next character, so quotes inside it are still found.
Verified: in dialogue-highlight.test.ts, "wraps a quote holding inner
quotes in the same marks" fails on main and on the previous commit
(expected [ '“children”', '« le chef »' ] to deeply equal [ …(2) ]);
"skips a quote longer than 2048 characters but keeps quotes inside
it" fails on the previous, uncapped commit (expected [ Array(1) ] to
deeply equal [ '«oui»' ]); "does not nest quotes across paragraphs"
guards the paragraph limit. 41/41 pass. pnpm lint is clean. The
reporter's sample EPUB in the web build (pnpm dev-web, Chromium):
main highlights only “children”; with the fix both the 621-character
quote and the whole nested speech are highlighted.
Reported by noideaforeal on GitHub (#6811).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(deps): resolve open Dependabot alerts
Raise the workspace overrides for shell-quote, proxy-addr and
source-map-js to their patched releases, and pull postcss-selector-parser
6.x (pinned by @tailwindcss/typography) up to 7.1.6. The compiled prose
styles are unchanged.
Move katex to 0.18 (with marked-katex-extension 5.1.13 and an override for
the packages still asking for 0.16) and i18next-http-backend to 4.0.2.
v4 only drops the cross-fetch fallback; the backend loads in the browser,
which has fetch.
Update rustls to 0.23.46 in Cargo.lock, Cargo.cef.lock and the koplugin
LocalSend helper.
Blank the Nix pnpmDeps and cargo hashes so the fod-hashes check prints
the new values.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(nix): refresh pnpm and cargo dependency hashes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): remove a merged PR's worktree in the triage loop
Worktrees for the loop's PRs stayed on disk after merge. When a followed
PR is merged, the loop now removes its worktree with `pnpm worktree:rm` and
deletes the local branch, but only for a worktree it made, with no local
changes and HEAD at the merged head; anything else is kept and reported.
Verified: the same checks and removal ran by hand on today's nine merged
worktrees (all clean, all at their merged heads); the main checkout's
shared target/ was untouched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): keep the commit-author check under open PRs
The worktree-cleanup sentences made "Then check every commit's author"
read as part of the merged case; it applies to PRs that are still open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(scripts): declare tsx so worktree:new and worktree:rm run
`worktree:new`, `worktree:rm` and `dictionary:yomitan:convert` run their
TypeScript with tsx, but tsx was never a dependency: it was only in the
lockfile as an optional Vite peer, so `pnpm exec tsx` failed with
"Command tsx not found". Declare it at the version already locked
(4.21.0), so no new package is fetched.
Verified: before, `pnpm worktree:new` failed at once with "Command tsx not
found"; after, it created a worktree end to end (submodules, install,
Android gen, vendor and simplecc copies, clean git status), and
`pnpm worktree:rm` removed it.
Not run: the Nix build locally (no nix here); nix_flake_check in CI checks
the pnpmDeps hash, which shouldn't move since no new package is fetched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): keep a merged worktree whose submodules hold local work
The cleanup checked the superproject only (--ignore-submodules=all), so a
submodule commit or edit, such as a foliate-js fix, could be removed with
the worktree. It now also requires no submodule at a different commit
(no '+' in git submodule status) and no submodule changes. Ignored files
are expected and still don't block removal.
Verified: both checks print nothing on two real worktrees, so a clean
worktree is still removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): check worktree cleanup with plain git status
--ignore-submodules=all hid a staged submodule pointer, and the extra
submodule status check missed it too. Plain git status --porcelain reports
a submodule at another commit, a staged pointer and edits inside a
submodule, so it replaces the three checks.
Verified on the foliate-js submodule: moved to another commit, plain
status prints ' M packages/foliate-js' while --ignore-submodules=all prints
nothing; staged, plain status prints 'M packages/foliate-js' while both
older checks print nothing. Plain status prints nothing on three clean
worktrees.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): require the branch itself at the merged head before cleanup
A worktree detached at the merged head could pass the HEAD check while its
branch held later local commits, and git branch -D would drop them. Both
HEAD and the branch ref must be the merged PR's head sha.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Notes sync runs on each booknotes change, throttled to one call per 5 s
with the trailing call dropped (emitLast: false). After a highlight's round
trip the loop makes one more sync that pulls nothing, which leaves the
cursor, and with it the throttle, unchanged. A highlight made within 5 s of
that sync was skipped and went out only with the next highlight or when the
book was reopened on the same device, so the last highlights of a session
never reached the other device while the book stayed open (#6762). The
throttle now makes its trailing call.
Reported by noideaforeal on GitHub.
Verified: useNotesSync-trailing-push.test.tsx (cursor held still, a second
highlight 1 s after the first sync) fails without the change (syncNotes
called once, never with the new note) and passes with it; the reader hook,
hook and sync service test files (166) and pnpm lint pass.
Not run: two real devices on Readest Cloud. useBookOrbitNotesSync has the
same throttle but already flushes when the window goes inactive; left as is.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(layout): clip nowrapped lines along the line only, keeping footnote icons whole
Readest adds `overflow: clip !important` to any book rule with
`white-space: nowrap`, so a long unwrapped line can't spill past the column
(#2663). `overflow: clip` clips both axes, so whatever rose above the line
box was cut too: a footnote icon in a <sup> with `vertical-align: top` on a
paragraph's first line showed only a sliver. The clip is now on the inline
axis only (overflow-x, or overflow-y in vertical writing); the other axis
stays visible. Closes#6797.
Reported by laraon on GitHub.
Verified: the two new style.test.ts cases fail on the old code (no
overflow-x/overflow-y clip) and pass now. A repro EPUB built from the
issue's HTML and CSS (the attached zip returns 404) on dev-web in
Chromium: before, the first-line icons' top edge hit-tested to <body>
(clipped); after, every icon hits its link, and a 2976px nowrapped line in
a 432px paragraph is still clipped at the paragraph edge. The utils and
transformer test files (176), the three layout browser tests that run
transformStylesheet, and pnpm lint pass.
Not run: the reporter's own EPUB, Windows WebView2 and Android, vertical
writing in a real book.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(layout): keep the two-axis clip for nowrapped rules that size their box
Narrows the #6797 fix. A white-space: nowrap rule that sets height,
max-height or a line clamp may rely on its box clipping the content, so it
keeps overflow: clip on both axes; only unsized nowrapped elements get the
line-axis clip. line-height and min-height don't count as sizing, so the
reporter's .adapt-poem-c rule still lets the footnote icon show.
Verified: the new "keeps clipping both axes" test fails on the previous
commit and passes now; the reporter's exact rule (line-height 1.7) still
gets overflow-x only; style.test.ts 103/103, the utils and document test
files (204) and pnpm lint pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A custom header/footer text color reached every footer item except the
battery percentage, which kept the theme's text color (text-base-content).
When a custom color is set, the number now inherits it like the clock and
the battery outline. Without one, or in e-ink, it is unchanged: themed
text (#5045) and the e-ink knockout (#6588) stay as they were. Closes#6794.
Reported by @atulyya in discussion #6789.
Verified: the new StatusInfo and ProgressBar tests fail without the change
(the number keeps text-base-content; inheritTextColor undefined) and pass
with it, including "no custom color" and "e-ink with a custom color", which
keep text-base-content; the component and reader test files (223) and
pnpm lint pass.
Not run: looking at it in the app with a real battery reading.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): add the maintenance loops, lessons and acceptance rules
Reproduces the Magpie workflow: three /loop skills that run in parallel
(triage-github for issues and PRs, watch-social for X/Discord/Reddit with a
Haiku pre-filter, nightly-review for the retrospective), a LESSONS.md that
every session imports, acceptance criteria that lessons seen on three days
are promoted into, and a community-PR policy (harm first, close unreviewed,
proposed blocks).
LESSONS.md is seeded from a review of the 86 commits from 2026-10-04 to
v0.12.16; three lessons seen on three days are already promoted.
Verified: pnpm format:check passes; the triage step-0 query was run against
the repo (needs --include-prs to see PRs). Not run: the loops themselves,
which reply on GitHub, X and Discord.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): route triage replies through the Reply Desk for approval
Drafts go to a private review page; the loop posts only approved replies,
re-checks the thread first, and treats edits and notes as feedback. The
window is threads created in the last 3 days.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): filter triage search by date; GitHub ignores timestamps
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): real timestamps on the Reply Desk; foliate-js fixes go first
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): the triage loop follows its own PRs: dependencies, CI, reviews, merge cards
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): edit PR bodies through the REST API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): wake the triage loop on CI results and reviews
watch-prs.py polls the loop's open PRs every minute and prints one line
per event (CI finished green or red, CodeRabbit done, a new review or
comment, merged or closed, a failed read) for a Monitor to deliver. The
loop also checks a PR is still open before writing its merge card: the
maintainer merges on GitHub too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): the loops only process the owner's own PRs
The loops run unattended with the owner's credentials, so a PR from anyone
else is skipped unread: its description, diff, comments and branch are
never opened or run, and the loop never comments on, closes or merges it.
On the owner's PRs they act only on CodeRabbit (login and type checked) and
the owner, and stop on a commit the owner didn't author. The watcher
enforces the same: it skips other people's PRs, reports foreign commits,
and tags each review or comment trusted or untrusted. The nightly review
reads only the merged code of community PRs and no longer proposes blocks;
the social loop never fetches code a post links to.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): propose English titles for non-English issues
A non-English issue title gets an English newTitle on its Reply Desk card,
applied when the card is approved; the reply stays in the reporter's
language.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): send approved Reply Desk cards within five minutes
A light job checks the desk for approved cards every 5 minutes and runs
only the send step; merges go through the REST merge endpoint pinned to
the approved head.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): read the Reply Desk URL from local loop config
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): keep the PR watcher honest on a failed login or a null status
The watcher now stops with WATCH_ERROR when it can't read the gh login,
instead of treating every PR as foreign and later replaying old events.
A CodeRabbit status with a null description no longer raises before the
status is recorded.
Verified: py_compile; a --once pass on #6796 against a scratch state dir;
GH_TOKEN=invalid exits 1 with "WATCH_ERROR cannot read the gh login: gh:
Bad credentials (HTTP 401)"; a stubbed status with description null emits
CODERABBIT_DONE and records it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): tighten the send step, rules and checklists from review
- Send step: drop a PR card whose PR is no longer open, keep other
people's comments on owner PRs unread, claim a card as "sending" before
any GitHub action, and send a failed action back as stale instead of
retrying it; record prUrl, and open a fix PR only through its card.
- Acceptance: the failing-test rule is for behavior changes; a red test on
main is fixed in its own PR; reporters are credited by their public name
only.
- Nightly review: CI at merge is the runs that finished before mergedAt;
"Fixed in" only when a fix exists.
- Social loop: the filter agent reads only the collected file, and the loop
acts only on items that match it.
- Community PR checklist: run their code only in a credential-free sandbox.
Verified: docs and agent files only; read through for consistency with the
trust boundary and the Reply Desk flow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): write PR descriptions for GitHub, not from commit bodies
The loop's PRs reused hard-wrapped commit bodies, which GitHub renders with
a forced break at every line. PR descriptions now follow the format of the
earlier Claude Code PRs: a Closes/Part of line, Problem, Fix and
Verification sections, unwrapped lines and code in backticks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): the social loop posts only approved Reply Desk cards
Replies on X, Discord and Reddit, and issues it opens, are now drafted as
social cards and posted only after approval; the triage send step skips
them. A post asking the loop to act gets nothing beyond a card.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): leave approved social cards to the social loop
The triage send step now skips kind: social cards, as 7e87d684c described.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): fetch sending cards too, so a stopped send gets reported
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): give a follow-up draft its own card after a sent one
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Read Aloud already skipped a note marker inside a link (<a>17</a>), but an
unlinked one such as <sup>17</sup> was spoken mid-sentence. A superscript
holding only a marker (digits, [n], (n), *, †, ‡, §) is now skipped the same
way; one with letters, like 1<sup>st</sup>, is still read. The live reader
and the timeline enumeration share this filter, so both change together.
Closes#6790.
Reported by Oshibuki on GitHub.
Verified: the two new marker tests fail without the change ("expected
'他停下了脚步17,然后转身离开。' not to contain '17'") and pass with it;
the 1<sup>st</sup> test passes both ways; tts.test.ts 51/51, the TTS and
document test files (78) and pnpm lint pass.
Not run: listening in the app with a real voice. A number-only superscript
that is not a note (m<sup>2</sup>) is now skipped too.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(reader): release closed reader views from the cached book document, closes#6710
The library keeps each opened book's document in bookDataStore for the next
open. FoliateViewer and foliate-js's paginator both added listeners to that
document's transformTarget on every open and never removed them, so each
closed reader (its foliate-view, iframes, section documents and their
listeners) stayed alive for the rest of the session, one more per open. A
restart was the only thing that released them, as the report describes.
FoliateViewer now removes its load/data listeners on unmount, and the
paginator removes its listener in destroy() (foliate-js 49b4b05).
Verified:
- foliate-viewer-transform-listeners.test.tsx fails before (2 listeners
left after unmount) and passes after; paginator-transform-listener
.browser.test.ts fails before (1 left after destroy) and passes after.
- Production web build, Playwright Chromium, 40 EPUBs, one book opened and
closed 12 times, measured after forced GC: before, each cycle left +1
frame, +1 document, ~+79 DOM nodes, ~+16 listeners and ~+0.75 MB heap;
after, all flat (heap ~+0.3 MB/cycle). A heap snapshot shows no closed
foliate-view reachable from app code after the fix.
- pnpm lint, format:check, the related vitest files and the 37 paginator
browser test files pass.
Not run: iOS (the reporter's platform), no device or simulator here. The
library return time did not degrade in desktop Chromium before the fix, so
the link between this leak and the reported delay is not shown on iOS.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(deps): move foliate-js to the merged readest/foliate-js#128
Same tree as the 49b4b05 branch head this PR was tested with; the squash
merge only changed the commit id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(reader): drop transformTarget listeners added after the reader closed
openBook registers its transformTarget listeners after awaiting
view.open(). If the reader unmounts while that is pending, its cleanup
has already run, so the listeners were added afterwards and kept the
closed view alive, the same leak as #6710. They are now removed at once
when the reader has already closed.
Found by CodeRabbit on #6788.
Verified: the new "closes while the book is opening" test fails without
the change (expected 2 to be +0) and passes with it; the 90 reader test
files pass; pnpm lint passes. Not run: iOS.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Annotations imported from a Readest, Moon+ Reader or ReadEra file vanished
once the book was reopened on a synced device. Two rules combined:
- The importers kept each note's updatedAt from the export, so a remote
deletion of the same note (say, Clear Annotations before re-importing)
was newer and won the next pull, and native sync never pushed a note
whose old updatedAt predated its last sync. Every note an import adds,
restores or updates is now stamped with the import time.
- File sync (WebDAV, S3, data folder) let any remote tombstone beat a
local live note, even one restored or edited after the deletion, and a
newer local deletion lost to an older remote edit. mergeNotes now uses
the rule native sync's useNotesSync already applies: the latest change,
max(updatedAt, deletedAt), wins and an exact tie goes to the tombstone.
The winner's deletedAt is pinned so a live winner can't inherit the
loser's tombstone.
Verified:
- 8 new tests fail on the old code ("expected 1000 to be 50000",
"expected 20000 to be falsy", "expected 10 to be falsy"). Reverting
either fix alone turns 4 (importer) or 5 (merge) of them red, including
the import-then-pull case for each.
- 55 related test files (file-sync engine, merge, annotation import,
ReadEra, Readest import, notes sync): 674 passed. pnpm lint passes.
- dev-web, signed out, Chromium: importing the Alice fixture export and
reopening the book keeps all highlights, before and after the change.
Not run: a signed-in Readest Cloud or WebDAV round trip in the app (no
test account or local WebDAV server here); the reporter's iPad/Windows
setup. Part 2 of the report (149 vs 142 notes) is not addressed.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
When a fixed-layout page is zoomed past the window (or vertical text
scrolls sideways), the desktop footer and its hover strip were lifted
12px to clear the horizontal scrollbar. Scrollbars are taller than that
(15px in Chromium on Linux, 17px on Windows), so moving the mouse down
to the scrollbar revealed the footer over its top edge. Both now share
one 17px lift.
Reported by TOM-PIC in #6784 (Windows 11, WebView2).
Verified:
- footer-bar-scrollbar-lift.test.ts fails on the old 12px lift
("expected 12 to be greater than or equal to 17") and passes now;
footer-bar-position and desktop-footer-bar tests pass.
- pnpm lint (tsc + Biome) passes.
- Reporter's EPUB (pre-paginated) on dev-web in headless Chromium with
classic scrollbars, 1198px wide, zoomed 4 steps: before, the revealed
footer spanned y 1048-1088 over a scrollbar at 1085-1100; after, it
ends at 1083 and every scrollbar row hits the scroller. Dragging the
thumb still scrolls (scrollLeft 90 -> 0).
Not run: Windows / WebView2 (the reported platform) and macOS; the full
vitest suite. Dragging the page itself with the mouse still does
nothing; that is left for a product decision.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The bookshelf cuts titles to one line, so long titles end in an ellipsis
with no way to read the rest short of opening the book. Book titles and
group names in grid and list views now carry their full text as a
tooltip. Closes#6783.
Reported by TOM-PIC on GitHub.
Verified:
- bookshelf-full-title-tooltip.test.tsx fails without the change (4 of 4:
"expected null to be 'The Remarkably Long Title…'") and passes with it;
related library tests (group-item-localized-name, book-item-list-tags,
book-item-series, book-item-cover-aspect) pass, 17/17.
- pnpm lint (tsc + Biome) passes.
- dev-web in Chromium: the clipped demo titles "This Side of Paradise" and
"A Room of One's Own" carry their full text in the title attribute.
Not run: the native tooltip on Windows WebView2 (the reporter's surface),
macOS, or Linux CEF; e-ink is unaffected (no visual change).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(reader): fill the drawing dock's bottom safe area with its own background
The dock was lifted above the home indicator by the bottom inset, leaving a
transparent gap where the reader footer showed through. It now sits at the
bottom edge and pads the inset with its own background.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(library): title the bookshelf editor with the selected shelf on phones
On phones the selected shelf's name becomes the page title beside the back
button and the tabs, icons only, get a centered row of their own, so the
built-in shelves and the add button fit on one line. Wrapped rows are now
centered on the screen rather than within the slot beside the back button.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tts): keep a hidden mini player hidden through brief stalls between paragraphs
The buffering probe catches the short wait while the next paragraph loads,
which re-showed the card and re-armed its linger, and the end of buffering
showed it again. A hidden card now comes back only when a stall lasts 500ms,
and the end of buffering just restarts the countdown.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): only full-height sheets clear the status bar and show a back button
A content-height sheet such as a confirmation, or a sheet snapped short of
the screen, rests at the bottom: it no longer pads its drag handle for the
status bar or shows the back button.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tts): stop the word highlight stepping back at a sentence start on iOS
chunk-start fires as the item is handed to AVPlayer, which can take a few
hundred ms to open it. The media clock ran ahead from the event and lit the
second word, then the next poll pulled it back to the first. The clock now
holds until a poll sees the item playing, and the position reports playing
from timeControlStatus rather than the rate, which is set while loading.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tts): preload the next chapter's opening for Edge TTS and custom voices
Only windowed engines carried preloads past a chapter end, so with Edge TTS
or a custom service a chapter change started with nothing synthesized. A new
preloadsAudio capability marks engines whose preloads keep the audio; they
now fill their usual handful of paragraphs from the next chapter too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: run selected unit test files with pnpm test --run
pnpm test -- <files> forwards the separator and runs more than the files
asked for.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(agent): update agent memories
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tts): keep the iOS word clock from stepping back on pause or running ahead on resume
Pausing froze the clock at the last poll, up to 250ms behind where it had
got, so the highlight could step back a word. Resuming restarted it before
AVPlayer was rendering again. Pause now freezes the extrapolated position,
and resume polls and lets the player report when it is playing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tts): preload the next chunk's opening at the end of a chunk
A section over 512 KB of HTML is read in chunks, and the live document ends
at each chunk's end. The cross-boundary preload took that for the chapter's
end and preloaded the next section, so moving to the next chunk found nothing
synthesized. It now starts a throwaway reader at the next chunk's anchor in a
fresh copy of the section, and only goes on to the next section after the
last chunk.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(ui): drag a content-height sheet up instead of setting its height
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tts): use the contrast button for play/pause in the full player
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tts): preload the next section in the language it is read in, and more
- A section file with only xml:lang is rendered with the book's language as
its lang, which is what the live read speaks it in. The fresh copy used to
preload the next section kept xml:lang (zh-CN vs zh), so its audio was
cached under keys playback never looked up and each new section started
with a wait. The fresh copy now takes the book's language the same way.
- E-ink drops the tinted sentence under the word highlight in word mode.
- The lyric sheet follows the document read, not only the section index, so
a section read in chunks no longer shows the first chunk's lines under the
next chunk's position.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(tts): resize the full player on desktop and size the lyric text
- A corner grip resizes the full Read Aloud player with the pointer on
desktop, up to the whole window, and the size is kept for the next time
it opens. Once sized, the lyric sheet takes the height it is given.
- A−/A+ in the lyric view step the line size from 12 to 38px, kept per
device.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tts): keep the lyric size and player size working when storage is blocked
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#6662 records a book's page count at import, but only on the importing
device. Other devices got a count only once someone opened the book, and a
cloud-only book could not be counted locally at all.
- Add a nullable books.page_count column (migration 028) and map it both ways.
A pulled row without a count leaves the key off, so the pull merge keeps the
local value.
- The server merge keeps the row winner's count and falls back to the other
side's, so a push without one (old clients, the KOReader and Calibre
plugins, devices that never counted the book) never erases it, and a server
row missing a count gets it filled in without a date-read reorder.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(library): count pages at import, add Pages sort and filter, closes#6662
A book's page count was only known once the reader laid it out, so an
unopened book showed "Pages: Unknown" and the library could not sort or
filter by length.
- Record Book.pageCount at import with the reader's own formula: one page
per section for fixed layout, otherwise linear spine bytes / 1500
(foliate's SectionProgress). Once opened, progress[1] holds the same total.
- Native import paths: parse_epub_metadata returns the zip entry sizes and
the EPUB bridge reads the OPF spine; parse_pdf_metadata returns the page
tree count. MOBI/AZW3 native import still fills it in on first open.
- Refresh Metadata fills in the count for books imported earlier.
- Add a Pages sort (after Format) and a built-in Pages bookshelf filter.
Calibre columns move to their own group in the filter field picker, since
one can share a built-in field's name.
- Manage Bookshelves: the shelf tabs take the place of the dialog title.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(import): read the spine of an OPF without a namespace
Match foliate's childGetter, which ignores the OPF namespace when the
document never declares it, so such EPUBs still get a page count on the
native import path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* build(android): ship per-ABI APKs (arm64, armv7, legacy) instead of universal
Release and nightly now build Android in three parallel jobs, one APK
each: arm64 (aarch64), armv7, and legacy (i686 + x86_64). The universal
APK is no longer built.
The updater maps the running native library's arch to android-arm64,
android-armv7 or android-legacy, and only offers an update when the
manifest carries this device's key. In release.yml a single job writes
all Android keys into latest.json after every build leg finishes, so the
legs never race on the asset; nightly emits one fragment per leg.
android-universal stays in both manifests, pointing at the armv7 APK, so
clients released before this change still find an update on non-arm64
devices.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(updater): cover arm64 devices against the per-ABI manifest
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ios): open the share sheet on iOS 16 for files with spaces or non-ASCII names, closes#6719
The share plugin parses the path it gets with URL(string:), which on iOS 16
returns nil for a space or non-ASCII character. The share was rejected,
the rejection was treated as a cancel, and the export reported success
with no share sheet. iOS now gets a percent-encoded file URL.
The plugin also copies the file to <tmp>/<name> and keeps a copy that is
already there, so a second export of the same book shared the first one.
That copy is removed before sharing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ios): share an "Open with" book's file URI without wrapping it again
On iOS an "Open with" book keeps its decoded file:// URI as its file
path, which the share path then encoded into a second file URL.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>