Files
readest/apps/readest.koplugin/library/readingstatus.lua
T
Huang Xin 72233e1c6a 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>
2026-06-19 05:00:40 +02:00

109 lines
4.4 KiB
Lua

-- 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