mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
80244ea4e2
Squash of feat/koreader-widget-board. KOReader plugin 2.0.0 gets the widget-board dashboard, redesigned book details, menus, lists and dialogs, Decor page and reading companions. Highlights, notes and bookmarks now stay in step across web, Kobo, KOReader and the phones (defects D-00 to D-20, migration 0121), and reading progress and narration hand off cleanly between devices.
247 lines
11 KiB
Lua
247 lines
11 KiB
Lua
-- Dogear sync: normalization, skip logic, live apply, sidecar apply,
|
|
-- deletion detection and the ack identity the server links devices by.
|
|
|
|
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;koreader-plugin/spec/?.lua;" .. package.path
|
|
|
|
local sidecar_store
|
|
local sidecar_flags
|
|
package.loaded["docsettings"] = {
|
|
open = function()
|
|
return {
|
|
readSetting = function(_, key)
|
|
if key == "annotations" then return sidecar_store end
|
|
end,
|
|
saveSetting = function(_, key, value)
|
|
if key == "annotations" then sidecar_store = value end
|
|
end,
|
|
makeTrue = function(_, key) sidecar_flags[key] = true end,
|
|
flush = function() sidecar_flags.flushed = true end,
|
|
}
|
|
end,
|
|
hasSidecarFile = function() return false end,
|
|
findSidecarFile = function() return nil end,
|
|
}
|
|
|
|
local handled_events = {}
|
|
package.loaded["ui/event"] = {
|
|
new = function(_, name, payload)
|
|
return { name = name, payload = payload }
|
|
end,
|
|
}
|
|
|
|
local dirty_calls = 0
|
|
package.loaded["ui/uimanager"] = {
|
|
setDirty = function() dirty_calls = dirty_calls + 1 end,
|
|
}
|
|
|
|
package.loaded["logger"] = { dbg = function() end }
|
|
|
|
-- Identity md5 is replaced by the input itself so assertions stay readable.
|
|
package.loaded["ffi/sha2"] = { md5 = function(value) return value end }
|
|
package.loaded["ffi/util"] = {
|
|
template = function(pattern, a) return (pattern:gsub("%%1", tostring(a))) end,
|
|
}
|
|
package.loaded["gettext"] = function(value) return value end
|
|
|
|
package.loaded["libs/libkoreader-lfs"] = { attributes = function() return nil end }
|
|
package.loaded["ui/widget/booklist"] = { setBookInfoCacheProperty = function() end }
|
|
|
|
local capability_answer = true
|
|
local marked_unsupported = false
|
|
package.loaded["bookorbit_capabilities"] = {
|
|
supports = function(_, feature)
|
|
if feature ~= "bookmarkSync" then return false end
|
|
return capability_answer
|
|
end,
|
|
markUnsupported = function() marked_unsupported = true end,
|
|
}
|
|
|
|
local BookOrbitSidecar = require("bookorbit_sidecar")
|
|
local BookOrbitBookmarks = require("bookorbit_bookmarks")
|
|
|
|
local function assertEqual(actual, expected, label)
|
|
if actual ~= expected then
|
|
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)), 2)
|
|
end
|
|
end
|
|
|
|
local function assertTrue(value, label)
|
|
if not value then error(label .. ": expected truthy", 2) end
|
|
end
|
|
|
|
-- normalizeBookmarks
|
|
|
|
local raw = {
|
|
{ page = "/body/DocFragment[2]/body/p[1]/text().0", datetime = "2026-06-01 10:00:00", chapter = "One", pageno = 4 },
|
|
{ page = "/body/DocFragment[3]", datetime = "2026-06-02 11:00:00", note = "Look", pageno = 9 },
|
|
-- A highlight: has a drawer, belongs to the annotation exchange.
|
|
{ page = "/body/DocFragment[4]", pos0 = "/body/DocFragment[4]", drawer = "lighten", datetime = "2026-06-03 12:00:00" },
|
|
-- A paging document dogear: the position is a page number, out of scope.
|
|
{ page = 12, datetime = "2026-06-04 13:00:00" },
|
|
-- Unusable datetime.
|
|
{ page = "/body/DocFragment[5]", datetime = "nonsense" },
|
|
}
|
|
|
|
local bookmarks, max_datetime, signature = BookOrbitSidecar.normalizeBookmarks(raw)
|
|
assertEqual(#bookmarks, 2, "only rolling dogears are normalized")
|
|
assertEqual(bookmarks[1].pos, "/body/DocFragment[2]/body/p[1]/text().0", "position comes from page")
|
|
assertEqual(bookmarks[1].chapter, "One", "chapter is carried")
|
|
assertEqual(bookmarks[1].pageno, 4, "pageno is carried")
|
|
assertEqual(bookmarks[2].note, "Look", "note is carried")
|
|
assertEqual(max_datetime, "2026-06-02 11:00:00", "max datetime ignores highlights")
|
|
|
|
local renamed = {
|
|
{ page = "/body/DocFragment[2]/body/p[1]/text().0", datetime = "2026-06-01 10:00:00", chapter = "One", pageno = 4 },
|
|
{ page = "/body/DocFragment[3]", datetime = "2026-06-02 11:00:00", note = "Renamed", pageno = 9 },
|
|
}
|
|
local _renamed, _renamed_max, renamed_signature = BookOrbitSidecar.normalizeBookmarks(renamed)
|
|
assertTrue(renamed_signature ~= signature,
|
|
"a note edit changes the signature even though KOReader leaves datetime_updated alone")
|
|
|
|
-- The highlight signature must not move, or every book re-exchanges after an update.
|
|
local annotations_before = select(3, BookOrbitSidecar.normalizeAnnotations(raw))
|
|
local annotations_after = select(3, BookOrbitSidecar.normalizeAnnotations(raw))
|
|
assertEqual(annotations_before, annotations_after, "highlight signature is stable")
|
|
|
|
-- keys and skip logic
|
|
|
|
local keys = BookOrbitBookmarks.collectKeys(bookmarks)
|
|
assertEqual(#keys, 2, "one key per bookmark")
|
|
assertEqual(keys[1].k, "2026-06-01 10:00:00|/body/DocFragment[2]/body/p[1]/text().0", "key is datetime|pos")
|
|
assertEqual(keys[1].dt, "2026-06-01 10:00:00", "key carries its datetime")
|
|
|
|
local book = {}
|
|
assertEqual(BookOrbitBookmarks.canSkipExchange(book, signature, 1000), false, "never exchanged, cannot skip")
|
|
BookOrbitBookmarks.rememberExchanged(book, signature, 1000)
|
|
assertEqual(BookOrbitBookmarks.canSkipExchange(book, signature, 1100), true, "unchanged set may skip")
|
|
assertEqual(BookOrbitBookmarks.canSkipExchange(book, renamed_signature, 1100), false, "changed set may not skip")
|
|
assertEqual(BookOrbitBookmarks.canSkipExchange(book, signature, 1000 + BookOrbitBookmarks.EXCHANGE_MAX_AGE + 1), false,
|
|
"a stale stamp may not skip")
|
|
assertEqual(BookOrbitBookmarks.canSkipExchange(book, signature, 500), false, "a stamp in the future may not skip")
|
|
|
|
-- capability gate
|
|
|
|
assertEqual(BookOrbitBookmarks.enabled({}, false), false, "the two-way toggle gates bookmark sync")
|
|
assertEqual(BookOrbitBookmarks.enabled({}, true), true, "an advertising server enables bookmark sync")
|
|
capability_answer = false
|
|
assertEqual(BookOrbitBookmarks.enabled({}, true), false, "a server without the capability stays dormant")
|
|
capability_answer = true
|
|
BookOrbitBookmarks.markUnsupported({})
|
|
assertTrue(marked_unsupported, "a confirmed 404 downgrades the capability")
|
|
|
|
-- live apply
|
|
|
|
local resolvable = {}
|
|
local annotations = {}
|
|
local added_items = {}
|
|
local removed_indexes = {}
|
|
local ui = {
|
|
document = {
|
|
isXPointerInDocument = function(_, pos) return resolvable[pos] == true end,
|
|
},
|
|
annotation = {
|
|
annotations = annotations,
|
|
addItem = function(self, item)
|
|
item.datetime = item.datetime or "2026-07-01 09:00:00"
|
|
table.insert(self.annotations, item)
|
|
table.insert(added_items, item)
|
|
return #self.annotations
|
|
end,
|
|
},
|
|
bookmark = {
|
|
getCurrentPageNumber = function() return "/body/DocFragment[1]" end,
|
|
setDogearVisibility = function() end,
|
|
removeItemByIndex = function(_, index)
|
|
table.insert(removed_indexes, index)
|
|
table.remove(annotations, index)
|
|
end,
|
|
},
|
|
toc = {
|
|
getTocTitleByPage = function(_, pos)
|
|
if pos == "/body/DocFragment[7]" then return "Chapter Seven" end
|
|
return ""
|
|
end,
|
|
},
|
|
handleEvent = function(_, event) table.insert(handled_events, event) end,
|
|
}
|
|
|
|
resolvable["/body/DocFragment[7]"] = true
|
|
resolvable["/body/DocFragment[8]"] = true
|
|
|
|
local applied, deleted, touched, deleted_touched = BookOrbitBookmarks.applyLive(ui, {
|
|
add = {
|
|
{ serverId = 1, pos = "/body/DocFragment[7]", title = "Chapter Seven" },
|
|
{ serverId = 2, pos = "/body/DocFragment[8]", title = "My own label" },
|
|
{ serverId = 3, pos = "/body/DocFragment[9]", title = "Broken" },
|
|
},
|
|
})
|
|
assertEqual(touched, 2, "two resolvable positions were added")
|
|
assertEqual(deleted_touched, 0, "nothing was deleted")
|
|
assertEqual(#deleted, 0, "no delete acks")
|
|
assertEqual(applied[1].status, "applied", "resolvable entry applied")
|
|
assertEqual(applied[1].key, "2026-07-01 09:00:00|/body/DocFragment[7]", "ack carries the device identity")
|
|
assertEqual(applied[1].pos, "/body/DocFragment[7]", "ack carries the device position")
|
|
assertEqual(added_items[1].chapter, "Chapter Seven", "the device fills the chapter from its own toc")
|
|
assertEqual(added_items[1].note, nil, "a title matching the chapter needs no note")
|
|
assertEqual(added_items[2].note, "My own label", "a title that is not the chapter becomes the note")
|
|
assertEqual(applied[3].status, "failed", "an unresolvable position fails instead of landing")
|
|
assertTrue(dirty_calls > 0, "the screen is refreshed after a change")
|
|
|
|
-- An entry already present locally is updated and acked with the local identity, not re-added.
|
|
local before_count = #annotations
|
|
local applied_again, _, updated_touched = BookOrbitBookmarks.applyLive(ui, {
|
|
add = { { serverId = 1, pos = "/body/DocFragment[7]", title = "Renamed elsewhere" } },
|
|
})
|
|
assertEqual(#annotations, before_count, "an already present position is not duplicated")
|
|
assertEqual(updated_touched, 1, "an existing dogear title is updated")
|
|
assertEqual(annotations[1].note, "Renamed elsewhere", "the updated title becomes the dogear note")
|
|
assertEqual(applied_again[1].key, "2026-07-01 09:00:00|/body/DocFragment[7]", "the existing identity is acked")
|
|
|
|
-- Delete by the identity the server was acked with.
|
|
local _a, deletes, _t, delete_touched = BookOrbitBookmarks.applyLive(ui, {
|
|
delete = {
|
|
{ serverId = 1, key = "2026-07-01 09:00:00|/body/DocFragment[7]", datetime = "2026-07-01 09:00:00" },
|
|
{ serverId = 99, key = "never|seen", datetime = "2020-01-01 00:00:00" },
|
|
},
|
|
})
|
|
assertEqual(delete_touched, 1, "only the known entry was removed")
|
|
assertEqual(#deletes, 2, "an already absent entry is still acked")
|
|
assertEqual(deletes[2].status, "applied", "a missing local entry counts as applied")
|
|
|
|
-- sidecar apply
|
|
|
|
sidecar_store = {
|
|
{ page = "/body/DocFragment[2]", datetime = "2026-06-01 10:00:00" },
|
|
{ page = "/body/DocFragment[4]", drawer = "lighten", pos0 = "/body/DocFragment[4]", datetime = "2026-06-03 12:00:00" },
|
|
}
|
|
sidecar_flags = {}
|
|
|
|
local s_applied, s_deleted, s_touched, s_deleted_touched = BookOrbitBookmarks.applySidecar("/books/one.epub", {
|
|
add = { { serverId = 5, pos = "/body/DocFragment[6]", title = "From the web", pageno = 12 } },
|
|
delete = { { serverId = 6, key = "2026-06-01 10:00:00|/body/DocFragment[2]", datetime = "2026-06-01 10:00:00" } },
|
|
})
|
|
assertEqual(s_touched, 2, "one add and one delete touched the sidecar")
|
|
assertEqual(s_deleted_touched, 1, "one delete landed")
|
|
assertEqual(#s_applied, 1, "one apply ack")
|
|
assertEqual(#s_deleted, 1, "one delete ack")
|
|
assertEqual(#sidecar_store, 2, "the highlight stayed and the new dogear was appended")
|
|
assertEqual(sidecar_store[2].note, "From the web", "a closed book has no toc, so the title lands in the note")
|
|
assertEqual(sidecar_store[2].pageno, 12, "the server pageno is kept")
|
|
assertTrue(sidecar_flags.annotations_externally_modified, "KOReader is asked to re-validate on open")
|
|
assertTrue(sidecar_flags.flushed, "the sidecar was flushed")
|
|
|
|
sidecar_flags = {}
|
|
local s_updated, _, s_update_touched = BookOrbitBookmarks.applySidecar("/books/one.epub", {
|
|
add = { { serverId = 5, pos = "/body/DocFragment[6]", title = "Renamed while closed", pageno = 13 } },
|
|
})
|
|
assertEqual(#s_updated, 1, "an existing sidecar dogear is acked")
|
|
assertEqual(s_update_touched, 1, "an existing sidecar dogear title is updated")
|
|
assertEqual(sidecar_store[2].note, "Renamed while closed", "the sidecar keeps the server title")
|
|
assertEqual(sidecar_store[2].pageno, 13, "the sidecar refreshes the page number")
|
|
assertTrue(sidecar_flags.flushed, "an updated sidecar is flushed")
|
|
|
|
-- A highlight must never be touched by the bookmark exchange.
|
|
assertEqual(sidecar_store[1].drawer, "lighten", "the highlight survived the dogear delete")
|
|
|
|
print("bookorbit_bookmarks_test.lua: ok")
|