feat: sync reading status across devices and with KOReader (#4634) (#4656)

* docs(sync): design spec for syncing reading status (#4634)

Field-level LWW for reading_status (dedicated reading_status_updated_at),
a new 'abandoned' status in the Readest UI, and a koplugin bridge to
KOReader's native summary.status (whole-library apply + capture).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(sync): implementation plan for syncing reading status (#4634)

Bite-sized TDD tasks across 3 parts: A) cloud field-level LWW
(reading_status_updated_at on server upsert + client pull-merge),
B) 'abandoned'/On-hold status in the Readest UI, C) koplugin bridge
to KOReader summary.status (mapping + reconcile + whole-library
apply/capture).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): add reading_status_updated_at for field-level status LWW (#4634)

* feat(sync): stamp readingStatusUpdatedAt on status change in updateBookProgress

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): stamp status timestamp on explicit library status edits

* feat(sync): resolve reading status by its own timestamp in client pull-merge

* feat(sync): resolve reading status by its own timestamp in server upsert (#4634)

* fix(sync): tighten reading-status merge typing + strengthen test (A5 review)

Replace as-unknown-as double-casts at read sites with typed locals
(clientBook/serverBook); retain a single as-unknown-as only at the
server-wins construction site where the static type is too narrow.
Strengthen test 3 to assert both fields with toEqual.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(library): render the 'On hold' (abandoned) status badge

* feat(library): add 'Mark as On hold' actions + i18n for abandoned status

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* i18n: translate 'On hold' and 'Mark as On hold' for the abandoned status (#4634)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(koplugin): add reading-status mapping + reconcile between Readest and KOReader

* feat(koplugin): persist + sync reading_status_updated_at in LibraryStore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(koplugin): bridge reading status to KOReader summary.status on library sync (#4634)

* test(sync): cover koplugin v1->v2 migration + tighten status-sync tests (final review)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(sync): redesign KOReader first-sync (decisive-only + bootstrap) (#4634)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(koplugin): safe first-sync of reading status (decisive-only + bootstrap) (#4634)

KOReader auto-sets summary.status='reading' on first open, and legacy Readest
statuses have reading_status_updated_at=0, so pure timestamp LWW let opening a
finished book downgrade it. Restrict sync to deliberate statuses (finished/
complete, abandoned/on-hold, unread->clear); never capture KO 'reading'/'New'.
On the unsynced baseline (Readest ts=0) conflicts resolve Readest-authoritative,
then stamp now_ms to exit bootstrap into steady-state LWW. reconcile now returns
write_ko/write_store flags; statussync captures now_ms once and equalizes both
sides (convergent, idempotent, resumable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(koplugin): cover remaining first-sync graph cells + document sort effect (review)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-19 11:00:40 +08:00
committed by GitHub
parent e00a1e4f06
commit 72233e1c6a
65 changed files with 2786 additions and 38 deletions
@@ -0,0 +1,348 @@
# Sync Reading Status — Design
Issue: [#4634](https://github.com/readest/readest/issues/4634) — "FR: Sync reading status"
## Problem
A book's reading status (unread / reading / finished) does not reliably sync
across devices, and does not sync with KOReader. The reporter changes a book's
status near the end of reading (~95%) and the change is lost on other devices.
### Root cause (cross-device cloud sync)
Reading status already has full sync plumbing:
- `ReadingStatus` type and `Book.readingStatus` (`src/types/book.ts:19,114`)
- `books.reading_status` column (`docker/volumes/db/init/schema.sql:19`)
- Two-way mapping in `src/utils/transform.ts:97,137`
- The `book` sync category is **on by default** (`src/services/constants.ts`)
The data travels, but the **merge clobbers it**. The `books` row carries two
independently-edited fields under one `updated_at`:
- `reading_status` — rare, intentional edits (library context menu / auto-finish)
- a denormalized `progress` — bumped on **every page turn**
(`updateBookProgress``book.updatedAt = Date.now()`, `readerStore.ts:399`,
`libraryStore.ts:114`)
Both the server upsert (`src/pages/api/sync.ts:362-387`) and the client
pull-merge (`src/app/library/hooks/useBooksSync.ts:128-132`) use **whole-row
last-writer-wins** keyed on that single `updated_at`. So a device that is
actively reading (fresh `updated_at`, stale status) overwrites a deliberate
"finished" set on another device. This is exactly the "change status at ~95%"
scenario: the book is being read on more than one device, so progress updates
dominate the timestamp and win the whole row.
(The progress→books "piggyback", `sync.ts:436-488`, deliberately writes only
`progress` + `updated_at` and leaves `reading_status` intact — partial
protection, but nothing protects against the library's whole-row push.)
### Root cause (KOReader)
The `readest.koplugin` already round-trips the `books` table — including
`reading_status` — through its `LibraryStore` SQLite
(`apps/readest.koplugin/library/librarystore.lua:38`, wire mapping at
`syncbooks.lua:150` / `librarystore.lua:747`). The missing bridge is between
the `LibraryStore.reading_status` and KOReader's **native per-book status**
(`summary.status` in the `.sdr` DocSettings sidecar). Pulled status is never
applied to `summary.status`, and KOReader status changes are never captured
back into the store.
## Goals
1. A reading-status change on one Readest device reliably propagates to others
and is never clobbered by an orthogonal reading-progress update.
2. Reading status round-trips between Readest and KOReader: marking a book
finished/reading/on-hold in either reflects in the other.
3. Backward compatible: older clients keep working; the change is additive.
## Non-goals
- Changing how reading **progress** (position/CFI) syncs.
- A separate sync-category toggle for status (it stays under `book`).
- Real-time push; existing throttle/debounce cadence is fine.
## Decisions (locked with maintainer)
- **Field-level LWW** via a dedicated `reading_status_updated_at` timestamp
(additive nullable column). Chosen over "stop bumping updatedAt on page turns"
because only a per-field timestamp preserves **both** status and progress;
the alternative merely relocates the clobber to the denormalized progress bar.
- **Add an `abandoned` status to Readest** so KOReader's `abandoned` ("On hold")
round-trips losslessly, rather than collapsing it.
- **Whole-library apply** in KOReader: pulled status is written to every
matching local book's sidecar (resolved via `LibraryStore.file_path` /
`local_present`), not just the currently-open book.
## Data model & merge rules
### Status value set
`ReadingStatus = 'unread' | 'reading' | 'finished' | 'abandoned'`
(`undefined`/absent = no explicit status, rendered as a plain progress bar).
### Readest ⇄ KOReader status mapping
KOReader stores `summary.status ∈ { "reading", "abandoned", "complete" }`
(unopened books have no sidecar and render as "New"; there is no "unread").
**Only deliberate statuses sync.** KOReader auto-sets `summary.status = "reading"`
the *first time a book is opened* — that is not a user decision, so treating it
as a syncable status would let merely opening a finished book downgrade it.
Therefore:
- **Decisive (sync):** Readest `unread`, `finished`, `abandoned`; KOReader
`complete`, `abandoned`.
- **Non-decisive (ignored as a status signal):** Readest `undefined`/`reading`
(Readest never even sets `reading` explicitly); KOReader `reading` (auto) and
`New`/absent.
Reading *position* still syncs via the separate progress channel — this section
is only about the status badge.
| Readest | → KOReader `summary.status` | KOReader | → Readest |
| ------------ | --------------------------- | --------------- | -------------- |
| `finished` | `complete` | `complete` | `finished` |
| `abandoned` | `abandoned` ("On hold") | `abandoned` | `abandoned` |
| `unread` | clear (→ "New") | `reading`/`New` | — (no opinion) |
| `undefined` | — (leave sidecar) | | |
`unread` in Readest means **"not started / reset"**: it intentionally hides the
progress bar *and* any badge (`SHOW_UNREAD_STATUS_BADGE = false`,
`ReadingProgress` renders nothing — decision from `#3103`/`c58e172a5`), and the
reader clears it back to `undefined` the moment the book is opened
(`readerStore.ts:393-394`). Clearing KOReader's status (→ "New") is its faithful
equivalent; KOReader can't tell "deliberately marked not-started" from "never
opened", which is harmless.
#### First-sync transfer graph (the unsynced baseline)
On the baseline — a book whose Readest `reading_status_updated_at` is `0`/absent
(status predates this feature, or was pulled before it) — timestamps are not
trustworthy, so conflicts resolve **Readest-authoritative**:
| Readest ↓ \ KOReader → | `New`/`reading` (auto) | `complete` | `abandoned` |
| ---------------------- | ---------------------- | ----------------------- | ----------------------- |
| `undefined` | — (nothing) | capture → `finished` | capture → `abandoned` |
| `unread` | push → clear KO | Readest wins → clear KO | Readest wins → clear KO |
| `finished` | push → KO `complete` | agree | Readest wins → `complete` |
| `abandoned` | push → KO `abandoned` | Readest wins → `abandoned` | agree |
The two reported cases fall out of this graph: a `finished`-in-Readest book that
is *opened* in KOReader hits the `finished × reading` cell → **push down to
`complete`, no downgrade**; a `reading`-in-KOReader / `undefined`-in-Readest book
hits `undefined × reading`**nothing synced**.
### Field-level last-writer-wins
`reading_status` is merged by `reading_status_updated_at` (ms), **independent**
of the row's `updated_at`. All other `books` columns keep whole-row LWW on
`updated_at`/`deleted_at` (unchanged). Concretely, when merging two versions of
a books row:
- pick the base row by the existing whole-row LWW (`updated_at`/`deleted_at`);
- then override `reading_status` + `reading_status_updated_at` with whichever
side has the greater `reading_status_updated_at`.
This must run on **both** ends (server upsert and client pull-merge) so neither
direction can clobber the field. A missing `reading_status_updated_at` is
treated as `0` (oldest), so legacy rows never beat a real status edit.
### Timestamp stamping rule
`reading_status_updated_at` is set to "now" **only when `reading_status`
actually changes** — never on a pure progress update. Sources:
- Readest explicit edits (`Bookshelf.tsx` status handlers, `SetStatusAlert`).
- Readest auto-status in the reader (`readerStore.ts`: `unread`→cleared on open,
`finished` at 100%) — stamped inside `updateBookProgress` only when the
status arg differs from the existing value.
- KOReader: a *captured* decisive status (`complete`/`abandoned`) is stamped
with `summary.modified` parsed to day-ms (KOReader's own change date), falling
back to the sync time if absent. (Day-granularity; see Known limitations.)
- Bootstrap exit: the first reconcile of a book that has a decisive status but a
`0`/absent Readest `reading_status_updated_at` stamps it with the sync time, so
every later change resolves by ordinary LWW instead of the Readest-authoritative
baseline rule (see First sync below).
## Part A — Cloud field-level LWW (Readest web/app)
**Schema** (`docker/volumes/db/init/schema.sql` + new numbered migration in
`docker/volumes/db/migrations/`, applied to prod Supabase):
```sql
ALTER TABLE public.books ADD COLUMN IF NOT EXISTS reading_status_updated_at timestamptz NULL;
```
Additive, nullable, backward compatible.
**Types**`src/types/book.ts`:
- extend `ReadingStatus` with `'abandoned'`;
- add `Book.readingStatusUpdatedAt?: number`;
- add `DBBook.reading_status_updated_at?: string` (`src/types/records.ts`).
**Transform**`src/utils/transform.ts`:
- to DB: `reading_status_updated_at: readingStatusUpdatedAt ? new Date(...).toISOString() : null`;
- from DB: `readingStatusUpdatedAt: reading_status_updated_at ? Date.parse(...) : undefined`.
**libraryStore.updateBookProgress** (`src/store/libraryStore.ts`):
- stamp `readingStatusUpdatedAt = Date.now()` iff `readingStatus !== book.readingStatus`;
otherwise carry the existing value through.
**Explicit status edits** (`src/app/library/components/Bookshelf.tsx`
`updateBooksStatus` + `handleUpdateReadingStatus`): set
`readingStatusUpdatedAt: Date.now()` alongside `readingStatus`/`updatedAt`.
**Client pull-merge** (`src/app/library/hooks/useBooksSync.ts` `processOldBook`):
after the existing `mergedBook` whole-object LWW, override
`readingStatus`/`readingStatusUpdatedAt` by the greater `readingStatusUpdatedAt`.
**Server upsert** (`src/pages/api/sync.ts`, `books` branch only): keep whole-row
LWW for the row, but resolve `reading_status` / `reading_status_updated_at` by
the field-level rule. This means a corrected row may need to be written even
when the whole row "loses" (server newer overall, client status newer, or vice
versa). Implement as a books-specific post-step so `book_configs`/`book_notes`
are untouched.
## Part B — `abandoned` status in the Readest UI
- `StatusBadge` (`src/app/library/components/StatusBadge.tsx`): `abandoned` is a
**visible badge** (label "On hold"; distinct color, eink-safe per DESIGN.md) —
treated like `finished`, **not** like the intentionally badge-less `unread`.
"On hold" is a state worth surfacing; the user always sees it.
- `ReadingProgress` (`.../ReadingProgress.tsx`): show the badge for `abandoned`
(keep the progress bar — unlike `unread`, an on-hold book has real progress).
- Context menu (`BookshelfItem.tsx`) + menu ids (`libraryUtils.ts`): add a
"Mark as On hold" action; keep "Mark as Finished" / "Mark as Unread" /
"Clear Status".
- `SetStatusAlert.tsx`: add the batch "On hold" button.
- i18n: new keys via the key-as-content flow (`docs/i18n.md`); run extraction.
Copy note: internal value is `abandoned` (matches KOReader's stored value);
display label "On hold" mirrors KOReader. Final wording is a review item.
## Part C — KOReader status sync (readest.koplugin)
**LibraryStore** (`library/librarystore.lua`): add `reading_status_updated_at
INTEGER` to the `books` schema + `BOOK_COLS`, and migrate existing DBs with an
idempotent `ALTER TABLE ... ADD COLUMN` guarded against the
already-exists error (reusing the store's schema-version path if one exists —
to be confirmed in the plan). Carry the field in `row_to_wire` (`syncbooks.lua`)
and `parseSyncRow` (`librarystore.lua`).
**Status mapping module** (new `library/readingstatus.lua`): pure, unit-tested.
`readest_to_ko` (`finished→complete`, `abandoned→abandoned`, `unread→`clear),
`ko_to_readest` (decisive only: `complete→finished`, `abandoned→abandoned`;
`reading`/`New`/unknown → `nil`), `readest_decisive`, `parse_modified_ms`, and
`reconcile(cloud, ko, now_ms)`. `reconcile` decides the winning decisive status
W (per the transfer graph: only-one-decisive → that side; both-agree → that
status; both-conflict → Readest-authoritative when the Readest ts is `0`, else
LWW) and returns `{ write_ko, write_store, readest_status, ts, ko_status }` so
the caller equalizes both sides to W. The bootstrap stamp uses `now_ms`.
**Apply + capture, whole-library**`statussync.reconcileLocalStatuses` runs in
the library sync (after `pullBooks`, before `pushChangedBooks` via the
`before_push` hook; and after pull in pull-only mode), over every row with
`local_present == 1` and a resolvable `file_path`. For each it reads the sidecar
`summary` through injected `deps` (production: `DocSettings:open(file_path)`),
calls `reconcile`, then:
- if `write_ko`: set `summary.status` (+ `summary.modified`), `flush()`, and
`BookList.setBookInfoCacheProperty(file, "status", ...)``ko_status` may be
`nil` to clear (→ "New");
- if `write_store`: `touchBook(hash, { reading_status, reading_status_updated_at })`
so `pushChangedBooks` sends the complete row (pushing through `LibraryStore`
avoids a partial books row that would null out other columns server-side).
The IO is injected via `deps` (`now_ms`, `open_summary`, `write_status`) so the
walk is unit-testable without DocSettings.
`statussync.reconcileLocalStatuses(store, deps)` walks `local_present == 1` rows,
calls `reconcile`, then performs `deps.write_status` (when `write_ko`) and
`store:touchBook` (when `write_store`). It equalizes both sides to W, so the next
pass finds no mismatch and stops — convergence holds regardless of which side won.
#### First sync & failure handling
- **Bootstrap (Readest ts `0`)** resolves conflicts Readest-authoritative, then
stamps the winning status with `now_ms` so the book leaves bootstrap; every
later change (either side) then resolves by ordinary LWW. So "Readest wins"
applies only to the *initial* reconciliation, not forever — a deliberate
KOReader status set *after* first sync correctly wins over an old Readest one.
- **Idempotent + convergent + per-book.** A failed/partial first sync just leaves
some books un-baselined; the next sync finishes them, and already-baselined
books re-evaluate to no-op. There is **no global "bootstrap done" flag** — the
per-book timestamp is the marker.
- **Non-destructive ordering.** Writes are durable before the next book; a crash
mid-book re-reconciles that book *identically* (no double-apply, no loss).
A decisive status is never downgraded to a non-decisive one in any ordering.
Cloud push is eventually-consistent via the existing `getChangedBooks`
watermark.
**i18n + tests**: no new user-facing string (status sync is silent). Busted specs
cover the mappings (decisive-only), the full transfer graph, bootstrap vs
steady-state, both reported cases, and convergence — following existing
`*_spec.lua` idioms.
## Testing strategy
Per `.claude/rules/test-first.md`, write failing tests first.
- **Part A unit (vitest)**: a merge helper test proving status survives when the
other side is whole-row-newer due to progress; transform round-trip incl. the
new field and `abandoned`; `updateBookProgress` stamps the status timestamp
only on change. Server merge covered by a focused unit on the books field-level
resolver.
- **Part B**: `StatusBadge` / context-menu tests extended for `abandoned`
(mirror existing `book-context-menu.test.ts`).
- **Part C (busted)**: decisive-only mappings; the full first-sync transfer graph;
bootstrap (Readest-authoritative + stamp-to-exit) vs steady-state LWW; both
reported cases; convergence (no oscillation across two passes).
## Verification (per `.claude/rules/verification.md`)
`pnpm test`, `pnpm lint`; `pnpm lint:lua` + `pnpm test:lua` (koplugin changed);
no `src-tauri/` changes expected (skip Rust gates). DB migration applied to the
docker schema and a new numbered migration file.
## Migration & rollout
- Additive nullable column; old clients ignore it (treated as `0`).
- New `abandoned` value: older clients that read it fall through their
`finished`/`unread` checks to the default progress-bar branch — no crash,
just no badge. Acceptable.
- koplugin `LibraryStore` schema version bump with an idempotent `ADD COLUMN`.
## Known limitations / risks
- **KOReader `reading` is never captured.** It is auto-set on first open, so
treating it as a status would downgrade a finished book. The trade-off: a
*deliberate* "I'm reading this" on KOReader is not reflected as a Readest
status — but Readest renders `reading` and `undefined` identically (a progress
bar), and reading *position* syncs via the progress channel, so nothing
user-visible is lost.
- **Decisive-vs-decisive cross-conflict on the unsynced baseline** — e.g.
`finished` in Readest *and* `abandoned` ("On hold") in KOReader, both set
before they ever synced. Resolves Readest-authoritative (per the chosen
policy); rare, and the user can re-set. After first sync, recency LWW governs.
- **KOReader status timestamp is coarse** — `summary.modified` is day-grained,
so a same-day Readest edit (real ms) beats a same-day KOReader change. Minor.
- **`unread` ⇄ KOReader "New"** — `unread` maps to clearing `summary.status`,
exactly KOReader's "New"; KOReader can't distinguish "marked not-started" from
"never opened" (harmless). On the baseline a Readest `unread` will also clear a
KOReader `complete`/`abandoned` (Readest-authoritative); rare.
- **Whole-library reconcile touches many sidecars on first sync** — bounded to
`local_present` rows; per-book conditional writes + one bootstrap stamp each.
- **First sync re-orders the koplugin Library's "recently read" view.** Each
decisive book's bootstrap stamp bumps `updated_at` (load-bearing: it's what
pushes the stamp to the cloud so the next pull doesn't reset it and re-enter
bootstrap), and the Library sort is `COALESCE(updated_at, last_read_at)`. So
status-stamped books cluster at the top once, after the first sync. One-time,
cosmetic; not engineered around in v1.
## Implementation phasing
All three parts land in **one PR** on `feat/sync-reading-status`. Implement and
verify internally in order — A (cloud LWW) → B (Readest `abandoned` UI) →
C (koplugin bridge) — since A is the core fix, B adds the value C needs to be
lossless, and C depends on both. Each step is independently testable even though
they ship together.
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,6 @@
{
"On hold": "معلق",
"Mark as On hold": "تحديد كمعلق",
"(detected)": "(تم التعرف)",
"About Readest": "حول ريديست",
"Add your notes here...": "أضف ملاحظاتك هنا...",
@@ -1,4 +1,6 @@
{
"On hold": "স্থগিত",
"Mark as On hold": "স্থগিত হিসেবে চিহ্নিত করুন",
"Email address": "ইমেইল ঠিকানা",
"Your Password": "আপনার পাসওয়ার্ড",
"Your email address": "আপনার ইমেইল ঠিকানা",
@@ -1,4 +1,6 @@
{
"On hold": "གློད་བཞག",
"Mark as On hold": "གློད་བཞག་ཏུ་རྟགས་རྒྱག",
"(detected)": "(བརྟག་དཔྱད་བྱུང་བ།)",
"About Readest": "Readest སྐོར།",
"Add your notes here...": "འདིར་ཁྱེད་ཀྱི་ཟིན་བྲིས་འདེབས་དང་།...",
@@ -1,4 +1,6 @@
{
"On hold": "Pausiert",
"Mark as On hold": "Als pausiert markieren",
"(detected)": "(erkannt)",
"About Readest": "Über Readest",
"Add your notes here...": "Fügen Sie hier Ihre Notizen hinzu...",
@@ -1,4 +1,6 @@
{
"On hold": "Σε αναμονή",
"Mark as On hold": "Σήμανση ως σε αναμονή",
"(detected)": "(εντοπίστηκε)",
"About Readest": "Σχετικά με το Readest",
"Add your notes here...": "Προσθέστε τις σημειώσεις σας εδώ...",
@@ -1,4 +1,6 @@
{
"On hold": "On hold",
"Mark as On hold": "Mark as On hold",
"LXGW WenKai GB Screen": "LXGW WenKai SC",
"LXGW WenKai TC": "LXGW WenKai TC",
"Source Han Serif CN": "Source Han Serif",
@@ -1,4 +1,6 @@
{
"On hold": "En pausa",
"Mark as On hold": "Marcar como en pausa",
"(detected)": "(detectado)",
"About Readest": "Acerca de Readest",
"Add your notes here...": "Añade tus notas aquí...",
@@ -1,4 +1,6 @@
{
"On hold": "در حال توقف",
"Mark as On hold": "علامت‌گذاری به‌عنوان در حال توقف",
"(detected)": "(تشخیص‌داده‌شده)",
"About Readest": "درباره‌ی Readest",
"Add your notes here...": "یادداشت‌های خود را اینجا اضافه کنید...",
@@ -1,4 +1,6 @@
{
"On hold": "En pause",
"Mark as On hold": "Marquer comme en pause",
"(detected)": "(détecté)",
"About Readest": "À propos de Readest",
"Add your notes here...": "Ajoutez vos notes ici...",
@@ -1,4 +1,6 @@
{
"On hold": "בהמתנה",
"Mark as On hold": "סמן כבהמתנה",
"Email address": "כתובת אימייל",
"Your Password": "הסיסמה שלך",
"Your email address": "כתובת האימייל שלך",
@@ -1,4 +1,6 @@
{
"On hold": "होल्ड पर",
"Mark as On hold": "होल्ड पर के रूप में चिह्नित करें",
"(detected)": "(पता लगाया)",
"About Readest": "Readest के बारे में",
"Add your notes here...": "अपनी टिप्पणियां यहां जोड़ें...",
@@ -1,4 +1,6 @@
{
"On hold": "Felfüggesztve",
"Mark as On hold": "Megjelölés felfüggesztettként",
"Email address": "E-mail cím",
"Your Password": "Az Ön jelszava",
"Your email address": "Az Ön e-mail címe",
@@ -1,4 +1,6 @@
{
"On hold": "Ditunda",
"Mark as On hold": "Tandai sebagai Ditunda",
"(detected)": "(terdeteksi)",
"About Readest": "Tentang Readest",
"Add your notes here...": "Tambahkan catatan Anda di sini...",
@@ -1,4 +1,6 @@
{
"On hold": "In pausa",
"Mark as On hold": "Segna come in pausa",
"(detected)": "(rilevato)",
"About Readest": "Informazioni su Readest",
"Add your notes here...": "Aggiungi qui le tue note...",
@@ -1,4 +1,6 @@
{
"On hold": "保留中",
"Mark as On hold": "保留中にする",
"(detected)": "(検出)",
"About Readest": "Readestについて",
"Add your notes here...": "ここにメモを追加...",
@@ -1,4 +1,6 @@
{
"On hold": "보류 중",
"Mark as On hold": "보류 중으로 표시",
"(detected)": "(감지됨)",
"About Readest": "Readest 정보",
"Add your notes here...": "여기에 메모를 추가하세요...",
@@ -1,4 +1,6 @@
{
"On hold": "Ditangguhkan",
"Mark as On hold": "Tandakan sebagai Ditangguhkan",
"Email address": "Alamat e-mel",
"Your Password": "Kata Laluan Anda",
"Your email address": "Alamat e-mel anda",
@@ -1,4 +1,6 @@
{
"On hold": "On hold",
"Mark as On hold": "Markeren als on hold",
"Email address": "E-mailadres",
"Your Password": "Uw wachtwoord",
"Your email address": "Uw e-mailadres",
@@ -1,4 +1,6 @@
{
"On hold": "Wstrzymane",
"Mark as On hold": "Oznacz jako wstrzymane",
"(detected)": "(wykryto)",
"About Readest": "O Readest",
"Add your notes here...": "Dodaj swoje notatki tutaj...",
@@ -1,4 +1,6 @@
{
"On hold": "Suspenso",
"Mark as On hold": "Marcar como suspenso",
"Email address": "Endereço de e-mail",
"Your Password": "Sua senha",
"Your email address": "Seu endereço de e-mail",
@@ -1,4 +1,6 @@
{
"On hold": "Suspenso",
"Mark as On hold": "Marcar como suspenso",
"(detected)": "(detectado)",
"About Readest": "Sobre o Readest",
"Add your notes here...": "Adicione suas notas aqui...",
@@ -1,4 +1,6 @@
{
"On hold": "În așteptare",
"Mark as On hold": "Marcați ca în așteptare",
"Email address": "Adresa de e-mail",
"Your Password": "Parola dvs",
"Your email address": "Adresa dvs. de e-mail",
@@ -1,4 +1,6 @@
{
"On hold": "Отложено",
"Mark as On hold": "Отметить как отложенное",
"(detected)": "(обнаружено)",
"About Readest": "О Readest",
"Add your notes here...": "Добавьте свои заметки здесь...",
@@ -1,4 +1,6 @@
{
"On hold": "තාවකාලිකව නතර",
"Mark as On hold": "තාවකාලිකව නතර ලෙස සලකුණු කරන්න",
"Email address": "ඊමේල් ලිපිනය",
"Your Password": "ඔබේ මුරපදය",
"Your email address": "ඔබේ ඊමේල් ලිපිනය",
@@ -1,4 +1,6 @@
{
"On hold": "Na čakanju",
"Mark as On hold": "Označi kot na čakanju",
"Email address": "E-poštni naslov",
"Your Password": "Vaše geslo",
"Your email address": "Vaš e-poštni naslov",
@@ -1,4 +1,6 @@
{
"On hold": "Pausad",
"Mark as On hold": "Markera som pausad",
"Email address": "E-postadress",
"Your Password": "Ditt lösenord",
"Your email address": "Din e-postadress",
@@ -1,4 +1,6 @@
{
"On hold": "நிறுத்தி வைக்கப்பட்டது",
"Mark as On hold": "நிறுத்தி வைக்கப்பட்டதாகக் குறிக்கவும்",
"Email address": "மின்னஞ்சல் முகவரி",
"Your Password": "உங்கள் கடவுச்சொல்",
"Your email address": "உங்கள் மின்னஞ்சல் முகவரி",
@@ -1,4 +1,6 @@
{
"On hold": "พักอ่าน",
"Mark as On hold": "ทำเครื่องหมายว่าพักอ่าน",
"Email address": "อีเมล",
"Your Password": "รหัสผ่าน",
"Your email address": "อีเมลของคุณ",
@@ -1,4 +1,6 @@
{
"On hold": "Askıda",
"Mark as On hold": "Askıda olarak işaretle",
"(detected)": "(algılandı)",
"About Readest": "Readest Hakkında",
"Add your notes here...": "Notlarınızı buraya ekleyin...",
@@ -1,4 +1,6 @@
{
"On hold": "Призупинено",
"Mark as On hold": "Позначити як призупинене",
"(detected)": "(виявлено)",
"About Readest": "Про Readest",
"Add your notes here...": "Додайте свої нотатки сюди...",
@@ -1,4 +1,6 @@
{
"On hold": "To'xtatilgan",
"Mark as On hold": "To'xtatilgan deb belgilash",
"Email address": "Email manzil",
"Your Password": "Parolingiz",
"Your email address": "Email manzilingiz",
@@ -1,4 +1,6 @@
{
"On hold": "Tạm dừng",
"Mark as On hold": "Đánh dấu là tạm dừng",
"(detected)": "(đã phát hiện)",
"About Readest": "Về Readest",
"Add your notes here...": "Thêm ghi chú của bạn vào đây...",
@@ -1,4 +1,6 @@
{
"On hold": "搁置中",
"Mark as On hold": "标记为搁置",
"(detected)": "(检测到)",
"About Readest": "关于 Readest",
"Add your notes here...": "在这里添加您的笔记...",
@@ -1,4 +1,6 @@
{
"On hold": "擱置中",
"Mark as On hold": "標示為擱置",
"(detected)": "(檢測到)",
"About Readest": "關於 Readest",
"Add your notes here...": "在這裡添加您的筆記...",
@@ -20,6 +20,7 @@ describe('getBookContextMenuItemIds', () => {
'select',
'group',
'markFinished',
'markAbandoned',
'showDetails',
'showInFinder',
'searchGoodreads',
@@ -29,12 +30,13 @@ describe('getBookContextMenuItemIds', () => {
]);
});
it('shows "Mark as Unread" + "Clear Status" for a finished book', () => {
it('shows markUnread + markAbandoned + clearStatus for a finished book', () => {
const book = createBook({ downloadedAt: 1, readingStatus: 'finished' });
expect(getBookContextMenuItemIds(book)).toEqual([
'select',
'group',
'markUnread',
'markAbandoned',
'clearStatus',
'showDetails',
'showInFinder',
@@ -47,6 +49,23 @@ describe('getBookContextMenuItemIds', () => {
it('shows "Mark as Finished" + "Clear Status" for an unread book', () => {
const book = createBook({ downloadedAt: 1, readingStatus: 'unread' });
expect(getBookContextMenuItemIds(book)).toEqual([
'select',
'group',
'markFinished',
'markAbandoned',
'clearStatus',
'showDetails',
'showInFinder',
'searchGoodreads',
'upload',
'share',
'delete',
]);
});
it('hides markAbandoned but offers markFinished + clearStatus for an abandoned book', () => {
const book = createBook({ downloadedAt: 1, readingStatus: 'abandoned' });
expect(getBookContextMenuItemIds(book)).toEqual([
'select',
'group',
@@ -67,6 +86,7 @@ describe('getBookContextMenuItemIds', () => {
'select',
'group',
'markFinished',
'markAbandoned',
'showDetails',
'showInFinder',
'searchGoodreads',
@@ -82,6 +102,7 @@ describe('getBookContextMenuItemIds', () => {
'select',
'group',
'markFinished',
'markAbandoned',
'showDetails',
'showInFinder',
'searchGoodreads',
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { withReadingStatus, pickFresherReadingStatus } from '@/app/library/utils/libraryUtils';
import type { Book } from '@/types/book';
const book: Book = {
hash: 'h1',
format: 'EPUB',
title: 'T',
author: 'A',
createdAt: 1,
updatedAt: 2,
readingStatus: undefined,
};
describe('withReadingStatus', () => {
it('sets status, stamps readingStatusUpdatedAt = updatedAt, and does not mutate input', () => {
const out = withReadingStatus(book, 'abandoned');
expect(out.readingStatus).toBe('abandoned');
expect(out.readingStatusUpdatedAt).toBe(out.updatedAt);
expect(out.readingStatusUpdatedAt).toBeGreaterThan(0);
expect(book.readingStatus).toBeUndefined(); // input untouched
});
it('clears the status when undefined is passed but still stamps the timestamp', () => {
const out = withReadingStatus({ ...book, readingStatus: 'finished' }, undefined);
expect(out.readingStatus).toBeUndefined();
expect(out.readingStatusUpdatedAt).toBe(out.updatedAt);
});
});
describe('pickFresherReadingStatus', () => {
it('keeps the status whose timestamp is newer, even if the other object is newer overall', () => {
const local = { readingStatus: 'finished' as const, readingStatusUpdatedAt: 200 };
const remote = { readingStatus: undefined, readingStatusUpdatedAt: 100 };
expect(pickFresherReadingStatus(local, remote)).toEqual({
readingStatus: 'finished',
readingStatusUpdatedAt: 200,
});
});
it('treats a missing timestamp as oldest', () => {
const local = { readingStatus: undefined, readingStatusUpdatedAt: undefined };
const remote = { readingStatus: 'abandoned' as const, readingStatusUpdatedAt: 5 };
expect(pickFresherReadingStatus(local, remote)).toEqual({
readingStatus: 'abandoned',
readingStatusUpdatedAt: 5,
});
});
it('prefers the first argument on a timestamp tie', () => {
const a = { readingStatus: 'reading' as const, readingStatusUpdatedAt: 50 };
const b = { readingStatus: 'finished' as const, readingStatusUpdatedAt: 50 };
expect(pickFresherReadingStatus(a, b).readingStatus).toBe('reading');
});
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { render } from '@testing-library/react';
import StatusBadge from '@/app/library/components/StatusBadge';
describe('StatusBadge', () => {
it('renders children for the abandoned status', () => {
const { queryByText } = render(<StatusBadge status='abandoned'>On hold</StatusBadge>);
expect(queryByText('On hold')).not.toBeNull();
});
it('renders nothing for the reading status', () => {
const { container } = render(<StatusBadge status='reading'>x</StatusBadge>);
expect(container.firstChild).toBeNull();
});
it('renders nothing when status is undefined', () => {
const { container } = render(<StatusBadge>x</StatusBadge>);
expect(container.firstChild).toBeNull();
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { resolveReadingStatusMerge } from '@/pages/api/sync';
const iso = (ms: number) => new Date(ms).toISOString();
describe('resolveReadingStatusMerge', () => {
it('keeps the client status when its status timestamp is newer', () => {
const out = resolveReadingStatusMerge(
{ reading_status: 'finished', reading_status_updated_at: iso(200) },
{ reading_status: 'reading', reading_status_updated_at: iso(100) },
);
expect(out).toEqual({ reading_status: 'finished', reading_status_updated_at: iso(200) });
});
it('keeps the server status when its status timestamp is newer', () => {
const out = resolveReadingStatusMerge(
{ reading_status: 'reading', reading_status_updated_at: iso(100) },
{ reading_status: 'finished', reading_status_updated_at: iso(300) },
);
expect(out).toEqual({ reading_status: 'finished', reading_status_updated_at: iso(300) });
});
it('treats a missing timestamp as oldest (server wins over an unstamped client)', () => {
const out = resolveReadingStatusMerge(
{ reading_status: undefined, reading_status_updated_at: undefined },
{ reading_status: 'abandoned', reading_status_updated_at: iso(1) },
);
expect(out).toEqual({ reading_status: 'abandoned', reading_status_updated_at: iso(1) });
});
});
@@ -163,6 +163,25 @@ describe('libraryStore', () => {
const visible = useLibraryStore.getState().getVisibleLibrary();
expect(visible.map((b) => b.hash)).toEqual(['a', 'c']);
});
test('stamps readingStatusUpdatedAt when the status changes', () => {
useLibraryStore.getState().setLibrary([makeBook({ hash: 'a', readingStatus: undefined })]);
useLibraryStore.getState().updateBookProgress('a', [100, 100], 'finished');
const book = useLibraryStore.getState().getBookByHash('a');
expect(book?.readingStatus).toBe('finished');
expect(book?.readingStatusUpdatedAt).toBeGreaterThan(0);
});
test('does NOT change readingStatusUpdatedAt on a progress-only update', () => {
useLibraryStore
.getState()
.setLibrary([
makeBook({ hash: 'a', readingStatus: 'reading', readingStatusUpdatedAt: 111 }),
]);
useLibraryStore.getState().updateBookProgress('a', [50, 100], 'reading');
const book = useLibraryStore.getState().getBookByHash('a');
expect(book?.readingStatusUpdatedAt).toBe(111);
});
});
describe('updateBooks', () => {
@@ -4,8 +4,10 @@ import {
transformBookNoteFromDB,
transformBookConfigToDB,
transformBookConfigFromDB,
transformBookToDB,
transformBookFromDB,
} from '@/utils/transform';
import { BookConfig, BookNote } from '@/types/book';
import { BookConfig, BookNote, Book } from '@/types/book';
import { DBBookConfig, DBBookNote } from '@/types/records';
describe('transformBookNoteToDB with xpointer fields', () => {
@@ -284,3 +286,56 @@ describe('transformBookConfigToDB / transformBookConfigFromDB rsvpPosition', ()
expect(restored.rsvpPosition).toEqual(config.rsvpPosition);
});
});
describe('transformBook readingStatus + readingStatusUpdatedAt', () => {
const userId = 'user-1';
const baseBook: Book = {
hash: 'h1',
format: 'EPUB',
title: 'T',
author: 'A',
createdAt: 1,
updatedAt: 2,
};
it('serializes abandoned status + timestamp to ISO in the DB record', () => {
const ts = Date.UTC(2026, 5, 18, 12, 0, 0);
const db = transformBookToDB(
{ ...baseBook, readingStatus: 'abandoned', readingStatusUpdatedAt: ts },
userId,
);
expect(db.reading_status).toBe('abandoned');
expect(db.reading_status_updated_at).toBe(new Date(ts).toISOString());
});
it('leaves reading_status_updated_at null when unset', () => {
const db = transformBookToDB({ ...baseBook, readingStatus: 'finished' }, userId);
expect(db.reading_status_updated_at).toBeNull();
});
it('round-trips abandoned + timestamp back to the client shape', () => {
const ts = Date.UTC(2026, 5, 18, 12, 0, 0);
const db = transformBookToDB(
{ ...baseBook, readingStatus: 'abandoned', readingStatusUpdatedAt: ts },
userId,
);
const back = transformBookFromDB(db);
expect(back.readingStatus).toBe('abandoned');
expect(back.readingStatusUpdatedAt).toBe(ts);
});
it('reads undefined readingStatusUpdatedAt when the DB column is null', () => {
const back = transformBookFromDB({
user_id: userId,
book_hash: 'h1',
format: 'EPUB',
title: 'T',
author: 'A',
reading_status: 'finished',
reading_status_updated_at: null,
created_at: new Date(1).toISOString(),
updated_at: new Date(2).toISOString(),
});
expect(back.readingStatusUpdatedAt).toBeUndefined();
});
});
@@ -43,6 +43,7 @@ import {
compareSortValues,
resolveEffectivePrimarySort,
resolveEffectiveSecondarySort,
withReadingStatus,
} from '../utils/libraryUtils';
import { eventDispatcher } from '@/utils/event';
import { getLocalBookFilename } from '@/utils/book';
@@ -542,7 +543,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
for (const id of selectedIds) {
const book = filteredBooks.find((b) => b.hash === id);
if (book) {
booksToUpdate.push({ ...book, readingStatus: status, updatedAt: Date.now() });
booksToUpdate.push(withReadingStatus(book, status));
}
}
@@ -557,7 +558,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const handleUpdateReadingStatus = useCallback(
async (book: Book, status: ReadingStatus | undefined) => {
const updatedBook = { ...book, readingStatus: status, updatedAt: Date.now() };
const updatedBook = withReadingStatus(book, status);
await updateBooks(envConfig, [updatedBook]);
},
[envConfig, updateBooks],
@@ -254,6 +254,12 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
handleUpdateReadingStatus(book, 'unread');
},
},
markAbandoned: {
text: _('Mark as On hold'),
action: async () => {
handleUpdateReadingStatus(book, 'abandoned');
},
},
clearStatus: {
text: _('Clear Status'),
action: async () => {
@@ -33,6 +33,20 @@ const ReadingProgress: React.FC<ReadingProgressProps> = memo(
);
}
if (book.readingStatus === 'abandoned') {
return (
<div
className='text-neutral-content/70 flex items-center justify-between gap-2 text-xs'
role='status'
>
<StatusBadge status={book.readingStatus}>{_('On hold')}</StatusBadge>
{progressPercentage !== null && !Number.isNaN(progressPercentage) && (
<span>{progressPercentage}%</span>
)}
</div>
);
}
if (book.readingStatus === 'unread') {
if (SHOW_UNREAD_STATUS_BADGE) {
return (
@@ -34,6 +34,12 @@ const SetStatusAlert: React.FC<SetStatusAlertProps> = ({
className:
'not-eink:bg-success/15 not-eink:text-success not-eink:border-success/20 eink-bordered',
},
{
label: _('Mark as On hold'),
status: 'abandoned' as ReadingStatus,
className:
'not-eink:bg-slate-500/15 not-eink:text-slate-600 dark:not-eink:text-slate-300 not-eink:border-slate-500/20 eink-bordered',
},
{
label: _('Clear Status'),
status: undefined,
@@ -8,9 +8,7 @@ interface StatusBadgeProps {
}
const StatusBadge: React.FC<StatusBadgeProps> = ({ status, children, className }) => {
if (status !== 'finished' && status !== 'unread') return null;
const isFinished = status === 'finished';
if (status !== 'finished' && status !== 'unread' && status !== 'abandoned') return null;
return (
<span
@@ -19,16 +17,21 @@ const StatusBadge: React.FC<StatusBadgeProps> = ({ status, children, className }
'rounded-[1px] px-0.5',
'text-[8px] font-bold uppercase leading-none tracking-wider',
'h-3.5',
isFinished && 'status-badge-finished',
!isFinished && 'status-badge-unread',
status === 'finished' && 'status-badge-finished',
status === 'unread' && 'status-badge-unread',
status === 'abandoned' && 'status-badge-abandoned',
// finished: green/emerald
isFinished && 'bg-emerald-100 dark:bg-emerald-900/90',
isFinished && 'border border-emerald-300/50 dark:border-emerald-700/50',
isFinished && 'text-emerald-700 dark:text-emerald-300',
status === 'finished' && 'bg-emerald-100 dark:bg-emerald-900/90',
status === 'finished' && 'border border-emerald-300/50 dark:border-emerald-700/50',
status === 'finished' && 'text-emerald-700 dark:text-emerald-300',
// unread: pastel yellow/amber
!isFinished && 'bg-amber-100 dark:bg-amber-900/80',
!isFinished && 'border border-amber-300/50 dark:border-amber-700/50',
!isFinished && 'text-amber-700 dark:text-amber-300',
status === 'unread' && 'bg-amber-100 dark:bg-amber-900/80',
status === 'unread' && 'border border-amber-300/50 dark:border-amber-700/50',
status === 'unread' && 'text-amber-700 dark:text-amber-300',
// abandoned / on hold: slate
status === 'abandoned' && 'bg-slate-100 dark:bg-slate-800/80',
status === 'abandoned' && 'border border-slate-300/50 dark:border-slate-600/50',
status === 'abandoned' && 'text-slate-700 dark:text-slate-300',
className,
)}
role='status'
@@ -9,6 +9,7 @@ import { SYNC_BOOKS_INTERVAL_SEC } from '@/services/constants';
import { throttle } from '@/utils/throttle';
import { debounce } from '@/utils/debounce';
import { eventDispatcher } from '@/utils/event';
import { pickFresherReadingStatus } from '@/app/library/utils/libraryUtils';
export const useBooksSync = () => {
const _ = useTranslation();
@@ -129,6 +130,11 @@ export const useBooksSync = () => {
matchingBook.updatedAt >= oldBook.updatedAt
? { ...oldBook, ...matchingBook, syncedAt: Date.now() }
: { ...matchingBook, ...oldBook, syncedAt: Date.now() };
// Status is resolved by its own timestamp, independent of the row's
// updatedAt (which page-turn progress dominates) — see #4634.
const status = pickFresherReadingStatus(oldBook, matchingBook);
mergedBook.readingStatus = status.readingStatus;
mergedBook.readingStatusUpdatedAt = status.readingStatusUpdatedAt;
return mergedBook;
}
return oldBook;
@@ -1,4 +1,4 @@
import { Book, BooksGroup } from '@/types/book';
import { Book, BooksGroup, ReadingStatus } from '@/types/book';
import {
LibraryGroupByType,
LibrarySecondarySortByType,
@@ -597,6 +597,7 @@ export type BookContextMenuItemId =
| 'group'
| 'markFinished'
| 'markUnread'
| 'markAbandoned'
| 'clearStatus'
| 'showDetails'
| 'showInFinder'
@@ -606,6 +607,37 @@ export type BookContextMenuItemId =
| 'share'
| 'delete';
/**
* Build a new Book with an explicit reading status. Stamps both `updatedAt`
* (so the library sync picks it up) and `readingStatusUpdatedAt` (so the
* field-level merge resolves status independently of progress). Use this for
* every deliberate status edit so the timestamp is never forgotten.
*/
export const withReadingStatus = (book: Book, status: ReadingStatus | undefined): Book => {
const now = Date.now();
return { ...book, readingStatus: status, readingStatusUpdatedAt: now, updatedAt: now };
};
type ReadingStatusFields = Pick<Book, 'readingStatus' | 'readingStatusUpdatedAt'>;
/**
* Field-level last-writer-wins for reading status: return whichever side's
* status was set more recently (ties `a`). Missing timestamp = epoch 0.
* The book row's `updatedAt` is dominated by page-turn progress, so status
* must be resolved by its own timestamp or progress would clobber it.
*/
export const pickFresherReadingStatus = (
a: ReadingStatusFields,
b: ReadingStatusFields,
): ReadingStatusFields => {
const at = (x: ReadingStatusFields) => x.readingStatusUpdatedAt ?? 0;
const winner = at(a) >= at(b) ? a : b;
return {
readingStatus: winner.readingStatus,
readingStatusUpdatedAt: winner.readingStatusUpdatedAt,
};
};
/**
* Resolve the ordered list of context-menu item ids for a book from its state.
*
@@ -617,8 +649,13 @@ export type BookContextMenuItemId =
export const getBookContextMenuItemIds = (book: Book): BookContextMenuItemId[] => {
const ids: BookContextMenuItemId[] = ['select', 'group'];
ids.push(book.readingStatus === 'finished' ? 'markUnread' : 'markFinished');
if (book.readingStatus !== 'abandoned') ids.push('markAbandoned');
// "Clear Status" is offered only when the book has an explicit status set.
if (book.readingStatus === 'finished' || book.readingStatus === 'unread') {
if (
book.readingStatus === 'finished' ||
book.readingStatus === 'unread' ||
book.readingStatus === 'abandoned'
) {
ids.push('clearStatus');
}
ids.push('showDetails', 'showInFinder', 'searchGoodreads');
+60 -1
View File
@@ -37,6 +37,29 @@ export function pickWinningPages(
return { toUpsert };
}
/**
* Field-level last-writer-wins for a books row's reading_status: return the
* status fields with the newer reading_status_updated_at (ties client). NULL
* timestamp = epoch 0. Lets reading_status survive even when the whole row is
* decided the other way by updated_at (which page-turn progress dominates)
* issue #4634.
*/
export function resolveReadingStatusMerge(
client: Pick<DBBook, 'reading_status' | 'reading_status_updated_at'>,
server: Pick<DBBook, 'reading_status' | 'reading_status_updated_at'>,
): Pick<DBBook, 'reading_status' | 'reading_status_updated_at'> {
const ms = (s?: string | null) => (s ? new Date(s).getTime() : 0);
return ms(client.reading_status_updated_at) >= ms(server.reading_status_updated_at)
? {
reading_status: client.reading_status,
reading_status_updated_at: client.reading_status_updated_at,
}
: {
reading_status: server.reading_status,
reading_status_updated_at: server.reading_status_updated_at,
};
}
const transformsToDB = {
books: transformBookToDB,
book_notes: transformBookNoteToDB,
@@ -378,7 +401,43 @@ export async function POST(req: NextRequest) {
const clientIsNewer =
clientDeletedAt > serverDeletedAt || clientUpdatedAt > serverUpdatedAt;
if (clientIsNewer) {
if (table === 'books') {
// `dbRec` is DBBook | DBBookConfig; in the 'books' branch it is always DBBook.
const clientBook = dbRec as DBBook;
// `serverData` is BookDataRecord but the DB row carries the status columns at
// runtime — widen the type without going through `unknown`.
const serverBook = serverData as BookDataRecord &
Partial<Pick<DBBook, 'reading_status' | 'reading_status_updated_at'>>;
const status = resolveReadingStatusMerge(clientBook, serverBook);
if (clientIsNewer) {
// Client wins the row; graft the fresher status onto it (server's
// status may be the newer one even though the row is older).
clientBook.reading_status = status.reading_status;
clientBook.reading_status_updated_at = status.reading_status_updated_at;
toUpdate.push(clientBook);
} else {
// Only rewrite when the resolved status VALUE differs from the
// server's — a timestamp-only difference on the same value is a
// no-op, and rewriting it would churn updated_at + re-propagate.
const statusChanged = status.reading_status !== serverBook.reading_status;
if (statusChanged) {
// Server wins the row, but the client's status is newer. Write
// server's row with the fresher status and bump updated_at so
// peers re-pull the status change.
// The runtime DB row carries all DBBook columns; the static type
// of `serverBook` is a narrower intersection so `unknown` is
// required to bridge the gap at this one construction site.
toUpdate.push({
...serverBook,
reading_status: status.reading_status,
reading_status_updated_at: status.reading_status_updated_at,
updated_at: new Date().toISOString(),
} as unknown as DBBook);
} else {
batchAuthoritativeRecords.push(serverData);
}
}
} else if (clientIsNewer) {
toUpdate.push(dbRec);
} else {
batchAuthoritativeRecords.push(serverData);
@@ -107,10 +107,12 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
const idx = hashIndex.get(hash);
if (idx === undefined) return;
const book = library[idx]!;
const statusChanged = readingStatus !== book.readingStatus;
const updatedBook: Book = {
...book,
progress,
readingStatus,
readingStatusUpdatedAt: statusChanged ? Date.now() : book.readingStatusUpdatedAt,
updatedAt: Date.now(),
};
const newLibrary = library.slice();
+2 -1
View File
@@ -16,7 +16,7 @@ export type BookFormat =
| 'TXT'
| 'MD';
export type BookNoteType = 'bookmark' | 'annotation' | 'excerpt';
export type ReadingStatus = 'unread' | 'reading' | 'finished';
export type ReadingStatus = 'unread' | 'reading' | 'finished' | 'abandoned';
export type HighlightStyle = 'highlight' | 'underline' | 'squiggly';
// Predefined highlight colors, can be extended with custom hex colors
export type HighlightColor = 'red' | 'yellow' | 'green' | 'blue' | 'violet' | string;
@@ -112,6 +112,7 @@ export interface Book {
lastUpdated?: number; // deprecated in favor of updatedAt
progress?: [number, number]; // Add progress field: [current, total], 1-based page number
readingStatus?: ReadingStatus;
readingStatusUpdatedAt?: number; // ms; bumped only when readingStatus changes
primaryLanguage?: string;
metadata?: BookMetadata;
+1
View File
@@ -11,6 +11,7 @@ export interface DBBook {
tags?: string[];
progress?: [number, number];
reading_status?: string;
reading_status_updated_at?: string | null;
metadata?: string | null;
created_at?: string;
+8
View File
@@ -76,6 +76,7 @@ export const transformBookToDB = (book: unknown, userId: string): DBBook => {
tags,
progress,
readingStatus,
readingStatusUpdatedAt,
metadata,
createdAt,
updatedAt,
@@ -95,6 +96,9 @@ export const transformBookToDB = (book: unknown, userId: string): DBBook => {
tags: tags,
progress: progress,
reading_status: readingStatus,
reading_status_updated_at: readingStatusUpdatedAt
? new Date(readingStatusUpdatedAt).toISOString()
: null,
source_title: sanitizeString(sourceTitle),
metadata: metadata ? sanitizeString(JSON.stringify(metadata)) : null,
created_at: new Date(createdAt ?? Date.now()).toISOString(),
@@ -116,6 +120,7 @@ export const transformBookFromDB = (dbBook: DBBook): Book => {
tags,
progress,
reading_status,
reading_status_updated_at,
source_title,
metadata,
created_at,
@@ -135,6 +140,9 @@ export const transformBookFromDB = (dbBook: DBBook): Book => {
tags: tags,
progress: progress,
readingStatus: reading_status as ReadingStatus,
readingStatusUpdatedAt: reading_status_updated_at
? new Date(reading_status_updated_at).getTime()
: undefined,
sourceTitle: source_title,
metadata: metadata ? JSON.parse(metadata) : null,
createdAt: new Date(created_at!).getTime(),
+26 -5
View File
@@ -13,7 +13,7 @@
local SQ3 = require("lua-ljsqlite3/init")
local json = require("json")
local SCHEMA_VERSION = 1
local SCHEMA_VERSION = 2
local SCHEMA_SQL = [[
CREATE TABLE IF NOT EXISTS books (
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS books (
uploaded_at INTEGER,
progress_lib TEXT,
reading_status TEXT,
reading_status_updated_at INTEGER,
last_read_at INTEGER,
created_at INTEGER,
updated_at INTEGER,
@@ -62,7 +63,7 @@ local BOOK_COLS = {
"format", "metadata_json", "series", "series_index", "group_id",
"group_name", "cover_path", "file_path", "cloud_present",
"local_present", "uploaded_at", "progress_lib", "reading_status",
"last_read_at", "created_at", "updated_at", "deleted_at",
"reading_status_updated_at", "last_read_at", "created_at", "updated_at", "deleted_at",
}
local BOOK_COL_INDEX = {}
for i, c in ipairs(BOOK_COLS) do BOOK_COL_INDEX[c] = i end
@@ -73,8 +74,8 @@ for i, c in ipairs(BOOK_COLS) do BOOK_COL_INDEX[c] = i end
-- well within Lua's 53-bit double mantissa.
local NUMERIC_COLS = {
series_index = true, cloud_present = true, local_present = true,
uploaded_at = true, last_read_at = true, created_at = true,
updated_at = true, deleted_at = true,
uploaded_at = true, reading_status_updated_at = true, last_read_at = true,
created_at = true, updated_at = true, deleted_at = true,
}
local function row_to_table(raw)
@@ -136,8 +137,24 @@ function M.new(opts)
self.user_id = opts.user_id
self.db_path = opts.db_path or ":memory:"
self.db = SQ3.open(self.db_path)
-- Read version before creating tables; getUserVersion uses rowexec which
-- may leave an open iterator in some SQLite bindings, so use prepare/step.
local prev_stmt = self.db:prepare("PRAGMA user_version;")
local prev_row = prev_stmt:reset():step()
prev_stmt:close()
local prev = prev_row and tonumber(prev_row[1]) or 0
self.db:exec(SCHEMA_SQL)
self.db:exec(string.format("PRAGMA user_version = %d;", SCHEMA_VERSION))
-- v1 -> v2: add reading_status_updated_at to existing DBs. CREATE TABLE
-- IF NOT EXISTS won't add a column, so ALTER it in (pcall guards a DB that
-- somehow already has the column).
if prev >= 1 and prev < 2 then
pcall(function()
self.db:exec("ALTER TABLE books ADD COLUMN reading_status_updated_at INTEGER;")
end)
end
if prev < SCHEMA_VERSION then
self.db:exec(string.format("PRAGMA user_version = %d;", SCHEMA_VERSION))
end
self._groups_cache = {}
return self
end
@@ -745,6 +762,10 @@ function M.parseSyncRow(dbRow)
-- Reading status passthrough (web side has 'unread'/'reading'/'finished')
out.reading_status = dbRow.readingStatus or dbRow.reading_status
-- ms; server sends it as a timestamptz ISO string (iso_to_ms also passes
-- through a raw number when a caller already supplied ms).
out.reading_status_updated_at = iso_to_ms(dbRow.reading_status_updated_at)
or iso_to_ms(dbRow.readingStatusUpdatedAt)
-- Cloud-presence flag: tombstones from the cloud arrive with deleted_at
-- set; the row is still useful for tracking that the cloud copy is gone,
+44 -12
View File
@@ -471,22 +471,54 @@ end
-- ---------------------------------------------------------------------------
local function runCloudSync(opts, store)
local mode = opts.settings.auto_sync and "both" or "pull"
local DocSettings = require("docsettings")
local BookList = require("ui/widget/booklist")
local statussync = require("library.statussync")
local deps = {
now_ms = function() return os.time() * 1000 end,
open_summary = function(file_path)
local ok, ds = pcall(DocSettings.open, DocSettings, file_path)
if not ok or not ds then return nil end
return ds:readSetting("summary")
end,
write_status = function(file_path, ko_status)
local ok, ds = pcall(DocSettings.open, DocSettings, file_path)
if not ok or not ds then return end
local summary = ds:readSetting("summary") or {}
summary.status = ko_status -- nil clears -> KOReader "New"
summary.modified = os.date("%Y-%m-%d", os.time())
ds:saveSetting("summary", summary)
ds:flush()
BookList.setBookInfoCacheProperty(file_path, "status", ko_status)
end,
}
local function reconcile() statussync.reconcileLocalStatuses(store, deps) end
logger.info("ReadestLibrary runCloudSync: mode=" .. mode
.. " auto_sync=" .. tostring(opts.settings.auto_sync))
syncbooks.syncBooks({
sync_auth = opts.sync_auth,
sync_path = opts.sync_path,
settings = opts.settings,
store = store,
}, mode, function(success, msg, status)
local function done(success, msg, status)
logger.info("ReadestLibrary runCloudSync[" .. mode .. "] done: success="
.. tostring(success) .. " msg=" .. tostring(msg)
.. " status=" .. tostring(status))
-- Refresh either way: success picks up new cloud rows; failure
-- (auth, network, server) leaves local rows visible without
-- leaking a stale display state.
.. tostring(success) .. " msg=" .. tostring(msg) .. " status=" .. tostring(status))
M.refresh()
end)
end
if mode == "both" then
-- before_push runs after pull, before push: apply pulled statuses to
-- sidecars and capture sidecar changes into the store so they're pushed.
syncbooks.syncBooks({
sync_auth = opts.sync_auth, sync_path = opts.sync_path,
settings = opts.settings, store = store,
}, "both", done, reconcile)
else
syncbooks.syncBooks({
sync_auth = opts.sync_auth, sync_path = opts.sync_path,
settings = opts.settings, store = store,
}, "pull", function(success, msg, status)
reconcile() -- apply cloud statuses to sidecars even when auto_sync is off
done(success, msg, status)
end)
end
end
-- Cloud sync HTTP is synchronous on platforms without the Turbo looper
@@ -0,0 +1,108 @@
-- readingstatus.lua — pure bidirectional mapping + reconcile between Readest's
-- reading_status and KOReader's summary.status. No KOReader globals, so it
-- unit-tests cleanly under busted.
--
-- ONLY DELIBERATE statuses sync. KOReader auto-sets summary.status = "reading"
-- the first time a book is opened, so "reading" (and "New"/absent) is treated as
-- NON-DECISIVE and never captured — otherwise opening a finished book on
-- KOReader would downgrade it. Reading *position* syncs via the progress
-- channel, not here.
--
-- Decisive: Readest unread / finished / abandoned
-- KOReader complete / abandoned
--
-- On the unsynced baseline (a book whose Readest reading_status_updated_at is
-- 0/absent — status predates this feature or was pulled before it) timestamps
-- aren't trustworthy, so conflicts resolve "Readest authoritative". The first
-- reconcile of such a book stamps reading_status_updated_at = now_ms, which
-- exits bootstrap; every later change then resolves by ordinary recency LWW.
local M = {}
-- Readest reading_status -> KOReader summary.status push target.
-- finished->complete, abandoned->abandoned, unread->nil (clear / "New").
-- 'reading'/undefined are non-decisive and never pushed.
local READEST_TO_KO = { finished = "complete", abandoned = "abandoned" }
function M.readest_to_ko(status)
if status == "unread" then return nil end
return READEST_TO_KO[status]
end
-- KOReader summary.status -> Readest reading_status, ONLY for decisive KO
-- statuses. "reading" (auto-on-open), "New", nil, unknown -> nil (no opinion).
local KO_TO_READEST = { complete = "finished", abandoned = "abandoned" }
function M.ko_to_readest(status)
return KO_TO_READEST[status]
end
-- Is a Readest status a deliberate signal worth syncing?
function M.readest_decisive(status)
return status == "unread" or status == "finished" or status == "abandoned"
end
-- "YYYY-MM-DD" -> unix ms at local midnight; nil if unparseable.
function M.parse_modified_ms(s)
if type(s) ~= "string" then return nil end
local y, mo, d = s:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)")
if not y then return nil end
local t = os.time({ year = tonumber(y), month = tonumber(mo), day = tonumber(d),
hour = 0, min = 0, sec = 0 })
if not t then return nil end
return t * 1000
end
-- Decide what to write so both sides converge on the winning decisive status.
-- cloud = { reading_status, reading_status_updated_at(ms) } (LibraryStore row)
-- ko = { status (KO summary.status), ts (ms, from summary.modified) }
-- now_ms = current time in ms (used for the bootstrap stamp)
-- Returns { write_ko, write_store, readest_status, ts, ko_status } where
-- write_ko => set the sidecar to ko_status (may be nil = clear),
-- write_store => set the LibraryStore row to (readest_status, ts).
-- Both false means "nothing to do".
function M.reconcile(cloud, ko, now_ms)
cloud = cloud or {}
ko = ko or {}
now_ms = now_ms or 0
local cr = cloud.reading_status
local cloud_ts = cloud.reading_status_updated_at or 0
local cr_dec = M.readest_decisive(cr)
local kr = M.ko_to_readest(ko.status) -- finished | abandoned | nil
local ko_ts = ko.ts or 0
local ko_dec = kr ~= nil
-- Neither side has a decisive status: nothing to sync or baseline.
if not cr_dec and not ko_dec then
return { write_ko = false, write_store = false }
end
-- Winning Readest status W and its authoritative timestamp W_ts.
local W, W_ts
if cr_dec and not ko_dec then
W, W_ts = cr, (cloud_ts > 0 and cloud_ts or now_ms)
elseif ko_dec and not cr_dec then
W, W_ts = kr, (ko_ts > 0 and ko_ts or now_ms)
elseif cr == kr then -- both decisive, already agree
W, W_ts = cr, (cloud_ts > 0 and cloud_ts or now_ms)
elseif cloud_ts == 0 then -- bootstrap conflict: Readest authoritative
W, W_ts = cr, now_ms
elseif cloud_ts >= ko_ts then -- steady-state LWW (tie -> Readest)
W, W_ts = cr, cloud_ts
else
W, W_ts = kr, ko_ts
end
-- Equalize both sides to W.
local target_ko = M.readest_to_ko(W) -- complete | abandoned | nil (clear)
local write_ko = ko.status ~= target_ko
local write_store = (cr ~= W) or (cloud_ts ~= W_ts)
return {
write_ko = write_ko,
write_store = write_store,
readest_status = W,
ts = W_ts,
ko_status = target_ko,
}
end
return M
@@ -0,0 +1,45 @@
-- statussync.lua — bridge LibraryStore.reading_status <-> KOReader's per-book
-- summary.status. The decision is delegated to the pure readingstatus.reconcile;
-- this module only walks local-present rows and performs the chosen IO. The IO
-- is injected via `deps` so it unit-tests without DocSettings; production wires
-- a DocSettings-backed deps in librarywidget.
local readingstatus = require("library.readingstatus")
local M = {}
-- deps: { now_ms(), open_summary(file_path) -> {status, modified}|nil,
-- write_status(file_path, ko_status_or_nil) }
function M.reconcileLocalStatuses(store, deps)
if not store or not deps then return 0 end
-- Capture "now" once so every book baselines at the same instant.
local now_ms = deps.now_ms()
local changed = 0
local rows = store:listBooks({})
for _, row in ipairs(rows) do
if row.local_present == 1 and row.file_path then
local summary = deps.open_summary(row.file_path) or {}
local ko_ts = readingstatus.parse_modified_ms(summary.modified) or now_ms
local r = readingstatus.reconcile(
{ reading_status = row.reading_status,
reading_status_updated_at = row.reading_status_updated_at },
{ status = summary.status, ts = ko_ts },
now_ms)
-- Sidecar first (durable), then the store (durable + bumps
-- updated_at for the push). A crash between re-reconciles this book
-- identically next pass — convergent and idempotent.
if r.write_ko then
deps.write_status(row.file_path, r.ko_status) -- ko_status may be nil (clear)
end
if r.write_store then
store:touchBook(row.hash, {
reading_status = r.readest_status,
reading_status_updated_at = r.ts,
})
end
if r.write_ko or r.write_store then changed = changed + 1 end
end
end
return changed
end
return M
@@ -148,6 +148,7 @@ local function row_to_wire(row)
groupId = row.group_id,
groupName = row.group_name,
readingStatus = row.reading_status,
readingStatusUpdatedAt = num(row.reading_status_updated_at),
createdAt = num(row.created_at),
updatedAt = num(row.updated_at),
deletedAt = num(row.deleted_at),
@@ -89,11 +89,125 @@ describe("LibraryStore", function()
s3:close()
end)
it("sets PRAGMA user_version = 1", function()
it("sets PRAGMA user_version = 2", function()
local store = LibraryStore.new({ user_id = "alice" })
assert.are.equal(1, store:getUserVersion())
assert.are.equal(2, store:getUserVersion())
store:close()
end)
-- -----------------------------------------------------------------
-- v1 → v2 migration: ALTER TABLE adds reading_status_updated_at
-- -----------------------------------------------------------------
describe("v1 -> v2 migration", function()
local db_path
local SQ3 = require("lua-ljsqlite3/init")
after_each(function()
if db_path then
os.remove(db_path)
db_path = nil
end
end)
it("migrates an existing v1 DB: column added, row preserved, user_version=2", function()
-- Build a temp file path; os.tmpname() creates the file, remove it
-- so sqlite.open() starts fresh instead of opening an existing file.
db_path = os.tmpname()
os.remove(db_path)
-- Create a v1 DB: books table WITHOUT reading_status_updated_at
-- (all other v2 columns present so SCHEMA_SQL indexes succeed),
-- user_version = 1, one existing row.
local v1db = SQ3.open(db_path)
v1db:exec([[
CREATE TABLE books (
user_id TEXT NOT NULL,
hash TEXT NOT NULL,
meta_hash TEXT,
title TEXT NOT NULL,
source_title TEXT,
author TEXT,
format TEXT,
metadata_json TEXT,
series TEXT,
series_index REAL,
group_id TEXT,
group_name TEXT,
cover_path TEXT,
file_path TEXT,
cloud_present INTEGER NOT NULL DEFAULT 0,
local_present INTEGER NOT NULL DEFAULT 0,
uploaded_at INTEGER,
progress_lib TEXT,
reading_status TEXT,
last_read_at INTEGER,
created_at INTEGER,
updated_at INTEGER,
deleted_at INTEGER,
PRIMARY KEY (user_id, hash)
);
CREATE INDEX IF NOT EXISTS books_user_updated ON books(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS books_user_lastread ON books(user_id, last_read_at DESC);
CREATE INDEX IF NOT EXISTS books_user_meta ON books(user_id, meta_hash);
CREATE INDEX IF NOT EXISTS books_user_group ON books(user_id, group_name);
CREATE INDEX IF NOT EXISTS books_user_author ON books(user_id, author);
]])
v1db:exec("PRAGMA user_version = 1;")
local ins = v1db:prepare(
"INSERT INTO books (user_id, hash, title, cloud_present, local_present, reading_status) VALUES (?, ?, ?, ?, ?, ?)")
ins:reset():bind("alice", "h-v1", "OldBook", 1, 1, "reading"):step()
ins:close()
v1db:close()
-- Open the v1 file via LibraryStore; migration should run silently.
local store = LibraryStore.new({ user_id = "alice", db_path = db_path })
-- 1. user_version must now be 2
assert.are.equal(2, store:getUserVersion())
-- 2. The pre-existing row is still readable (no error, title intact)
local row = store:_getRowRaw("h-v1")
assert.is_not_nil(row)
assert.are.equal("OldBook", row.title)
assert.are.equal("reading", row.reading_status)
-- 3. reading_status_updated_at round-trips (column exists and is writable)
store:upsertBook({
hash = "h-v1",
title = "OldBook",
reading_status_updated_at = 1750000000000,
})
local row2 = store:_getRowRaw("h-v1")
assert.are.equal(1750000000000, row2.reading_status_updated_at)
store:close()
end)
end)
end)
-- =====================================================================
-- reading_status_updated_at: new column added in schema v2
-- =====================================================================
describe("reading_status_updated_at", function()
it("round-trips reading_status_updated_at through upsert + read", function()
local store = LibraryStore.new({ user_id = "u1", db_path = ":memory:" })
store:upsertBook({ hash = "h1", title = "T", reading_status = "finished",
reading_status_updated_at = 1750000000000, local_present = 1 })
local row = store:_getRowRaw("h1")
assert.are.equal("finished", row.reading_status)
assert.are.equal(1750000000000, row.reading_status_updated_at)
store:close()
end)
it("parseSyncRow reads reading_status_updated_at from the server ISO field", function()
local parsed = LibraryStore.parseSyncRow({
book_hash = "h2", title = "T", reading_status = "abandoned",
reading_status_updated_at = "2026-06-18T00:00:00+00:00", updated_at = "2026-06-18T00:00:00+00:00",
})
-- 2026-06-18T00:00:00Z = 1781740800 unix seconds = 1781740800000 ms
assert.are.equal(1781740800000, parsed.reading_status_updated_at)
assert.are.equal("abandoned", parsed.reading_status)
end)
end)
-- =====================================================================
@@ -0,0 +1,203 @@
-- readingstatus_spec.lua — contract for library/readingstatus.lua
require("spec_helper")
local RS = require("library.readingstatus")
-- A "now" clearly larger than any KOReader summary.modified ms used below,
-- so bootstrap stamps are unambiguous in assertions.
local NOW = 9000000000000
describe("readingstatus mapping (decisive-only)", function()
it("maps Readest -> KOReader (push targets)", function()
assert.are.equal("complete", RS.readest_to_ko("finished"))
assert.are.equal("abandoned", RS.readest_to_ko("abandoned"))
assert.is_nil(RS.readest_to_ko("unread")) -- clear -> "New"
assert.is_nil(RS.readest_to_ko("reading")) -- non-decisive, never pushed
assert.is_nil(RS.readest_to_ko(nil)) -- undefined
end)
it("maps KOReader -> Readest only for decisive statuses", function()
assert.are.equal("finished", RS.ko_to_readest("complete"))
assert.are.equal("abandoned", RS.ko_to_readest("abandoned"))
assert.is_nil(RS.ko_to_readest("reading")) -- auto-set on open: NOT captured
assert.is_nil(RS.ko_to_readest("New"))
assert.is_nil(RS.ko_to_readest(nil))
end)
it("knows which Readest statuses are decisive", function()
assert.is_true(RS.readest_decisive("finished"))
assert.is_true(RS.readest_decisive("abandoned"))
assert.is_true(RS.readest_decisive("unread"))
assert.is_false(RS.readest_decisive("reading"))
assert.is_false(RS.readest_decisive(nil))
end)
it("parses summary.modified to day ms", function()
assert.are.equal(os.time({ year = 2026, month = 6, day = 18, hour = 0, min = 0, sec = 0 }) * 1000,
RS.parse_modified_ms("2026-06-18"))
assert.is_nil(RS.parse_modified_ms(nil))
assert.is_nil(RS.parse_modified_ms("garbage"))
end)
end)
describe("readingstatus reconcile — non-decisive", function()
it("does nothing when neither side has a decisive status", function()
local r = RS.reconcile({ reading_status = nil, reading_status_updated_at = 0 },
{ status = nil, ts = 0 }, NOW)
assert.is_false(r.write_ko)
assert.is_false(r.write_store)
end)
it("ignores KOReader auto-'reading' against an undefined Readest book (reported case 2)", function()
local r = RS.reconcile({ reading_status = nil, reading_status_updated_at = 0 },
{ status = "reading", ts = 1000 }, NOW)
assert.is_false(r.write_ko)
assert.is_false(r.write_store)
end)
end)
describe("readingstatus reconcile — only one side decisive", function()
it("pushes a bootstrap Readest 'finished' down to an opened KO book + stamps (reported case 1)", function()
local r = RS.reconcile({ reading_status = "finished", reading_status_updated_at = 0 },
{ status = "reading", ts = 1000 }, NOW)
assert.is_true(r.write_ko)
assert.are.equal("complete", r.ko_status)
assert.is_true(r.write_store)
assert.are.equal("finished", r.readest_status)
assert.are.equal(NOW, r.ts) -- bootstrap stamp
end)
it("pushes a steady Readest 'finished' down without re-stamping the store", function()
local r = RS.reconcile({ reading_status = "finished", reading_status_updated_at = 500 },
{ status = "reading", ts = 1000 }, NOW)
assert.is_true(r.write_ko)
assert.are.equal("complete", r.ko_status)
assert.is_false(r.write_store) -- already finished@500
assert.are.equal(500, r.ts)
end)
it("captures a KO 'complete' into an undefined Readest book at the KO timestamp", function()
local r = RS.reconcile({ reading_status = nil, reading_status_updated_at = 0 },
{ status = "complete", ts = 300 }, NOW)
assert.is_false(r.write_ko)
assert.is_true(r.write_store)
assert.are.equal("finished", r.readest_status)
assert.are.equal(300, r.ts)
end)
it("captures a KO 'abandoned' even when Readest is in non-decisive 'reading'", function()
local r = RS.reconcile({ reading_status = "reading", reading_status_updated_at = 100 },
{ status = "abandoned", ts = 300 }, NOW)
assert.is_true(r.write_store)
assert.are.equal("abandoned", r.readest_status)
assert.are.equal(300, r.ts)
end)
end)
describe("readingstatus reconcile — both decisive", function()
it("is a no-op in steady state when both already agree", function()
local r = RS.reconcile({ reading_status = "finished", reading_status_updated_at = 500 },
{ status = "complete", ts = 300 }, NOW)
assert.is_false(r.write_ko)
assert.is_false(r.write_store)
end)
it("stamps a bootstrap book even when both already agree (exit bootstrap)", function()
local r = RS.reconcile({ reading_status = "finished", reading_status_updated_at = 0 },
{ status = "complete", ts = 300 }, NOW)
assert.is_false(r.write_ko)
assert.is_true(r.write_store)
assert.are.equal("finished", r.readest_status)
assert.are.equal(NOW, r.ts)
end)
it("bootstrap conflict: Readest is authoritative (finished beats KO on-hold)", function()
local r = RS.reconcile({ reading_status = "finished", reading_status_updated_at = 0 },
{ status = "abandoned", ts = 300 }, NOW)
assert.is_true(r.write_ko)
assert.are.equal("complete", r.ko_status) -- KO pulled to finished
assert.is_true(r.write_store)
assert.are.equal("finished", r.readest_status)
assert.are.equal(NOW, r.ts)
end)
it("steady conflict: Readest wins when its status timestamp is newer", function()
local r = RS.reconcile({ reading_status = "finished", reading_status_updated_at = 500 },
{ status = "abandoned", ts = 300 }, NOW)
assert.is_true(r.write_ko)
assert.are.equal("complete", r.ko_status)
assert.is_false(r.write_store)
assert.are.equal("finished", r.readest_status)
end)
it("steady conflict: KOReader wins when the sidecar change is newer", function()
local r = RS.reconcile({ reading_status = "finished", reading_status_updated_at = 300 },
{ status = "abandoned", ts = 500 }, NOW)
assert.is_false(r.write_ko) -- KO already abandoned
assert.is_true(r.write_store)
assert.are.equal("abandoned", r.readest_status)
assert.are.equal(500, r.ts)
end)
it("bootstrap: a Readest 'unread' reset clears a KO 'complete' (Readest authoritative)", function()
local r = RS.reconcile({ reading_status = "unread", reading_status_updated_at = 0 },
{ status = "complete", ts = 300 }, NOW)
assert.is_true(r.write_ko)
assert.is_nil(r.ko_status) -- clear -> "New"
assert.is_true(r.write_store)
assert.are.equal("unread", r.readest_status)
assert.are.equal(NOW, r.ts)
end)
end)
describe("readingstatus reconcile — remaining transfer-graph cells", function()
it("unread × reading: pushes a clear to KO + stamps", function()
local r = RS.reconcile({ reading_status = "unread", reading_status_updated_at = 0 },
{ status = "reading", ts = 1000 }, NOW)
assert.is_true(r.write_ko)
assert.is_nil(r.ko_status) -- clear -> "New"
assert.is_true(r.write_store)
assert.are.equal("unread", r.readest_status)
assert.are.equal(NOW, r.ts)
end)
it("abandoned × reading: pushes 'abandoned' down to KO + stamps", function()
local r = RS.reconcile({ reading_status = "abandoned", reading_status_updated_at = 0 },
{ status = "reading", ts = 1000 }, NOW)
assert.is_true(r.write_ko)
assert.are.equal("abandoned", r.ko_status)
assert.is_true(r.write_store)
assert.are.equal("abandoned", r.readest_status)
end)
it("abandoned × complete (bootstrap conflict): Readest 'abandoned' wins, KO pulled to abandoned", function()
local r = RS.reconcile({ reading_status = "abandoned", reading_status_updated_at = 0 },
{ status = "complete", ts = 300 }, NOW)
assert.is_true(r.write_ko)
assert.are.equal("abandoned", r.ko_status)
assert.is_true(r.write_store)
assert.are.equal("abandoned", r.readest_status)
assert.are.equal(NOW, r.ts)
end)
end)
describe("readingstatus reconcile — convergence (no ping-pong)", function()
it("converges after a capture", function()
local r = RS.reconcile({ reading_status = nil, reading_status_updated_at = 0 },
{ status = "complete", ts = 300 }, NOW)
-- equalize: store now holds the captured status; KO already had it
local r2 = RS.reconcile({ reading_status = r.readest_status, reading_status_updated_at = r.ts },
{ status = "complete", ts = 300 }, NOW)
assert.is_false(r2.write_ko)
assert.is_false(r2.write_store)
end)
it("converges after a bootstrap push", function()
local r = RS.reconcile({ reading_status = "finished", reading_status_updated_at = 0 },
{ status = "reading", ts = 1000 }, NOW)
-- equalize: store stamped to NOW; KO sidecar written to r.ko_status
local r2 = RS.reconcile({ reading_status = r.readest_status, reading_status_updated_at = r.ts },
{ status = r.ko_status, ts = 1000 }, NOW)
assert.is_false(r2.write_ko)
assert.is_false(r2.write_store)
end)
end)
@@ -0,0 +1,81 @@
require("spec_helper")
local StatusSync = require("library.statussync")
local LibraryStore = require("library.librarystore")
local function fake_deps(summaries, writes)
return {
now_ms = function() return 1750000000000 end,
open_summary = function(path) return summaries[path] end, -- {status, modified}
write_status = function(path, ko_status) writes[path] = ko_status end,
}
end
describe("statussync.reconcileLocalStatuses", function()
it("applies a newer cloud status down to the sidecar", function()
local store = LibraryStore.new({ user_id = "u1", db_path = ":memory:" })
store:upsertBook({ hash = "h1", title = "T", file_path = "/b1.epub", local_present = 1,
reading_status = "finished", reading_status_updated_at = 1770000000000 })
local summaries = { ["/b1.epub"] = { status = "reading", modified = "2026-01-01" } }
local writes = {}
StatusSync.reconcileLocalStatuses(store, fake_deps(summaries, writes))
assert.are.equal("complete", writes["/b1.epub"])
store:close()
end)
it("captures a newer sidecar status into the store and bumps updated_at", function()
local RS = require("library.readingstatus")
local expected_ts = RS.parse_modified_ms("2026-06-18")
local store = LibraryStore.new({ user_id = "u1", db_path = ":memory:" })
store:upsertBook({ hash = "h2", title = "T", file_path = "/b2.epub", local_present = 1,
reading_status = "reading", reading_status_updated_at = 100 })
local summaries = { ["/b2.epub"] = { status = "complete", modified = "2026-06-18" } }
StatusSync.reconcileLocalStatuses(store, fake_deps(summaries, {}))
local row = store:_getRowRaw("h2")
assert.are.equal("finished", row.reading_status)
-- reading_status_updated_at must equal parse_modified_ms("2026-06-18")
assert.are.equal(expected_ts, row.reading_status_updated_at)
-- updated_at must have advanced past the pre-set 100ms value
assert.is_true(row.updated_at > 100, "updated_at should have advanced past 100")
store:close()
end)
it("skips rows without a local file", function()
local store = LibraryStore.new({ user_id = "u1", db_path = ":memory:" })
store:upsertBook({ hash = "h3", title = "T", uploaded_at = 1, local_present = 0,
reading_status = "finished", reading_status_updated_at = 1 })
local writes = {}
StatusSync.reconcileLocalStatuses(store, fake_deps({}, writes))
assert.are.same({}, writes)
store:close()
end)
it("first sync: pushes a bootstrap 'finished' to an opened KO book AND stamps the store", function()
-- Reported case 1: finished in Readest (never-stamped baseline), opened in
-- KOReader so its sidecar is the auto 'reading'. Must push complete down and
-- stamp the store so the book leaves bootstrap.
local store = LibraryStore.new({ user_id = "u1", db_path = ":memory:" })
store:upsertBook({ hash = "h4", title = "T", file_path = "/b4.epub", local_present = 1,
reading_status = "finished", reading_status_updated_at = 0 })
local summaries = { ["/b4.epub"] = { status = "reading", modified = "2026-01-01" } }
local writes = {}
StatusSync.reconcileLocalStatuses(store, fake_deps(summaries, writes))
assert.are.equal("complete", writes["/b4.epub"]) -- pushed down, no downgrade
local row = store:_getRowRaw("h4")
assert.are.equal("finished", row.reading_status)
assert.are.equal(1750000000000, row.reading_status_updated_at) -- stamped with now_ms
store:close()
end)
it("first sync: does not touch a book that is undefined in Readest and only 'reading' in KO (case 2)", function()
local store = LibraryStore.new({ user_id = "u1", db_path = ":memory:" })
store:upsertBook({ hash = "h5", title = "T", file_path = "/b5.epub", local_present = 1,
reading_status = nil, reading_status_updated_at = 0 })
local summaries = { ["/b5.epub"] = { status = "reading", modified = "2026-01-01" } }
local writes = {}
StatusSync.reconcileLocalStatuses(store, fake_deps(summaries, writes))
assert.are.same({}, writes)
local row = store:_getRowRaw("h5")
assert.is_nil(row.reading_status)
store:close()
end)
end)
@@ -213,6 +213,13 @@ describe("library.syncbooks", function()
it("returns nil for nil input (defensive)", function()
assert.is_nil(syncbooks._row_to_wire(nil))
end)
it("emits readingStatusUpdatedAt in the wire payload", function()
local wire = syncbooks._row_to_wire({ hash = "h1", title = "T",
reading_status = "finished", reading_status_updated_at = 1750000000000 })
assert.are.equal("finished", wire.readingStatus)
assert.are.equal(1750000000000, wire.readingStatusUpdatedAt)
end)
end)
-- =====================================================================
+1
View File
@@ -17,6 +17,7 @@ CREATE TABLE public.books (
uploaded_at timestamp with time zone NULL,
progress integer[] NULL,
reading_status text NULL,
reading_status_updated_at timestamp with time zone NULL,
group_id text NULL,
group_name text NULL,
metadata json NULL,
@@ -0,0 +1,11 @@
-- Migration 015: Add `reading_status_updated_at` to books
--
-- Field-level last-writer-wins for reading_status. The books row carries
-- both reading_status (rare, intentional) and a denormalized progress
-- (every page turn) under one updated_at, so whole-row LWW lets progress
-- updates clobber a status change across devices (issue #4634). A dedicated
-- per-field timestamp lets the merge resolve reading_status independently.
-- Additive + nullable; NULL is treated as epoch 0 (oldest) by the merge.
ALTER TABLE public.books
ADD COLUMN IF NOT EXISTS reading_status_updated_at timestamp with time zone NULL;