forked from akai/readest
72233e1c6a
* 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>
82 lines
4.2 KiB
Lua
82 lines
4.2 KiB
Lua
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)
|