Russian typography requires short function words (prepositions,
conjunctions, particles) to never hang at the end of a line. Add an
`nbsp` content transformer that inserts U+00A0 after such words so they
stick to the following word. The source file is never modified.
The transformer is language-driven via an NBSP_LANGUAGES registry keyed
by language code (only `ru` is configured today), so adding another
language is a single entry. It runs only for matching books and rewrites
text between tags with a regex, leaving tags, attributes, and the XML
declaration intact. Runs after whitespace normalization so the inserted
spaces are not stripped under the override-layout setting.
The space-to-NBSP swap is length-preserving (both are single UTF-16 code
units), so DOM character offsets and CFIs stay valid for every word
before and after the transform; tests enforce this invariant.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a book was matched via Hardcover title search with no featured
edition and the user had not selected a specific edition, the sync
client fell back to using the Hardcover book id as the edition_id.
Hardcover's Action rejects that with a parse-failed error
("ActionWebhookErrorResponse ... key 'message' not found"), so progress
and note sync failed for those books.
Leave editionId null when no real edition is known, make the read and
journal mutations accept a nullable edition_id, and omit edition_id when
adding a book so Hardcover uses the book's default edition.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
useSync.pullChanges already re-read the live store settings for its
in-try setSettings, but its catch and finally still wrote the stale
per-render hook closure. When a settings change lands during an
in-flight pull (most visibly a WebDAV connect), the pull's finally
overwrote settings.json with the pre-change snapshot, so the connection
read back as "Not connected" after the app was reopened.
WebDAV was the unique casualty because it is the only integration
credential not in the replica SETTINGS_WHITELIST, so unlike
kosync/readwise/hardcover it is never re-hydrated from the server on the
next launch. Android's slower network widens the pull window, which made
the overlap reliable there.
Read useSettingsStore.getState().settings in both the catch and the
finally, matching the in-try path. This is a general fix that preserves
any concurrent settings change, not just WebDAV. Adds a regression test
that drives the real hook with a connect landing mid-pull.
Fixes#4780
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add per-folder sort and search to the WebDAV browse pane in Settings,
Integrations, WebDAV.
- Sort by name, date modified, date created, or size, ascending or
descending; the choice persists in WebDAV settings so a chosen
"recent first" order survives across sessions.
- Filter the current folder by file name or matched book title.
- Request and parse the WebDAV creationdate property; servers that omit
it fall back gracefully to a stable name order with no broken dates.
- Sort and search resolve a per-hash book directory to its library
title so they operate on what the user actually sees.
Sort and filter are pure, unit-tested helpers in webdavBrowseUtils;
creationdate parsing is covered by a listDirectory test. Verified on a
Xiaomi device against a live WebDAV server (675 books): name, modified
asc/desc, title filter, and persistence across an app restart.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adding a built-in popular catalog (e.g. Project Gutenberg) to My Catalogs
left it still rendering in the Popular Catalogs section, so it looked like a
duplicate. Only the card's Add button was hidden; the card itself stayed.
Filter added (and disabled) entries out of the Popular list entirely via a
new pure helper getUnaddedPopularCatalogs, which matches by normalized URL
(trim + lowercase) to mirror the store's findByUrl dedup. The section already
auto-hides when the list is empty, so it disappears once all are added.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(sync): extract provider-agnostic layout paths
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(sync): extract wire envelope module
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(sync): extract pure merge module with law tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(sync): add FileSyncProvider and LocalStore interfaces
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): FileSyncEngine orchestration over a provider
Port WebDAVSync's per-book + library-wide sync onto FileSyncProvider +
LocalStore. Behavior preserved; the #4756 metadata-reconciliation test is
retargeted to drive the engine through a fake provider + store.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(sync): move WebDAV client + connect settings under providers/webdav
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): WebDAVProvider implementing FileSyncProvider
Wraps the WebDAV transport client, maps WebDAVRequestError to the neutral
FileSyncError, and owns Tauri streaming upload/download. Adds a
provider-conformance suite future backends can run against.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): shared appService-backed LocalStore bridge
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(reader): drive WebDAV sync through FileSyncEngine
Construct a WebDAVProvider + shared LocalStore + engine once per hook; the
inline buffered/streaming book-file loader collapses into the provider +
store, so the hook no longer imports tauriUpload or the file path helpers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(settings): drive WebDAV library sync + browse through the provider
WebDAVForm now builds a WebDAVProvider + shared LocalStore + engine and calls
engine.syncLibrary; the ~170-line inline callback block (buffered/streaming
loaders, URL+auth construction) is gone. WebDAVBrowsePane builds a provider for
the engine-level deleteRemoteBookDir cleanup helper.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(sync): remove WebDAV-specific sync module, WebDAV is now a provider
Delete src/services/webdav (WebDAVSync/WebDAVPaths + the transitional client
and connect-settings shims). The superseded webdav-metadata-sync test is
replaced by engine-metadata-sync; webdav-delete now drives deleteRemoteBookDir
through a WebDAVProvider and asserts FileSyncError.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sync): hydrate library before WebDAV Sync now to prevent clobber
Sync now while the library store was unloaded (app launched into reader/
settings without mounting the Library view) merged the engine's
addBookToLibrary / updateBookMetadata against an empty in-memory library,
persisting a downloaded book or a metadata update as the entire library and
wiping what was on disk. Pre-existing bug surfaced during the file-sync
review. Hydrate the store in handleSyncNow and harden the store bridge with a
load-if-unloaded guard (mirrors useLibraryStore.updateBooks).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(sync): make listDirectory honor the FileSyncError contract
listDirectory threw a plain Error (and let raw fetch failures escape), so
WebDAVProvider flattened every list() failure to FileSyncError(UNKNOWN). Throw
the same WebDAVRequestError taxonomy as the file-level helpers (AUTH_FAILED /
NOT_FOUND / NETWORK) so the provider maps them correctly. Add list() cases to
the provider-conformance suite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(sync): cover streaming upload, discovery/download, and receive paths
The metadata-sync gate only exercised the buffered metadata + config-merge
paths. Add engine tests for streaming uploadStream (+ HEAD short-circuit +
one-shot retry), remote-only discovery -> streaming download -> addBook, and
the receive strategy (pull-only, no config or index writes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): incremental WebDAV Sync now + bounded concurrency
Sync now was a full walk of every book each run (675 round-trips even when
nothing changed). Default to incremental: diff the local library against the
shared library.json index per hash and only process books whose local copy is
newer (or absent). book.updatedAt bumps on every progress/notes/metadata save
(bookDataStore.saveConfig), so the index is a reliable per-book change marker.
Remote-newer books pull their config in the reconcile pass so peer progress
still propagates. A new 'Full Sync' toggle (default off) re-checks everything.
Also run the reconcile / download / push phases over a bounded worker pool
(default concurrency 4) instead of one book at a time.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(sync): simplify Sync now toast to a single book count
The completion toast built a multi-line success bullet list (downloaded /
pulled / pushed / uploaded). Replace it with the same single-line info toast
the native cloud sync uses: '{{count}} book(s) synced'. Add a booksSynced
counter to the engine result (a Set of distinct hashes touched in any
direction, since the per-action counters overlap under Full Sync). Failures
still surface as a warning.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ui): raise toasts above modals so they aren't hidden by open dialogs
Toasts rendered at z-50, below the Settings dialog (z-110) and ModalPortal
(z-120), so a toast dispatched from an open dialog (e.g. WebDAV 'Sync now')
was buried. The documented overlay scale already places toast at 130; the
component just hadn't followed it. Move the toast to z-[130] and extend the
zIndexScale invariant test to guard TOAST > MODAL/SETTINGS and APP_LOCK > TOAST.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-book and selection-scope proofread (find/replace) rules were pushed in
the synced book config but dropped on pull (applyRemoteProgress only applied
location), so they never propagated across devices. Merge them by id on the
config pull, mirroring the booknote CRDT path. Library-scope rules keep
syncing via the settings replica.
- Add updatedAt/deletedAt to ProofreadRule. Delete is now a tombstone for
book/selection scope so a removal is not resurrected by a peer's live copy;
library-scope deletion keeps the hard splice (settings-replica whole-field
LWW already handles it).
- Add mergeProofreadRules (by id, updatedAt/deletedAt last-write-wins) and
merge into applyRemoteProgress; refresh the live view only when the merged
rules actually changed.
- Backfill a content-derived id for id-less rules (legacy/foreign/hand-edited)
via ensureRuleId, and seed book/library ids from content so the same rule
created on two devices dedupes instead of duplicating. Selection rules keep
a per-instance unique id. Without this, id-less rules collide on one Map key
and clobber each other.
- Filter tombstoned rules from the transformer and the manager dialog list.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-relocate re-apply effect reads a memoized annotation index. A
highlight deleted in place after the index was built still sits in its
bucket, and selectLocationAnnotations trusted the build-time deletedAt
filter, so the effect re-drew the just-deleted overlay and left it
orphaned on the page until the book was reopened.
Re-check deletedAt at the read site: in selectLocationAnnotations and in
the sibling globals re-apply loop in Annotator.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sync): pull newer WebDAV book metadata to devices that already hold the book (#4756)
syncLibrary only pulled title/author/cover for books missing from the local
library. For a book a device already held it only pushed, so a peer's metadata
edit never propagated back, and the final library.json re-push clobbered the
peer's newer metadata with this device's stale copy.
Add a last-writer-wins reconciliation pass keyed on book.updatedAt: when the
shared index has a strictly newer copy of a locally-held book, merge its
metadata, re-pull the cover, persist it via a new updateBookMetadata callback,
and keep the merged copy authoritative for the index re-push so neither
direction loses the edit. Surface a "metadata updated" counter in the sync
toast and history, and translate the new strings across all locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sync): merge remote config before pushing in WebDAV Sync now (#4756)
The manual library "Sync now" pushed each book's config.json blind, so it
could overwrite a peer's booknotes (element-set CRDT) or regress newer remote
progress (per-config LWW) that this device had not pulled yet. The reader hook
already pull-merges before pushing; the library path did not, so notes and
progress could diverge or regress on the remote until a device happened to open
the book.
Give syncLibrary's config push the same read-merge-write cycle: pull-merge then
push the merged superset, persisting it locally so the device converges too.
Gated on canPull so 'silent' converges while 'send' keeps the local copy
authoritative and 'receive' still never pushes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Background Image: move the Library/Reader scope into the section title
("Background Image (Library)" and "Background Image (Reader)") instead
of a separate "Applies to ..." sublabel line.
- Send to Readest: render approved-sender emails monospace to match the
inbound address, and wrap long addresses to at most two lines instead
of truncating on one line.
- WebDAV: split the "Uploading X / Y" progress into a status line plus a
one-line book title.
- WebDAV: reword the "Upload Book Files" description to "Uploads book
files to your other devices."
- WebDAV: rename the "Always use latest" strategy to "Send and receive".
KOSync keeps "Always use latest" since it must contrast with its
"Ask on conflict" option.
- WebDAV: remove the Sync History section and its persisted log model;
the sync engine still reports per-book failures in its result.
Updated i18n across all 33 locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): stop iOS page-turn animation stutter (#4768)
iOS users saw occasional page-turn animation stutter that was not present
on earlier 0.11.x builds. It traces to foliate-js commit c1c7315 (first
shipped in 0.11.4): the large-section rafAnimateScroll fallback and the
removal of persistent compositor-layer hints, both added to fix a ~1s
Blink freeze on Android Chromium at high DPR.
Apple WebKit composites those layers fine, so on iOS (notably 120Hz
ProMotion devices) the changes only cost smoothness: large-section turns
animate scroll on the main thread, and every turn promotes a layer
on-demand instead of using a persistent one.
Opt the iOS renderer into foliate-js's new gpu-composite path, which
restores persistent compositor layers and skips the main-thread
rafAnimateScroll fallback. Other platforms keep the Android freeze fix.
Bumps the foliate-js submodule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(deps): repin foliate-js to merged gpu-composite commit (#4768)
readest/foliate-js#39 squash-merged to a new commit on main. Move the
submodule pin off the now-orphaned PR branch commit to the merged main
commit. No content change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(plan): design for cross-page corner auto-turn (#4741)
Extract useAutoPageTurn so the corner-dwell page turn works for instant
highlight drags and for range-editor handle drags, not just native text
selection. Decouple the dwell liveness from the DOM selection and anchor
each range's non-dragged end to a DOM position so it survives the scroll.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(plan): add keyboard turn-on-cross to cross-page design (#4741)
Shift+Arrow selection adjust extends into the off-screen next column
without turning the page. Fold it into the feature with an immediate
turn-on-cross (no dwell) in the keyboard path, reusing the page-edge
geometry from useAutoPageTurn.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): extend selections and highlights across pages (#4741)
Extract the corner-dwell auto page-turn (#1354) into useAutoPageTurn,
decoupled from the DOM selection, so every selection gesture can drive
it in paginated mode, not just native text selection:
- Instant Highlight drag: feed the finger corner into the dwell machine
and DOM-anchor the highlight start so it survives the page scroll.
- SelectionRangeEditor and AnnotationRangeEditor handle drags: feed the
dragged-handle corner; anchor the non-dragged end to a DOM position so
the edited range spans pages (the annotation editor previously resolved
both ends from window coordinates and lost the previous page).
- Shift+Arrow keyboard selection adjust: turn the page immediately when
the extended focus leaves the visible page, so the growing selection
stays in view.
An after-turn re-emit rebuilds each gesture's range from the held
position so the selection extends onto the new page without waiting for
the next move.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single tap on an image wrapped in an <a> element followed the link instead of opening the image viewer, because postSingleClick returned early for any element inside an anchor before reaching media detection.
Compute the media target up front and let it bypass the anchor guard, so a tapped image/table/svg-image opens the viewer just like long-press already does. Footnotes are excluded so footnote anchors keep their popup and navigation behavior.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Downloading a Readest cloud library into KOReader previously required
tapping each book one at a time. Add a "Download all books" action to
the Library view menu that pulls every cloud-only book to the device
in one pass.
- LibraryStore:listCloudOnlyBooks() returns the downloadable
cloud-present, not-local books for the current user (whole library,
independent of the active search/group view).
- librarywidget.downloadAll() streams them sequentially via the
existing syncbooks.downloadBook path inside a Trapper coroutine:
cancellable progress (Trapper:info Abort/Continue), per-book
failures skipped and counted, summary toast at the end. Bridges
downloadBook's sync-or-async callback with a coroutine suspended
check so it serializes correctly either way.
- Wire the action into the view-menu Actions section.
- Add the new UI strings and translate them across all 33 locales.
Closes#4751
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Calibre-parity search modes to the reader's full-text search. The
"Match Whole Words" toggle becomes a single-select mode group: Contains,
Whole Words, Regular Expression, Nearby Words.
- Regex and nearby-words matching live in the foliate-js submodule
(bumped here); the sidebar threads `mode` and `nearbyWords` through.
- Nearby distance is chosen with a "within N words" control (5/10/20/50,
default 10), not parsed from the query, so trailing numbers stay
literal search words.
- Per-mode modifiers: Match Diacritics is greyed out for regex (no-op).
- Calm inline error for invalid regex / too-few nearby words, a
no-results state, and a results-count footer.
- Nearby matches render a segmented excerpt emphasizing each matched
word and highlight every word in the book.
- BookConfig schema v2 -> v3 migrates the deprecated `matchWholeWords`
boolean to `mode` (still written for sync back-compat).
Also fix two search interactions:
- option changes (e.g. within-N-words) now take effect immediately by
reading the latest config at search time instead of a stale closure.
- closing search from the results nav bar now exits the sidebar search
mode, not just the results (search-bar visibility lifted to the store).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The customized annotation toolbar only took effect in the book where it
was changed, instead of applying to every book.
Root cause: serializeConfig decided which per-book view settings to
persist as overrides using a reference check (globalViewSettings[key]
!== value). It deep-clones the config first, so array-valued settings
like annotationToolbarItems are always a fresh reference and were stored
as a per-book override on every save (each progress autosave). On reopen
the per-book override shadowed the global value, so a global toolbar
change never reached already-opened books.
Compare view-setting values by content, not reference, so array/object
settings equal to the global value are no longer persisted per-book. This
also fixes the same latent issue for paragraphMode, proofreadRules,
ttsHighlightOptions and noteExportConfig.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Feeds that list several groups previously rendered each group as a full
grid, which made scrolling past many groups tedious. When a feed has two
or more groups, render each group's items in a compact horizontal
carousel, matching what Thorium does.
Each carousel is a horizontally virtualized react-virtuoso list, so only
the covers in view are mounted and fetched; off-screen covers load lazily
as the row is scrolled. Scroll arrows page through by index and stay
centered on the cover artwork.
Book items also get rounded covers (matching the library bookshelf) and
drop the inline acquisition badge, which remains on the publication
detail page.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(library): separate background texture for library and reader (#4743)
The library and reader shared a single background texture, so a reader
backdrop with borders or other reading-oriented decoration looked wrong
on the bookshelf. Let users set them independently.
- Add device-local libraryBackground{TextureId,Opacity,Size} to
SystemSettings. Each field falls back to the reader/global value when
unset (getLibraryViewSettings), so an existing bookshelf looks
unchanged until the user picks a library texture, then decouples
per-field. No migration needed; the selection stays per-device like
the reader's, while imported images keep syncing via the texture kind.
- Make the Color panel's Background Image picker context-aware: opened
from the library it edits the library texture, opened while reading it
edits the reader texture. A sublabel states which page it applies to.
- Apply the library texture at boot and on every library mount, so
returning from a textured book restores the bookshelf background.
- useBackgroundTexture now unmounts on 'none' instead of early-returning,
since library and reader share one style element: switching a page to
None must clear a texture the other page mounted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* i18n: translate library/reader background texture labels (#4743)
Add translations for the two new context sublabels ("Applies to the
Library" / "Applies to the Reader") across all 33 locales, anchored to
each locale's existing Library and reading terminology.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
OPDS 2.0 feeds may list a publication in summary form (title, cover, and
only a rel="self" link of type application/opds-publication+json), serving
the full record (acquisition links, description, publisher, subjects) only
when the client follows that link on click, as Thorium does. Readest
ignored it, so such books showed no download option and no description.
Add opdsPublication.ts with getPublicationDetailHref and
parsePublicationDocument (OPDS 2.0 JSON and Atom entry, absolutizing
link/image hrefs), and dereference the link in the OPDS page when a
feed-selected summary advertises one, upgrading the detail view in place
once it loads.
Also render an OPDS 2.0 JSON HTML description as sanitized markup instead
of literal tags by falling back from the typed content to the plain
description string in getOPDSDescriptionHtml.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On macOS a trackpad pinch-to-zoom is delivered as a rapid stream of
ctrl+wheel events. The zoomed image kept its 0.05s transform transition
during that stream, so each event restarted the in-flight transition
from its interpolated mid-point and the image lagged and flickered. This
is the same root cause as the #4451 pan flicker, which was fixed by
dropping the transition during the gesture for the pan and touch-pinch
paths; the wheel-zoom path was the only continuous gesture left with the
transition on.
Suppress the transition while a wheel-zoom gesture is streaming, cleared
on a short debounce since wheel has no explicit gesture-end event.
Discrete zoom (buttons, double-click, keyboard) keeps its smoothing.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A recent change (#4407) made Android's "Open with Readest" (VIEW intent,
used by Telegram and similar apps) always open the file as a transient
book: into the reader but never written to the library, with its filePath
pointing at the original content:// URI. Once that temporary URI grant
dies the book can no longer be reopened, so the user has to re-share it
from the source app every time.
Make the transient behavior an opt-out gated by the existing
autoImportBooksOnOpen setting, and surface it on mobile:
- The VIEW handler now consults the setting via a new shouldOpenTransient
predicate. When auto-import is on it falls through to the same library
ingest path as a share-sheet SEND (full ingest plus cloud upload); when
off it keeps the transient open.
- Read the setting from disk in the handler rather than the settings
store, since on a cold-start "Open with" the store is not hydrated yet
and would wrongly fall back to a transient open.
- Show the "Auto Import on File Open" toggle on mobile (was desktop only).
- Default autoImportBooksOnOpen to true on mobile so shared files persist
and sync by default; the desktop default is unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): require a still-hold before instant-highlight on touch
Instant Highlight (the highlighter quick action) engaged on every
pointer-down over text, calling preventDefault, which swallowed the
single tap / swipe that turns the page on Android. Tapping the side
margins still worked only because they are not selectable text; the
synthetic-click fallback was also dead on Android (native touchend
calls handlePointerUp with no event).
Gate engagement behind a 300ms still hold for touch/pen: a tap
releases first and a swipe moves first, so both fall through to
pagination, and only a deliberate still hold starts drag-to-highlight.
Mouse input keeps engaging immediately (click vs. press-drag is
already unambiguous).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(agent): update agent memories
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(wordlens): support en-en monolingual glosses
Gloss difficult English words with a short English definition for learners
reading English with English hints.
- build: buildEnEn + shortDefGloss read ECDICT's English `definition` column
(first/primary WordNet sense, POS-stripped, drop ;-example, <=24 word-boundary
with trailing-connector trim). New `en-en` CLI branch; buildEnZh/buildEnEn now
share a buildEnPack core.
- gating: drop the hardcoded `hint === source` rejections (wordlensSection,
WordLensPanel) so same-language packs are allowed; availability is decided by
the manifest (resolvePack returns null when no pack exists).
- data: data/wordlens/en-en.json (26,578 entries) + regenerated manifest.json.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(wordlens): gloss styling, derivation lemmas, display-time cap
Builds on the en-en monolingual gloss support with refinements and
regenerated packs.
- settings: per-book gloss <rt> font size (em) and color in
Settings > Language > Word Lens (getRubyStyles reads viewSettings).
- en-en hints: WordNet hybrid (a simpler synonym, else a category
hypernym, else the ECDICT definition) instead of raw verbose
definitions.
- lemmatization: gate difficulty by the lemma rank for every English
source pair. enBaseFormCandidates now also covers -able/-ible suffixes
and negative prefixes (un/in/im/ir/il), so insufferable resolves to
suffer. A candidate is accepted when the English definition names the
base OR the Chinese translations share a content character, which keeps
true derivations (insufferable -> suffer) and rejects coincidental
stems (capable -> cap). en-X packs inherit the en-en lemma table.
- display cap: the max gloss length is applied at render time in
cleanGloss (MAX_GLOSS_LEN), so the packs store the full hint and the
cap can change without regenerating data.
- tooling: pnpm wordlens:preview to sample pack entries; cache build
corpora under data/wordlens/.sources (gitignored).
- data: regenerate en-en, en-zh and en-de/es/fr/pt/ru plus the manifest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In scrolled mode the header chapter title was wrong while transitioning
between sections, while paginated mode was correct (#4436). foliate-js
#getVisibleRange returned the first overlapping view (topmost in scroll
order), so when the tail of one section was a thin sliver at the top of
the viewport and the next section occupied the centre and most of the
screen, the relocate event reported the sliver's section — and its title
lagged behind what the reader was reading.
Bump foliate-js to prefer the view covering the viewport centre
(readest/foliate-js#37) and add a browser-lane regression test that
scrolls a sliver of section K to the top with K+1 across the centre and
asserts the relocate index is K+1.
Fixes#4436
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Support standard desktop shortcuts for refining an active text selection:
Shift+Left/Right by character, Ctrl/Option+Shift+Left/Right by word. Only
active while text is selected; otherwise the keys fall through to page
navigation as before.
Root cause: after a selection the reader container (not the book iframe)
holds focus, so Shift+Left/Right keystrokes reach the parent shortcut
handler and matched the page-turn shortcuts, turning the page instead of
refining the selection.
The new onAdjustTextSelection action runs before the navigation actions:
when a selection is active it extends the iframe selection via
Selection.modify() and suppresses the page turn; an iframe-forwarded key
(already extended natively) just suppresses navigation. handleSelectionchange
now refreshes the popup/range for keyboard-driven changes (no pointer drag)
so the selection toolbar follows the refined selection.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Android 13+ recolors the adaptive icon's `<monochrome>` layer with a
wallpaper-derived tint when the user enables themed icons. Support was added
in #2122/#2153 (the `ic_launcher_monochrome.png` assets) but #2353 ("fixed
launcher icon size") rewrote the committed adaptive icon to inset the
foreground 22% and silently dropped the `<monochrome>` layer, so themed icons
stopped working in shipped builds.
Restore it by re-adding a `<monochrome>` layer (same 22% inset as the
foreground) and shipping the monochrome mipmaps. The CI/release flow
regenerates `gen/android` (`tauri android init` + `tauri icon`) then
`git checkout .` to restore tracked customizations; `tauri icon` does not emit
a monochrome layer, so the mipmaps are force-committed under `gen/` like the
other customized resources.
The monochrome artwork is redesigned: Android tints the layer via SRC_IN
(alpha only), which flattened the old desaturated-logo asset into a solid
blob. A narrow vertical center gap now splits the open book into two pages
with a visible spine while keeping the bookmark, so the mark keeps its
character when themed.
Verified on a Pixel 9 Pro emulator (Android 36) with Themed Icons enabled, and
guarded by src/__tests__/android/themed-icon.test.ts.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The annotator's foliate `load` handler (onLoad) attached a renderer
`scroll` listener and, on Android, a global `native-touch` dispatcher
listener on every section load. Both the renderer and the eventDispatcher
outlive individual sections — and foliate fires `load` for preloaded
neighbour sections too — so these listeners accumulated without bound, one
set per chapter. Each renderer `scroll` (fired on every paragraph-mode
`goTo`) then ran all of them, and on Android the scroll/native-touch
handlers do real work. Reading a long book (e.g. a 3000-chapter web novel)
in paragraph mode slowed down steadily after a few chapters and only an
app restart cleared it.
Register these listeners once per view via a new `useRendererInputListeners`
hook with cleanup, instead of once per section load. The native-touch
handler now resolves the CURRENT primary section's doc/index at fire time
rather than capturing a (possibly off-screen, preloaded) section's. The
redundant `scroll` → `repositionPopups` listener is dropped — a dedicated
effect already repositions popups on scroll. Doc-scoped listeners stay in
onLoad, since they die with the section's iframe.
Add useRendererInputListeners unit tests covering register-once-per-view,
no-accumulation-across-re-renders, latest-handler routing, Android gating,
and unmount cleanup.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expose `::part(dict-content)` on the MDict shadow content and add a
dictionary popup font-size setting (Settings → Language → Dictionaries),
independent of the main reading view.
- mdictProvider: tag the in-shadow body with `part="dict-content"` and a
stable `dict-shadow-host` class so the popup's `::part()` rule can reach
across the shadow boundary — MDict is the only provider that renders into
a shadow root, so ordinary popup CSS can't touch it.
- DictionarySettings.fontScale (default 1) with setFontScale + load-merge;
synced cross-device via the `dictionarySettings.fontScale` whitelist entry.
- DictionaryResultsView drives `--dict-font-scale` + `data-dict-content` on
each per-tab container. globals.css re-bases the light-DOM Tailwind text
utilities to `em` within that scope and sizes the MDict shadow body via
`::part(dict-content)`, so every provider scales from one lever.
- SettingsSelect control (85–175%) in the Dictionaries panel.
Tests: jsdom unit tests (part attribute, store fontScale, sync whitelist)
plus a real-Chromium browser test for the em-rebasing + `::part` + custom-
property-inheritance CSS contract jsdom cannot model.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: design for syncing updated book data (cover + file) (#4544)
Cover-change sync via a content hash (coverHash = partial MD5 of cover.png)
plus a cover_updated_at field-level merge timestamp; file updates ride the
existing re-import / metaHash dedupe.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sync): sync updated book covers across devices (#4544)
Editing a book's cover wrote cover.png locally but changed no hash (the
cover is keyed by the file hash), so peers had no signal to re-download it
and the change never propagated.
Give the cover its own content-addressed version:
- coverHash = partial MD5 of cover.png; a peer re-downloads the cover iff
the synced hash differs from the local one (idempotent, no churn on
identical/re-extracted covers — compatible with the metaHash dedupe).
- coverUpdatedAt = field-level LWW timestamp so a page-turn that wins
whole-row LWW on updated_at can't clobber a cover edit (mirrors the
reading_status_updated_at fix for #4634).
Editing a cover recomputes the hash, bumps coverUpdatedAt, and re-uploads
only the cover; the server merges cover fields independently; peers
re-download on a hash diff. File updates continue to ride the existing
re-import / metaHash dedupe (changed file -> changed hash -> re-key).
Migration 016 adds cover_hash / cover_updated_at to books.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bumps foliate-js to drop the redundant manual `scrollBy` the scrolled-mode
page iframes ran on every wheel event. Because those iframes are
`scrolling="no"`, the browser already chains the wheel to the host scroller
natively; the extra scrollBy stacked on top, so wheeling over a page moved
it ~2x as far in an instant lurch while the margins scrolled smoothly by one
notch. Native scroll-chaining now provides the single smooth scroll over both
the page and the margins.
Adds a browser-lane regression test that mounts the real <foliate-fxl>
renderer in scrolled mode and asserts a wheel over a page does not
programmatically move the host scroller.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(library): keep in-place book paths absolute so uploads stay in fs scope (#4720)
resolveFilePath joined `${prefix}/${path}` unconditionally. For base 'None'
(in-place / external books, whose filePath lives outside Books/<hash>/) the
prefix is empty, so an already-absolute source path became `/C:\Users\...`
on Windows (and `//Users/...` on POSIX). The native upload guard added in
#4639 (transfer_file.rs `ensure_path_allowed`) then rejected that malformed
path as "permission denied: path not in filesystem scope", so uploading a
folder-imported book failed on Windows.
Return the path verbatim when the prefix is empty.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(library): use btn-contrast for the Import-from-folder confirm button (#4720)
Aligns the dialog's confirm CTA with the sibling import dialogs
(ImportFromUrlDialog, FailedImportsDialog), which already use the
theme-neutral, e-ink-correct btn-contrast instead of the colored
btn-primary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three paragraph-mode problems, all fixed:
- Shift+P inside paragraph mode flashed and re-entered (and pressing it
repeatedly did nothing). A single keypress toggled twice: eventDispatcher
.dispatch() iterated the live listener Set while awaiting each listener, and
the exit's awaited dispatch('paragraph-mode-disabled') let React re-run
useParagraphMode's subscription effect mid-loop, adding a handler the same
dispatch then called. Snapshot the listeners before iterating (dispatchSync
already did), so a listener added during a dispatch can't fire for the
current event.
- Shift+P / Escape only worked when focus sat on the overlay. Handle the
overlay's keys the way a dialog/alert does: focus the dialog element on open
and handle Escape / the toggle shortcut / paragraph navigation in its own
onKeyDown (stopping propagation so the global handler can't double-fire),
instead of a global window listener. Suppress the focus ring on the
programmatically-focused, non-tab-stop container.
- Resume jumped to the chapter start, and repeated enter/exit walked further
back. Two causes: (a) entering/exiting scrolled the underlying view to the
focused paragraph's start, which rewinds a page when that paragraph began on
the previous page — don't scroll on resume/exit (the paragraph is already on
screen); navigation still scrolls. (b) resume preferred the rAF-debounced
store progress and a stored last-paragraph CFI that can come out malformed
and resolve to an empty range, shadowing the correct candidate and sending
findByRange to the first block. Resume from the view's live, foliate-
generated lastLocation CFI first (set synchronously on every relocate,
resolved against the current document so it survives iframe recreation).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Urdu (ur) to TRANSLATOR_LANGS so it appears in the Translate Text
language list (inline TranslatorPopup and Settings → Translation). The
list is not provider-gated, and Google/Azure/Yandex all translate to
Urdu; Urdu is already in MIGHT_BE_RTL_LANGS so the translated output
renders right-to-left.
Closes#4721
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On Windows/Linux, Ctrl+P opens the proofread/replace rules but also
triggers the browser print dialog, since the selection shortcut handlers
return undefined and never preventDefault. Add a print-free `alt+p`
binding for Proofread Selection alongside ctrl+p/cmd+p.
Also fix Shift+P being unable to exit paragraph mode: the paragraph
overlay attaches a capture-phase keydown listener that calls
stopImmediatePropagation() on every key while visible, so the global
toggle shortcut never reached useShortcuts. Honor the configured
"Toggle Paragraph Mode" shortcut directly in the overlay so the same
shortcut that enters paragraph mode also exits it.
Extract the shared shortcut event-matching into matchesShortcut() in
utils/shortcutKeys.ts and reuse it from useShortcuts instead of its
private duplicate.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Android System TTS (and iOS) read-aloud could stop offline and refuse to
continue — #4613 "stops at the end of the chapter, won't advance" and #4408
"stops at random intervals" — after which the play/headphone controls felt
wedged.
Root cause: `TTSController.#speak` only auto-advances when the last event code
is `end`. The native client surfaces an offline engine failure as a terminal
`error` code (Android `UtteranceProgressListener.onError`). This typically
happens on a specific utterance the offline engine can't synthesize — e.g. an
unsupported character — characteristically the first utterance after a chapter
boundary, even with a local/offline voice (online, engines often fall back to
network synthesis, which is why it only breaks offline). On `error` the
controller never called `forward()` and left `state` stuck at `playing`, so
playback dead-ended and the controls couldn't recover. Edge/Web clients throw
instead (handled by `error()`), so only the native client hit this.
Fix (native-scoped, no change to the Edge/Web path): when the active client is
the native client and an utterance ends with a terminal `error` (still playing,
not aborted, not one-time), skip that chunk and advance just as a normal `end`
would — re-speaking the same unsynthesizable text would only fail again. A
consecutive-error cap stops playback gracefully if the engine can't speak
anything, so a wholly-unusable engine doesn't silently race to the end of the
book and the state machine always leaves `playing`.
Tests: tts-controller covers skip-on-error advancing past a bad chunk, and the
consecutive-error cap stopping gracefully (bounded, not wedged in playing).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): decouple the incremental-pull cursor from updated_at (#4678)
`books.updated_at` was overloaded as both the incremental-pull cursor
(`GET /api/sync?since=…` filters `updated_at > since`, devices keep one
global `max(updated_at)` watermark) and the library "date read" sort key.
A server-resolved reading-status merge had to be written with a timestamp
greater than every peer's global cursor to propagate, which forced
`updated_at = now()` and reordered the date-read library by sync-processing
time (the #4677 symptom).
Introduce a server-assigned `synced_at` column on `books`, stamped by a
`BEFORE INSERT OR UPDATE` trigger on every write, used only as the pull
cursor. `updated_at` stays pure client event time used only for sorting.
- Migration 016 + baseline schema: add `synced_at` (NOT NULL DEFAULT now()),
index `(user_id, synced_at)`, trigger `set_books_synced_at`. Backfill
`synced_at = updated_at` before creating the trigger so existing devices'
cursors hand over without a re-sync storm.
- GET: books filters/orders on `synced_at > since` (a delete bumps synced_at,
so the deleted_at clause is dropped); configs/notes stay on updated_at.
- POST: extract `buildStatusPropagationRow` and drop the `updated_at = now()`
bump — the trigger advances synced_at so peers re-pull the status change
while updated_at (the sort key) stays put.
- Client `computeMaxTimestamp` keys on synced_at, falling back to
updated_at/deleted_at for pre-migration servers and configs/notes.
Backward-compatible: `synced_at >= updated_at` always, so `synced_at > since`
is a strict superset of `updated_at > since` — old web clients and the
koplugin keep working with no data loss (at worst a redundant idempotent
re-pull of rare server-merged rows). The koplugin's shared pull/push cursor
is left untouched; a proper split is a follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sync): make the books synced_at backfill safe for large live tables (#4678)
The single `UPDATE … WHERE synced_at IS NULL` deadlocked on a 3.8M-row
production `books` table: it rewrites every row in one transaction while the
live /api/sync push path upserts books rows, and the two lock rows in opposite
orders. `ALTER COLUMN … SET NOT NULL` (full-table ACCESS EXCLUSIVE scan) and a
plain CREATE INDEX (write-blocking SHARE lock) compounded it.
Rework migration 016 as an online migration (run via psql, not in a wrapping
transaction):
- backfill in small autocommitted batches via a procedure, using
FOR UPDATE SKIP LOCKED so it never waits on an app-locked row;
- CREATE INDEX CONCURRENTLY instead of a blocking build;
- install the trigger last (so it can't clobber the updated_at backfill);
- drop the hard SET NOT NULL (the default + trigger + backfill keep the column
populated and the client falls back to updated_at); a NOT VALID CHECK +
VALIDATE alternative is included, commented, for operators who want it.
The baseline schema.sql (fresh, empty installs) keeps the simple inline
NOT NULL DEFAULT now() + trigger.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In a PDF two-page spread at a fractional devicePixelRatio (Windows display
scale 150% -> dpr 1.5), a one-pixel white bar appeared at the spine on certain
zoom levels. foliate-js' pdf.js sized the page canvas only via its bitmap, so
the fractional viewport width was truncated and the canvas rendered up to ~1
device pixel narrower than the page box, exposing the background at the spine.
Bump the foliate-js submodule to the fix (readest/foliate-js#35) which pins an
explicit canvas CSS size to the un-truncated viewport dimensions, and add a
regression test that drives render() at dpr 1.5 and asserts the canvas fills
its box exactly.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When "page turn with volume buttons" is enabled, the volume keys were
intercepted for the whole reading session, so switching from reading to
TTS left them flipping pages instead of adjusting playback volume.
Gate the volume-key interception on this book's TTS playback state
(via the existing `tts-playback-state` bus): release interception while
TTS is playing so the OS handles volume, and re-acquire it when TTS is
paused or stopped. The acquire/release pair is keyed on the playback
state so the deviceStore reference count stays balanced.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A wrong Server URL can land on the host's static web UI, which answers
200 OK with an HTML page. connect() treated any 2xx from /users/auth (or
/users/create) as a successful login, so the user was silently
"connected" to an endpoint that can never sync: pulls report 0% and
pushes fail with no error surfaced. This is the root of the symptom in
#4692 (KOReader sync to a Grimmory/Booklore server failing on Android).
Validate that the auth/registration response is an actual KOReader Sync
JSON object (a real server replies e.g. {"authorized":"OK"}; an HTML
page fails JSON parsing) and otherwise return a clear
"Not a KOReader Sync server. Check the Server URL." message.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an always-visible, opt-in progress bar with chapter tick marks in the
persistent footer, so reading progress no longer disappears like the hover
footer slider does.
- New StickyProgressBar: a 1px rounded-border capsule with a fill and
chapter tick marks; display-only, e-ink aware, and RTL safe. Ticks render
inside the clipped track so the rounded ends crop them and they never
exceed the border.
- Chapter ticks come from the TOC, mapped to spine-section start fractions
(getChapterTickFractions); the first and last ticks are trimmed so they
do not crowd the rounded ends.
- Thread the overall size-domain reading fraction through setProgress so the
bar fill aligns with the tick domain.
- Footer layout: when enabled the bar grows on the left and the info widgets
group to the right with even spacing; otherwise the existing layout is
unchanged.
- Horizontal writing mode only; vertical keeps the current footer.
- Add the showStickyProgressBar view setting, a LayoutPanel toggle, and i18n.
Closes#1616.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Sync library-scope replacement rules across devices (settings whitelist).
Book and selection rules already ride along the book config.
- Add regex support: a Regex toggle on the selection popup plus a full
add-rule form (find / replace / scope / regex / case-sensitive) in the
Proofread Rules manager.
- Reuse Ctrl/Cmd+P to open the rules manager when nothing is selected
(handleProofread); opens the create-from-selection popup otherwise.
- Translate the whole-word warning, which was hardcoded English.
- Drag-to-reorder rules within each category (dnd-kit), persisted via the
rule order field across both the book config and global settings.
- Modernize the manager dialog with the settings primitives and a
btn-contrast CTA; fix mobile height clipping and the inset scrollbar.
- Translate all pending i18n strings across 33 locales (includes the
delete-confirm strings surfaced by the extractor).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A backup .zip exported on Windows failed to restore on every platform
(Web, Android, Windows): books restored with metadata but no files or
covers.
`appService.readDirectory` returns paths using the host separator, so on
Windows `file.path` is `hash\cover.png` (backslash). `addBackupEntriesToZip`
used that verbatim as the zip entry name, so entries were named with `\`.
Restore matches a book's files with `filename.startsWith(`${hash}/`)`
(forward slash), which never matched the backslash names, so every book
file was silently skipped.
Normalize the zip entry name to forward slashes when adding files. Entry
names are now cross-platform and restorable everywhere. Already-exported
broken backups need re-exporting from the fixed app.
Fixes#4703
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the standalone "Purge Data" menu item with an opt-in toggle on the
delete confirmation alert (default off). When enabled, the delete escalates
to a full purge that also wipes the book's reading-data sidecars (config and
nav), instead of leaving the metadata folder behind. The single, bulk, and
multi-select deletes all share the same alert, so this also covers batch
deletes that previously kept every metadata folder.
- Alert: add optional children, confirmLabel, confirmButtonClassName slots
- DeleteConfirmAlert: new wrapper owning the toggle and red escalation
- BookDetailView: drop the Purge Data menu item and onPurge prop
- BookDetailModal: route the standard delete to purge when the toggle is on
- Bookshelf/page: route the bulk delete batch to purge when the toggle is on
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(payment): observability for store subscription webhooks
Add monitoring for the App Store / Google Play webhooks so store-side
subscription changes are observable on Cloudflare, where stored Workers
Logs are head-sampled at 1% and would miss almost all low-volume webhook
events.
- Add iap/telemetry.ts: every webhook invocation emits a structured log
line (streamed in full by `wrangler tail`) and a Cloudflare Analytics
Engine data point (100% capture, independent of log sampling). Writes
no-op off the Worker runtime, mirroring the getCloudflareContext guard
in deepl/translate.ts.
- Instrument both webhook routes to record outcome (handled, skipped,
rejected, error), notification type, status, reason, and latency on
every return path.
- Add GET /api/cron/iap-reconcile: a CRON_SECRET-protected sweep that
counts drift (rows still active while their store expiry has passed = a
missed webhook) in both IAP tables and records a reconcile metric.
Detection-only; never mutates state.
- Add the IAP_WEBHOOK_AE Analytics Engine binding to wrangler.toml.
New configuration: CRON_SECRET (reconcile auth) and an iap_webhooks
Analytics Engine dataset bound as IAP_WEBHOOK_AE. The reconcile route is
triggered on a schedule (a Cloudflare Cron Trigger worker that fetches
the URL, or any external scheduler) with an Authorization bearer header.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(payment): move reconciliation to a dedicated cron Worker
Replace the public CRON_SECRET-protected /api/cron/iap-reconcile route
with a dedicated Cloudflare Cron Worker. A Cron Trigger invokes the
worker's scheduled() handler directly, so there is no public HTTP surface
and no shared request secret to manage - the strongest option on
Cloudflare (OpenNext's generated worker only exports `fetch`, so the main
worker cannot host a scheduled() handler).
- Add workers/iap-reconcile: a self-contained worker (own package.json,
tsconfig, wrangler.toml) matching the existing workers/send-email
convention, registered in pnpm-workspace.yaml. Hourly Cron Trigger;
reads the IAP tables via the Supabase service role and records a drift
metric to the shared iap_webhooks Analytics Engine dataset.
- Reconcile logic lives in workers/iap-reconcile/src/reconcile.ts and is
unit-tested from the app suite.
- Remove the public route and its test; drop the now-unused
recordIapReconcile from iap/telemetry.ts (webhook telemetry unchanged).
Configuration: set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY as secrets
on the worker and deploy it with `wrangler deploy` from its directory.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add server-push endpoints so store-side subscription changes (cancel,
refund, expire, renew, grace period) are reflected in the database, not
only the in-app verification flow. Previously only Stripe had a webhook.
- POST /api/apple/notifications: verify and decode App Store Server
Notifications V2, resolve the user by original_transaction_id, map the
notification type to a status, and update the subscription and plan. A
single endpoint serves Sandbox and Production. Refunded one-time
purchases are marked refunded and storage is recomputed.
- POST /api/google/notifications: verify the Pub/Sub shared-secret token,
decode the RTDN, resolve the user by purchase_token, re-verify against
the Play Developer API (overriding the status for terminal events such
as REVOKED/EXPIRED and grace period), and handle voided purchases.
- Add an isEntitledStatus helper and reuse the existing
createOrUpdateSubscription and plan-update logic shared with Stripe.
New configuration: GOOGLE_RTDN_VERIFICATION_TOKEN (shared secret in the
Pub/Sub push URL) and the optional GOOGLE_IAP_PACKAGE_NAME; Apple reuses
APPLE_IAP_BUNDLE_ID and the existing service-account credentials.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>