mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(stats): repair stale KOReader file identities
Recompute the open file digest and recover previously verified matches without guessing between duplicate files. Preserve stranded statistics across lifecycle retries and advertise plugin version 1.5.3 so installed devices can update. Fixes #1497
This commit is contained in:
@@ -94,10 +94,15 @@ function BookOrbitBookSync.capture(plugin)
|
|||||||
metadata = BookOrbitStatsReader.primeIdentity(digest)
|
metadata = BookOrbitStatsReader.primeIdentity(digest)
|
||||||
end
|
end
|
||||||
metadata = metadata or {}
|
metadata = metadata or {}
|
||||||
local stats_ambiguous = metadata.metadata_ambiguous == true
|
local current_stats_id = stats and tonumber(stats.id_curr_book) or nil
|
||||||
|
local stats_ambiguous = current_stats_id == nil and metadata.metadata_ambiguous == true
|
||||||
local stats_ids = {}
|
local stats_ids = {}
|
||||||
for _, id in ipairs(metadata.ids or {}) do
|
if current_stats_id then
|
||||||
table.insert(stats_ids, id)
|
table.insert(stats_ids, current_stats_id)
|
||||||
|
else
|
||||||
|
for _, id in ipairs(metadata.ids or {}) do
|
||||||
|
table.insert(stats_ids, id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
logger.dbg(string.format("BookOrbit: lifecycle capture identity=%s elapsedMs=%.1f",
|
logger.dbg(string.format("BookOrbit: lifecycle capture identity=%s elapsedMs=%.1f",
|
||||||
cached and "cached" or "uncached", elapsedMs(started_ms)))
|
cached and "cached" or "uncached", elapsedMs(started_ms)))
|
||||||
@@ -109,6 +114,8 @@ function BookOrbitBookSync.capture(plugin)
|
|||||||
last_open = metadata.last_open or ts,
|
last_open = metadata.last_open or ts,
|
||||||
metadata_ambiguous = false,
|
metadata_ambiguous = false,
|
||||||
stats_metadata_ambiguous = stats_ambiguous,
|
stats_metadata_ambiguous = stats_ambiguous,
|
||||||
|
stats_identity_repaired = plugin.bookorbit_document_digest
|
||||||
|
and plugin.bookorbit_document_digest.repaired == true or false,
|
||||||
stats_ids = stats_ids,
|
stats_ids = stats_ids,
|
||||||
percentage = plugin:getLastPercent(),
|
percentage = plugin:getLastPercent(),
|
||||||
progress = plugin:getLastProgress(),
|
progress = plugin:getLastProgress(),
|
||||||
@@ -293,6 +300,7 @@ stepMatch = function(ctx)
|
|||||||
authors = ctx.snap.authors,
|
authors = ctx.snap.authors,
|
||||||
last_open = ctx.snap.last_open,
|
last_open = ctx.snap.last_open,
|
||||||
source = "current_file",
|
source = "current_file",
|
||||||
|
book_file_id = book and book.fileId or nil,
|
||||||
metadata_ambiguous = ctx.snap.metadata_ambiguous,
|
metadata_ambiguous = ctx.snap.metadata_ambiguous,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -344,7 +352,10 @@ stepStats = function(ctx)
|
|||||||
return step(ctx, stepAnnotations)
|
return step(ctx, stepAnnotations)
|
||||||
end
|
end
|
||||||
|
|
||||||
local watermark = book.statsWatermark or 0
|
if ctx.stats_watermark == nil then
|
||||||
|
ctx.stats_watermark = ctx.snap.stats_identity_repaired and 0 or (book.statsWatermark or 0)
|
||||||
|
end
|
||||||
|
local watermark = ctx.stats_watermark
|
||||||
local events = BookOrbitStatsReader.getEventsAfter(ctx.stat_ids, watermark, STATS_BATCH)
|
local events = BookOrbitStatsReader.getEventsAfter(ctx.stat_ids, watermark, STATS_BATCH)
|
||||||
if not events or #events == 0 then
|
if not events or #events == 0 then
|
||||||
if not acknowledge(ctx, "stats", false) then return end
|
if not acknowledge(ctx, "stats", false) then return end
|
||||||
@@ -365,6 +376,7 @@ stepStats = function(ctx)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local more = BookOrbitState.applyStatsAck(book, events, body, ctx.snap.digest, STATS_BATCH, watermark)
|
local more = BookOrbitState.applyStatsAck(book, events, body, ctx.snap.digest, STATS_BATCH, watermark)
|
||||||
|
ctx.stats_watermark = book.statsWatermark or watermark
|
||||||
ctx.counts.page_stats = ctx.counts.page_stats + #events
|
ctx.counts.page_stats = ctx.counts.page_stats + #events
|
||||||
if more then
|
if more then
|
||||||
return step(ctx, stepStats)
|
return step(ctx, stepStats)
|
||||||
|
|||||||
@@ -42,6 +42,19 @@ local function copyArray(values)
|
|||||||
return result
|
return result
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function mergeUniqueArrays(first, second)
|
||||||
|
local result, seen = {}, {}
|
||||||
|
for _, values in ipairs({ first or {}, second or {} }) do
|
||||||
|
for _, value in ipairs(values) do
|
||||||
|
if not seen[value] then
|
||||||
|
seen[value] = true
|
||||||
|
table.insert(result, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
local function validDigest(digest)
|
local function validDigest(digest)
|
||||||
return type(digest) == "string" and digest ~= "" and digest:match("^%x+$") ~= nil
|
return type(digest) == "string" and digest ~= "" and digest:match("^%x+$") ~= nil
|
||||||
end
|
end
|
||||||
@@ -408,6 +421,13 @@ function Outbox:enqueue(snapshot, opts)
|
|||||||
local stored_snapshot = {}
|
local stored_snapshot = {}
|
||||||
for key, value in pairs(snapshot) do stored_snapshot[key] = value end
|
for key, value in pairs(snapshot) do stored_snapshot[key] = value end
|
||||||
stored_snapshot.stats_ids = copyArray(snapshot.stats_ids)
|
stored_snapshot.stats_ids = copyArray(snapshot.stats_ids)
|
||||||
|
local existing_with_payload = existing and self:attachPayload(existing) or nil
|
||||||
|
if existing_with_payload and existing_with_payload.acknowledged.stats ~= true then
|
||||||
|
stored_snapshot.stats_ids = mergeUniqueArrays(
|
||||||
|
existing_with_payload.snapshot.stats_ids, stored_snapshot.stats_ids)
|
||||||
|
stored_snapshot.stats_identity_repaired = existing_with_payload.snapshot.stats_identity_repaired == true
|
||||||
|
or stored_snapshot.stats_identity_repaired == true
|
||||||
|
end
|
||||||
|
|
||||||
local annotation_sync = opts.annotation_sync ~= false
|
local annotation_sync = opts.annotation_sync ~= false
|
||||||
local entry = {
|
local entry = {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ local util = require("util")
|
|||||||
local T = require("ffi/util").template
|
local T = require("ffi/util").template
|
||||||
local _ = require("gettext")
|
local _ = require("gettext")
|
||||||
|
|
||||||
|
local BookOrbitStateManager = require("bookorbit_state_manager")
|
||||||
|
|
||||||
local API_CALL_DEBOUNCE_DELAY = time.s(25)
|
local API_CALL_DEBOUNCE_DELAY = time.s(25)
|
||||||
|
|
||||||
-- Assigned from the plugin class on install; shared with the menu module.
|
-- Assigned from the plugin class on install; shared with the menu module.
|
||||||
@@ -54,16 +56,31 @@ end
|
|||||||
-- partial MD5 of the file, so the filename checksum method does not exist here.
|
-- partial MD5 of the file, so the filename checksum method does not exist here.
|
||||||
function ProgressSync:getDocumentDigest()
|
function ProgressSync:getDocumentDigest()
|
||||||
if not self.ui or not self.ui.document then return nil end
|
if not self.ui or not self.ui.document then return nil end
|
||||||
local doc_settings = self.ui.doc_settings
|
|
||||||
local digest = doc_settings and doc_settings:readSetting("partial_md5_checksum") or nil
|
|
||||||
if digest then return digest end
|
|
||||||
|
|
||||||
local file = self.ui.document.file
|
local file = self.ui.document.file
|
||||||
if not file then return nil end
|
if not file then return nil end
|
||||||
|
if self.bookorbit_document_digest and self.bookorbit_document_digest.file == file then
|
||||||
|
return self.bookorbit_document_digest.digest
|
||||||
|
end
|
||||||
|
|
||||||
|
local doc_settings = self.ui.doc_settings
|
||||||
|
local cached = doc_settings and doc_settings:readSetting("partial_md5_checksum") or nil
|
||||||
local ok, computed = pcall(util.partialMD5, file)
|
local ok, computed = pcall(util.partialMD5, file)
|
||||||
if not ok or not computed then return nil end
|
if not ok or not computed then return cached end
|
||||||
if doc_settings then
|
|
||||||
doc_settings:saveSetting("partial_md5_checksum", computed)
|
self.bookorbit_document_digest = {
|
||||||
|
file = file,
|
||||||
|
digest = computed,
|
||||||
|
repaired = cached ~= nil and cached ~= computed,
|
||||||
|
}
|
||||||
|
if cached ~= computed then
|
||||||
|
if doc_settings then
|
||||||
|
doc_settings:saveSetting("partial_md5_checksum", computed)
|
||||||
|
end
|
||||||
|
local repaired, repair_err = pcall(
|
||||||
|
BookOrbitStateManager.repairFileIdentity, file, cached, computed)
|
||||||
|
if not repaired then
|
||||||
|
logger.warn("BookOrbit: could not repair cached file identity:", repair_err)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
return computed
|
return computed
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -166,6 +166,34 @@ function BookOrbitState:rememberFile(file, md5)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Repairs a path whose cached digest no longer matches the file on disk. A
|
||||||
|
-- server identity is never copied between digests: only an identity already
|
||||||
|
-- verified for the recomputed digest may be used to recover an ambiguous
|
||||||
|
-- server match.
|
||||||
|
function BookOrbitState:repairFileIdentity(file, old_md5, new_md5)
|
||||||
|
if not file or not new_md5 then return nil end
|
||||||
|
self.files[file] = new_md5
|
||||||
|
|
||||||
|
if old_md5 and old_md5 ~= new_md5 then
|
||||||
|
local old_book = self.books[old_md5]
|
||||||
|
if old_book and old_book.file == file then
|
||||||
|
old_book.file = nil
|
||||||
|
BookOrbitState.expireMatch(old_book)
|
||||||
|
end
|
||||||
|
self.unmatched[new_md5] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local new_book = self.books[new_md5]
|
||||||
|
if new_book then
|
||||||
|
new_book.file = file
|
||||||
|
if old_md5 ~= new_md5 then
|
||||||
|
BookOrbitState.expireMatch(new_book)
|
||||||
|
new_book.statsWatermark = 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return new_book
|
||||||
|
end
|
||||||
|
|
||||||
function BookOrbitState:flush()
|
function BookOrbitState:flush()
|
||||||
if self.flush_handler then
|
if self.flush_handler then
|
||||||
return self.flush_handler(self)
|
return self.flush_handler(self)
|
||||||
|
|||||||
@@ -415,6 +415,24 @@ function StateManager.linkFile(digest, book_file_id, book_id, file)
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function StateManager.repairFileIdentity(file, old_digest, new_digest)
|
||||||
|
if not file or not new_digest then return generation end
|
||||||
|
if old_digest == new_digest and ensure().files[file] == new_digest then
|
||||||
|
return generation
|
||||||
|
end
|
||||||
|
local digests = { new_digest }
|
||||||
|
if old_digest and old_digest ~= new_digest then
|
||||||
|
table.insert(digests, old_digest)
|
||||||
|
end
|
||||||
|
return StateManager.mutateScoped({
|
||||||
|
digests = digests,
|
||||||
|
files = { file },
|
||||||
|
global = false,
|
||||||
|
}, function(session)
|
||||||
|
session:repairFileIdentity(file, old_digest, new_digest)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
-- Test seam: forgets the shared instance and every derived cache.
|
-- Test seam: forgets the shared instance and every derived cache.
|
||||||
function StateManager.reset()
|
function StateManager.reset()
|
||||||
shared = nil
|
shared = nil
|
||||||
|
|||||||
@@ -445,6 +445,20 @@ local function historyEntry(ctx, file)
|
|||||||
|
|
||||||
local file_exists = lfs.attributes(file, "mode") == "file"
|
local file_exists = lfs.attributes(file, "mode") == "file"
|
||||||
local md5 = ctx.state.files[file]
|
local md5 = ctx.state.files[file]
|
||||||
|
local mapped_book = md5 and ctx.state:getBook(md5) or nil
|
||||||
|
local identity_conflict = mapped_book and mapped_book.file and mapped_book.file ~= file
|
||||||
|
if identity_conflict and file_exists then
|
||||||
|
local started = nowMs()
|
||||||
|
local ok, computed = pcall(util.partialMD5, file)
|
||||||
|
ctx.timing.partial_md5_ms = ctx.timing.partial_md5_ms + elapsedMs(started)
|
||||||
|
ctx.timing.partial_md5_count = ctx.timing.partial_md5_count + 1
|
||||||
|
if ok and computed then
|
||||||
|
if computed ~= md5 then
|
||||||
|
ctx.state:repairFileIdentity(file, md5, computed)
|
||||||
|
end
|
||||||
|
md5 = computed
|
||||||
|
end
|
||||||
|
end
|
||||||
if not md5 and file_exists and DocSettings:hasSidecarFile(file) then
|
if not md5 and file_exists and DocSettings:hasSidecarFile(file) then
|
||||||
local doc_settings = DocSettings:open(file)
|
local doc_settings = DocSettings:open(file)
|
||||||
md5 = doc_settings:readSetting("partial_md5_checksum")
|
md5 = doc_settings:readSetting("partial_md5_checksum")
|
||||||
@@ -476,6 +490,9 @@ local function historyEntry(ctx, file)
|
|||||||
end
|
end
|
||||||
ctx.candidates[md5] = cand
|
ctx.candidates[md5] = cand
|
||||||
local book = ctx.state:getBook(md5)
|
local book = ctx.state:getBook(md5)
|
||||||
|
if book and file_exists then
|
||||||
|
cand.book_file_id = book.fileId
|
||||||
|
end
|
||||||
if book and file_exists and not book.file then
|
if book and file_exists and not book.file then
|
||||||
book.file = file
|
book.file = file
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ local BookOrbitProgressSync = require("bookorbit_progress_sync")
|
|||||||
local BookOrbitSweep = require("bookorbit_sweep")
|
local BookOrbitSweep = require("bookorbit_sweep")
|
||||||
local BookOrbitUpdater = require("bookorbit_updater")
|
local BookOrbitUpdater = require("bookorbit_updater")
|
||||||
|
|
||||||
local PLUGIN_VERSION = "1.5.2"
|
local PLUGIN_VERSION = "1.5.3"
|
||||||
|
|
||||||
local SYNC_STRATEGY = {
|
local SYNC_STRATEGY = {
|
||||||
PROMPT = 1,
|
PROMPT = 1,
|
||||||
@@ -938,7 +938,8 @@ function BookOrbit:matchOpenBookForAutoSync(on_done)
|
|||||||
digests = { digest },
|
digests = { digest },
|
||||||
files = { self.ui.document.file },
|
files = { self.ui.document.file },
|
||||||
})
|
})
|
||||||
local had_local_match = state:getBook(digest) ~= nil
|
local local_book = state:getBook(digest)
|
||||||
|
local had_local_match = local_book ~= nil
|
||||||
-- Any local match used to end this path, with no freshness bound at all, so
|
-- Any local match used to end this path, with no freshness bound at all, so
|
||||||
-- a book whose server-side file was deleted or re-imported could stay
|
-- a book whose server-side file was deleted or re-imported could stay
|
||||||
-- wrongly matched forever. The bound here is a correctness fix.
|
-- wrongly matched forever. The bound here is a correctness fix.
|
||||||
@@ -956,6 +957,7 @@ function BookOrbit:matchOpenBookForAutoSync(on_done)
|
|||||||
[digest] = {
|
[digest] = {
|
||||||
title = titleFromFile(self.ui.document.file),
|
title = titleFromFile(self.ui.document.file),
|
||||||
source = "current_file",
|
source = "current_file",
|
||||||
|
book_file_id = local_book and local_book.fileId or nil,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if not body then
|
if not body then
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ local FakeScheduler = require("helpers/fake_scheduler")
|
|||||||
local scheduler
|
local scheduler
|
||||||
|
|
||||||
local calls
|
local calls
|
||||||
|
local last_match_candidates
|
||||||
|
local stats_watermarks
|
||||||
|
local page_stat_events
|
||||||
local match_fresh = false
|
local match_fresh = false
|
||||||
local exchange_skippable = false
|
local exchange_skippable = false
|
||||||
|
|
||||||
@@ -49,8 +52,9 @@ package.loaded["bookorbit_api"] = {
|
|||||||
new = function()
|
new = function()
|
||||||
return {
|
return {
|
||||||
isConfigured = function() return true end,
|
isConfigured = function() return true end,
|
||||||
matchCheck = function()
|
matchCheck = function(_, _, candidates)
|
||||||
table.insert(calls, "match")
|
table.insert(calls, "match")
|
||||||
|
last_match_candidates = candidates
|
||||||
return {
|
return {
|
||||||
libraryVersion = "lib-v1",
|
libraryVersion = "lib-v1",
|
||||||
matches = { { hash = "abcdef", bookFileId = 2, bookId = 1 } },
|
matches = { { hash = "abcdef", bookFileId = 2, bookId = 1 } },
|
||||||
@@ -60,6 +64,13 @@ package.loaded["bookorbit_api"] = {
|
|||||||
table.insert(calls, "state")
|
table.insert(calls, "state")
|
||||||
return { results = {} }
|
return { results = {} }
|
||||||
end,
|
end,
|
||||||
|
uploadPageStats = function(_, books)
|
||||||
|
table.insert(calls, "stats")
|
||||||
|
return {
|
||||||
|
results = { { hash = books[1].hash, watermark = books[1].events[#books[1].events].startTime } },
|
||||||
|
unmatched = {},
|
||||||
|
}
|
||||||
|
end,
|
||||||
updateProgress = function()
|
updateProgress = function()
|
||||||
table.insert(calls, "progress")
|
table.insert(calls, "progress")
|
||||||
return {}
|
return {}
|
||||||
@@ -106,7 +117,10 @@ package.loaded["bookorbit_state"] = {
|
|||||||
}
|
}
|
||||||
package.loaded["bookorbit_stats_reader"] = {
|
package.loaded["bookorbit_stats_reader"] = {
|
||||||
getBookIds = function() return {} end,
|
getBookIds = function() return {} end,
|
||||||
getEventsAfter = function() return {} end,
|
getEventsAfter = function(_, watermark)
|
||||||
|
table.insert(stats_watermarks, watermark)
|
||||||
|
return page_stat_events
|
||||||
|
end,
|
||||||
}
|
}
|
||||||
package.loaded["bookorbit_sweep"] = { isRunning = function() return false end }
|
package.loaded["bookorbit_sweep"] = { isRunning = function() return false end }
|
||||||
|
|
||||||
@@ -121,7 +135,15 @@ end
|
|||||||
local function run(opts)
|
local function run(opts)
|
||||||
scheduler = FakeScheduler.new()
|
scheduler = FakeScheduler.new()
|
||||||
calls = {}
|
calls = {}
|
||||||
book = { bookId = 1, fileId = 2, file = "/books/a.epub", statsWatermark = 0, annWatermark = "" }
|
stats_watermarks = {}
|
||||||
|
page_stat_events = opts.page_stat_events or {}
|
||||||
|
book = {
|
||||||
|
bookId = 1,
|
||||||
|
fileId = 2,
|
||||||
|
file = "/books/a.epub",
|
||||||
|
statsWatermark = opts.stats_watermark or 0,
|
||||||
|
annWatermark = "",
|
||||||
|
}
|
||||||
local acknowledged = {}
|
local acknowledged = {}
|
||||||
local finished
|
local finished
|
||||||
assert(BookSync.run{
|
assert(BookSync.run{
|
||||||
@@ -130,6 +152,7 @@ local function run(opts)
|
|||||||
digest = "abcdef",
|
digest = "abcdef",
|
||||||
file = "/books/a.epub",
|
file = "/books/a.epub",
|
||||||
stats_ids = { 42 },
|
stats_ids = { 42 },
|
||||||
|
stats_identity_repaired = opts.stats_identity_repaired == true,
|
||||||
annotations = {},
|
annotations = {},
|
||||||
ann_count = 0,
|
ann_count = 0,
|
||||||
ann_signature = "0::0:0",
|
ann_signature = "0::0:0",
|
||||||
@@ -164,6 +187,8 @@ exchange_skippable = false
|
|||||||
local requests, acks = run{}
|
local requests, acks = run{}
|
||||||
assertEqual(requests, "match,annotations,progress",
|
assertEqual(requests, "match,annotations,progress",
|
||||||
"a book with no usable local freshness still performs the full request chain")
|
"a book with no usable local freshness still performs the full request chain")
|
||||||
|
assertEqual(last_match_candidates.abcdef.book_file_id, 2,
|
||||||
|
"current-book recovery carries the previously verified server file id")
|
||||||
assertEqual(acks, "match,stats,annotations,state,progress", "every phase is acknowledged")
|
assertEqual(acks, "match,stats,annotations,state,progress", "every phase is acknowledged")
|
||||||
|
|
||||||
match_fresh = true
|
match_fresh = true
|
||||||
@@ -188,4 +213,14 @@ assertEqual(requests, "state,progress",
|
|||||||
"a locally changed state uploads without forcing a pull")
|
"a locally changed state uploads without forcing a pull")
|
||||||
state_payload = nil
|
state_payload = nil
|
||||||
|
|
||||||
|
match_fresh = true
|
||||||
|
exchange_skippable = true
|
||||||
|
requests = run{
|
||||||
|
stats_identity_repaired = true,
|
||||||
|
stats_watermark = 900,
|
||||||
|
page_stat_events = { { page = 1, startTime = 500, durationSeconds = 60, totalPages = 100 } },
|
||||||
|
}
|
||||||
|
assertEqual(stats_watermarks[1], 0, "identity repair replays exact-row history before the stored watermark")
|
||||||
|
assertEqual(requests, "stats,progress", "the stranded historical event is uploaded under the repaired digest")
|
||||||
|
|
||||||
print("bookorbit_book_sync_fast_path_test.lua: ok")
|
print("bookorbit_book_sync_fast_path_test.lua: ok")
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ local plugin = {
|
|||||||
doc_settings = { readSetting = function() return {} end },
|
doc_settings = { readSetting = function() return {} end },
|
||||||
statistics = {
|
statistics = {
|
||||||
settings = { is_enabled = true },
|
settings = { is_enabled = true },
|
||||||
|
id_curr_book = 42,
|
||||||
insertDB = function() flushes = flushes + 1 end,
|
insertDB = function() flushes = flushes + 1 end,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -144,15 +145,21 @@ local warm = BookSync.capture(plugin)
|
|||||||
assertEqual(sqlite.opens, 1, "the fallback primes the cache for the next handler")
|
assertEqual(sqlite.opens, 1, "the fallback primes the cache for the next handler")
|
||||||
assertEqual(warm.stats_ids[1], 42, "the warmed capture carries the same row ids")
|
assertEqual(warm.stats_ids[1], 42, "the warmed capture carries the same row ids")
|
||||||
|
|
||||||
-- A book with no statistics row yet is a cached miss, not a cache bypass; the
|
-- A repaired digest can be a cached miss while KOReader still has historical
|
||||||
-- drain resolves it through getBookIds() instead.
|
-- events attached to the current row's old digest. The live row id is exact,
|
||||||
|
-- so capture carries it under the recomputed file hash without a second query.
|
||||||
book_rows = {}
|
book_rows = {}
|
||||||
|
plugin.ui.statistics.id_curr_book = 115
|
||||||
|
plugin.bookorbit_document_digest = { file = "/books/a.epub", digest = "abc123", repaired = true }
|
||||||
BookOrbitStatsReader.forgetIdentity()
|
BookOrbitStatsReader.forgetIdentity()
|
||||||
sqlite.opens = 0
|
sqlite.opens = 0
|
||||||
BookOrbitStatsReader.primeIdentity("abc123")
|
BookOrbitStatsReader.primeIdentity("abc123")
|
||||||
local missing = BookSync.capture(plugin)
|
local missing = BookSync.capture(plugin)
|
||||||
assertEqual(sqlite.opens, 1, "a cached miss is not re-queried by the handler")
|
assertEqual(sqlite.opens, 1, "a cached miss is not re-queried by the handler")
|
||||||
assertEqual(#missing.stats_ids, 0, "a missing statistics row captures no ids")
|
assertEqual(#missing.stats_ids, 1, "a repaired digest retains the live statistics row")
|
||||||
|
assertEqual(missing.stats_ids[1], 115, "historical events follow the exact current-book row")
|
||||||
|
assertEqual(missing.stats_metadata_ambiguous, false, "the exact current row is not ambiguous")
|
||||||
|
assertEqual(missing.stats_identity_repaired, true, "the snapshot requests an unwatermarked history replay")
|
||||||
local entry, was_cached = BookOrbitStatsReader.cachedIdentity("abc123")
|
local entry, was_cached = BookOrbitStatsReader.cachedIdentity("abc123")
|
||||||
assertEqual(entry, nil, "the cached miss stores no row")
|
assertEqual(entry, nil, "the cached miss stores no row")
|
||||||
assertEqual(was_cached, true, "a miss is still cached, so the handler stops looking")
|
assertEqual(was_cached, true, "a miss is still cached, so the handler stops looking")
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ local snapshot = {
|
|||||||
progress = "/4/2",
|
progress = "/4/2",
|
||||||
annotations = { { datetime = "2026-01-01", pos0 = "/1" } },
|
annotations = { { datetime = "2026-01-01", pos0 = "/1" } },
|
||||||
stats_ids = { 17, 23 },
|
stats_ids = { 17, 23 },
|
||||||
|
stats_identity_repaired = true,
|
||||||
}
|
}
|
||||||
|
|
||||||
local first = assert(outbox:enqueue(snapshot, { reason = "close", annotation_sync = true }))
|
local first = assert(outbox:enqueue(snapshot, { reason = "close", annotation_sync = true }))
|
||||||
@@ -50,10 +51,19 @@ assertEqual(first.snapshot.annotations[1].pos0, "/1", "captured annotations are
|
|||||||
assertEqual(first.snapshot.stats_ids[2], 23, "stable statistics ids are stored")
|
assertEqual(first.snapshot.stats_ids[2], 23, "stable statistics ids are stored")
|
||||||
|
|
||||||
now = 1001
|
now = 1001
|
||||||
local replacement = assert(outbox:enqueue(snapshot, { reason = "suspend", annotation_sync = true }))
|
local replacement_snapshot = {}
|
||||||
|
for key, value in pairs(snapshot) do replacement_snapshot[key] = value end
|
||||||
|
replacement_snapshot.stats_ids = { 23, 29 }
|
||||||
|
local replacement = assert(outbox:enqueue(replacement_snapshot, { reason = "suspend", annotation_sync = true }))
|
||||||
assertEqual(replacement.id, first.id, "unstarted snapshot is deduplicated by digest")
|
assertEqual(replacement.id, first.id, "unstarted snapshot is deduplicated by digest")
|
||||||
assertEqual(replacement.generation, first.generation + 1, "dedupe publishes a new generation")
|
assertEqual(replacement.generation, first.generation + 1, "dedupe publishes a new generation")
|
||||||
assertEqual(outbox:status().count, 1, "dedupe keeps one logical entry")
|
assertEqual(outbox:status().count, 1, "dedupe keeps one logical entry")
|
||||||
|
assertEqual(#replacement.snapshot.stats_ids, 3, "unacknowledged statistics rows survive snapshot replacement")
|
||||||
|
assertEqual(replacement.snapshot.stats_ids[1], 17, "the oldest pending statistics row is retained")
|
||||||
|
assertEqual(replacement.snapshot.stats_ids[2], 23, "duplicate statistics rows are deduplicated")
|
||||||
|
assertEqual(replacement.snapshot.stats_ids[3], 29, "the newest statistics row is appended")
|
||||||
|
assertEqual(replacement.snapshot.stats_identity_repaired, true,
|
||||||
|
"a pending identity-repair replay survives snapshot replacement")
|
||||||
|
|
||||||
fixture.fail_write = true
|
fixture.fail_write = true
|
||||||
local failed, failure = outbox:enqueue(snapshot, { reason = "close" })
|
local failed, failure = outbox:enqueue(snapshot, { reason = "close" })
|
||||||
|
|||||||
@@ -27,9 +27,16 @@ package.loaded["ui/uimanager"] = {
|
|||||||
unschedule = function() end,
|
unschedule = function() end,
|
||||||
getElapsedTimeSinceBoot = function() return 0 end,
|
getElapsedTimeSinceBoot = function() return 0 end,
|
||||||
}
|
}
|
||||||
package.loaded["logger"] = { dbg = function() end }
|
package.loaded["logger"] = { dbg = function() end, warn = function() end }
|
||||||
package.loaded["ui/time"] = { s = function(value) return value end }
|
package.loaded["ui/time"] = { s = function(value) return value end }
|
||||||
|
|
||||||
|
local repairs = {}
|
||||||
|
package.loaded["bookorbit_state_manager"] = {
|
||||||
|
repairFileIdentity = function(file, old_digest, new_digest)
|
||||||
|
table.insert(repairs, { file = file, old_digest = old_digest, new_digest = new_digest })
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
local partial_calls = 0
|
local partial_calls = 0
|
||||||
package.loaded["util"] = {
|
package.loaded["util"] = {
|
||||||
partialMD5 = function(file)
|
partialMD5 = function(file)
|
||||||
@@ -53,14 +60,16 @@ ProgressSync.install(plugin)
|
|||||||
|
|
||||||
plugin.ui = {
|
plugin.ui = {
|
||||||
document = {
|
document = {
|
||||||
file = "/tmp/book.epub",
|
file = "/books/book.epub",
|
||||||
info = { has_pages = true },
|
info = { has_pages = true },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
assertEqual(plugin:getDocumentDigest(), "computed:/tmp/book.epub", "digest computes without doc settings")
|
assertEqual(plugin:getDocumentDigest(), "computed:/books/book.epub", "digest computes without doc settings")
|
||||||
assertEqual(partial_calls, 1, "partial md5 called once")
|
assertEqual(partial_calls, 1, "partial md5 called once")
|
||||||
|
assertEqual(#repairs, 1, "missing cached identity is recorded")
|
||||||
|
|
||||||
local saved_digest
|
local saved_digest
|
||||||
|
plugin.bookorbit_document_digest = nil
|
||||||
plugin.ui.doc_settings = {
|
plugin.ui.doc_settings = {
|
||||||
readSetting = function()
|
readSetting = function()
|
||||||
return nil
|
return nil
|
||||||
@@ -69,15 +78,26 @@ plugin.ui.doc_settings = {
|
|||||||
saved_digest = value
|
saved_digest = value
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
assertEqual(plugin:getDocumentDigest(), "computed:/tmp/book.epub", "digest computes with empty doc settings")
|
assertEqual(plugin:getDocumentDigest(), "computed:/books/book.epub", "digest computes with empty doc settings")
|
||||||
assertEqual(saved_digest, "computed:/tmp/book.epub", "computed digest is cached when possible")
|
assertEqual(saved_digest, "computed:/books/book.epub", "computed digest is cached when possible")
|
||||||
|
|
||||||
|
plugin.bookorbit_document_digest = nil
|
||||||
plugin.ui.doc_settings = {
|
plugin.ui.doc_settings = {
|
||||||
readSetting = function()
|
readSetting = function()
|
||||||
return "cached"
|
return "cached"
|
||||||
end,
|
end,
|
||||||
|
saveSetting = function(_, _, value)
|
||||||
|
saved_digest = value
|
||||||
|
end,
|
||||||
}
|
}
|
||||||
assertEqual(plugin:getDocumentDigest(), "cached", "cached digest is returned")
|
local calls_before_repair = partial_calls
|
||||||
|
assertEqual(plugin:getDocumentDigest(), "computed:/books/book.epub", "stale cached digest is repaired")
|
||||||
|
assertEqual(saved_digest, "computed:/books/book.epub", "recomputed digest replaces the stale sidecar value")
|
||||||
|
assertEqual(repairs[#repairs].old_digest, "cached", "repair receives the stale digest")
|
||||||
|
assertEqual(repairs[#repairs].new_digest, "computed:/books/book.epub", "repair receives the actual digest")
|
||||||
|
assertEqual(plugin.bookorbit_document_digest.repaired, true, "the open document remembers that statistics recovery is required")
|
||||||
|
assertEqual(plugin:getDocumentDigest(), "computed:/books/book.epub", "actual digest is cached for the open document")
|
||||||
|
assertEqual(partial_calls, calls_before_repair + 1, "open document is hashed only once")
|
||||||
|
|
||||||
plugin.ui = nil
|
plugin.ui = nil
|
||||||
assertEqual(plugin:getDocumentDigest(), nil, "missing UI returns nil")
|
assertEqual(plugin:getDocumentDigest(), nil, "missing UI returns nil")
|
||||||
|
|||||||
@@ -49,9 +49,10 @@ package.loaded["ui/uimanager"] = {
|
|||||||
unschedule = function() end,
|
unschedule = function() end,
|
||||||
getElapsedTimeSinceBoot = function() return 10000 end,
|
getElapsedTimeSinceBoot = function() return 10000 end,
|
||||||
}
|
}
|
||||||
package.loaded["logger"] = { dbg = function() end }
|
package.loaded["logger"] = { dbg = function() end, warn = function() end }
|
||||||
package.loaded["ui/time"] = { s = function(value) return value end }
|
package.loaded["ui/time"] = { s = function(value) return value end }
|
||||||
package.loaded["util"] = { partialMD5 = function(file) return "digest:" .. tostring(file) end }
|
package.loaded["util"] = { partialMD5 = function(file) return "digest:" .. tostring(file) end }
|
||||||
|
package.loaded["bookorbit_state_manager"] = { repairFileIdentity = function() end }
|
||||||
|
|
||||||
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
|
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,47 @@ assertEqual(after_batch.byFileId[66], "/books/e.epub", "the second batched link
|
|||||||
assertEqual(after_batch.generation, batch_generation, "patched maps carry the batch generation")
|
assertEqual(after_batch.generation, batch_generation, "patched maps carry the batch generation")
|
||||||
assertEqual(disk.books.fff.bookId, 6, "every batched link is persisted")
|
assertEqual(disk.books.fff.bookId, 6, "every batched link is persisted")
|
||||||
|
|
||||||
|
-- A stale path mapping moves to the recomputed digest without copying an
|
||||||
|
-- identity between hashes. An identity already verified for the new digest is
|
||||||
|
-- retained and expired so the next match check can establish a server link.
|
||||||
|
StateManager.mutate(function(current)
|
||||||
|
current.books.old = {
|
||||||
|
bookId = 70,
|
||||||
|
fileId = 700,
|
||||||
|
file = "/books/repaired.epub",
|
||||||
|
matchVerifiedAt = 100,
|
||||||
|
matchVerifiedVersion = "old-version",
|
||||||
|
}
|
||||||
|
current.books.actual = {
|
||||||
|
bookId = 71,
|
||||||
|
fileId = 701,
|
||||||
|
file = "/books/other.epub",
|
||||||
|
statsWatermark = 250,
|
||||||
|
matchVerifiedAt = 200,
|
||||||
|
matchVerifiedVersion = "current-version",
|
||||||
|
}
|
||||||
|
current.unmatched.actual = 300
|
||||||
|
current.files["/books/repaired.epub"] = "old"
|
||||||
|
end)
|
||||||
|
flushes = 0
|
||||||
|
StateManager.repairFileIdentity("/books/repaired.epub", "old", "actual")
|
||||||
|
assertEqual(flushes, 1, "identity repair is persisted once")
|
||||||
|
assertEqual(disk.files["/books/repaired.epub"], "actual", "path points at the recomputed digest")
|
||||||
|
assertEqual(disk.books.old.file, nil, "stale digest releases the repaired path")
|
||||||
|
assertEqual(disk.books.actual.file, "/books/repaired.epub", "known actual digest owns the path")
|
||||||
|
assertEqual(disk.books.actual.fileId, 701, "actual digest keeps its independently verified file id")
|
||||||
|
assertEqual(disk.books.actual.matchVerifiedAt, nil, "actual digest is due for explicit recovery")
|
||||||
|
assertEqual(disk.books.actual.statsWatermark, 0, "identity repair replays the possibly stranded history")
|
||||||
|
assertEqual(disk.unmatched.actual, nil, "an old unmatched stamp cannot suppress recovery")
|
||||||
|
|
||||||
|
StateManager.mutate(function(current)
|
||||||
|
current.books.orphaned = { bookId = 80, fileId = 800, file = "/books/unknown.epub" }
|
||||||
|
current.files["/books/unknown.epub"] = "orphaned"
|
||||||
|
end)
|
||||||
|
StateManager.repairFileIdentity("/books/unknown.epub", "orphaned", "unknown")
|
||||||
|
assertEqual(disk.books.unknown, nil, "identity is never copied to an unknown digest")
|
||||||
|
assertEqual(disk.books.orphaned.file, nil, "unknown replacement still releases the stale path")
|
||||||
|
|
||||||
-- A session write publishes a new generation, so the next read rebuilds rather
|
-- A session write publishes a new generation, so the next read rebuilds rather
|
||||||
-- than serving a stale map.
|
-- than serving a stale map.
|
||||||
local state = StateManager.session({ digests = { "bbb" }, global = false })
|
local state = StateManager.session({ digests = { "bbb" }, global = false })
|
||||||
@@ -146,7 +187,7 @@ state:setUnmatched("bbb")
|
|||||||
state:flush()
|
state:flush()
|
||||||
attribute_calls = 0
|
attribute_calls = 0
|
||||||
local rebuilt = StateManager.onDeviceMaps()
|
local rebuilt = StateManager.onDeviceMaps()
|
||||||
assertEqual(attribute_calls, 5, "an external write forces one rebuild")
|
assertEqual(attribute_calls, 6, "an external write forces one rebuild")
|
||||||
assertEqual(rebuilt.byBookId[2], nil, "unmatched book leaves the map")
|
assertEqual(rebuilt.byBookId[2], nil, "unmatched book leaves the map")
|
||||||
assertEqual(rebuilt.byBookId[1], "/books/a.epub", "unaffected entries survive")
|
assertEqual(rebuilt.byBookId[1], "/books/a.epub", "unaffected entries survive")
|
||||||
|
|
||||||
|
|||||||
@@ -253,6 +253,41 @@ do
|
|||||||
assertEqual(#harness.calls.annotation_exchanges, 1, "the acknowledged sidecar is not exchanged again")
|
assertEqual(#harness.calls.annotation_exchanges, 1, "the acknowledged sidecar is not exchanged again")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- A path mapped to another matched book is a stale identity signal. The sweep
|
||||||
|
-- samples only that conflicted file, repairs the map, and carries the file id
|
||||||
|
-- already verified for the actual digest into the recovery match.
|
||||||
|
do
|
||||||
|
local file = "/books/repaired.epub"
|
||||||
|
local actual = "md5-" .. file
|
||||||
|
local harness = SweepHarness.install{
|
||||||
|
books = { { md5 = actual, id = 9, title = "Recovered", last_open = 500 } },
|
||||||
|
history = { { file = file, time = 500, text = "Recovered" } },
|
||||||
|
library_version = "v1",
|
||||||
|
state = {
|
||||||
|
books = {
|
||||||
|
stale = {
|
||||||
|
bookId = 1, fileId = 11, file = "/books/different.epub",
|
||||||
|
matchVerifiedAt = NOW, matchVerifiedVersion = "v1",
|
||||||
|
},
|
||||||
|
[actual] = {
|
||||||
|
bookId = 2, fileId = 22, file = file,
|
||||||
|
matchVerifiedAt = NOW - 2 * DAY, matchVerifiedVersion = "v1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
files = { [file] = "stale" },
|
||||||
|
global = { libraryVersion = "v1" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
local Sweep = require("bookorbit_sweep")
|
||||||
|
|
||||||
|
startSweep(harness, Sweep)
|
||||||
|
harness.scheduler:drain()
|
||||||
|
|
||||||
|
assertEqual(harness.state.files[file], actual, "sweep repairs the stale path digest")
|
||||||
|
assertEqual(harness.calls.match_candidates[1][actual].book_file_id, 22,
|
||||||
|
"sweep recovery carries only the actual digest's verified file id")
|
||||||
|
end
|
||||||
|
|
||||||
-- Cancellation stops the run at its next yield: the step already scheduled
|
-- Cancellation stops the run at its next yield: the step already scheduled
|
||||||
-- runs, sees it is no longer the current generation and writes nothing.
|
-- runs, sees it is no longer the current generation and writes nothing.
|
||||||
do
|
do
|
||||||
|
|||||||
@@ -78,8 +78,9 @@ local function installClient(handle)
|
|||||||
local responses = handle.responses
|
local responses = handle.responses
|
||||||
return {
|
return {
|
||||||
isConfigured = function() return true end,
|
isConfigured = function() return true end,
|
||||||
matchCheck = function(_, hashes)
|
matchCheck = function(_, hashes, candidates)
|
||||||
table.insert(handle.calls.match, hashes)
|
table.insert(handle.calls.match, hashes)
|
||||||
|
table.insert(handle.calls.match_candidates, candidates)
|
||||||
if responses.match_error then return nil, responses.match_error end
|
if responses.match_error then return nil, responses.match_error end
|
||||||
local matches = {}
|
local matches = {}
|
||||||
for _, hash in ipairs(hashes) do
|
for _, hash in ipairs(hashes) do
|
||||||
@@ -158,6 +159,7 @@ function SweepHarness.install(opts)
|
|||||||
},
|
},
|
||||||
calls = {
|
calls = {
|
||||||
match = {},
|
match = {},
|
||||||
|
match_candidates = {},
|
||||||
page_stats = {},
|
page_stats = {},
|
||||||
book_states = {},
|
book_states = {},
|
||||||
progress = {},
|
progress = {},
|
||||||
|
|||||||
@@ -320,7 +320,9 @@ describe('KOReader plugin update source wiring', () => {
|
|||||||
expect(bookSync).toContain('metadata = BookOrbitStatsReader.primeIdentity(digest)');
|
expect(bookSync).toContain('metadata = BookOrbitStatsReader.primeIdentity(digest)');
|
||||||
expect(statsReader).toContain('function BookOrbitStatsReader.primeIdentity(md5)');
|
expect(statsReader).toContain('function BookOrbitStatsReader.primeIdentity(md5)');
|
||||||
expect(statsReader).toContain('local book = BookOrbitStatsReader.getBook(md5)');
|
expect(statsReader).toContain('local book = BookOrbitStatsReader.getBook(md5)');
|
||||||
expect(bookSync).toContain('local stats_ambiguous = metadata.metadata_ambiguous == true');
|
expect(bookSync).toContain('local current_stats_id = stats and tonumber(stats.id_curr_book) or nil');
|
||||||
|
expect(bookSync).toContain('local stats_ambiguous = current_stats_id == nil and metadata.metadata_ambiguous == true');
|
||||||
|
expect(bookSync).toContain('table.insert(stats_ids, current_stats_id)');
|
||||||
expect(bookSync).toContain('title = stats_ambiguous and titleFromFile(file) or (metadata.title or titleFromFile(file))');
|
expect(bookSync).toContain('title = stats_ambiguous and titleFromFile(file) or (metadata.title or titleFromFile(file))');
|
||||||
expect(bookSync).toContain('authors = stats_ambiguous and nil or metadata.authors');
|
expect(bookSync).toContain('authors = stats_ambiguous and nil or metadata.authors');
|
||||||
expect(bookSync).toContain('last_open = metadata.last_open or ts');
|
expect(bookSync).toContain('last_open = metadata.last_open or ts');
|
||||||
@@ -331,6 +333,7 @@ describe('KOReader plugin update source wiring', () => {
|
|||||||
expect(bookSync).toContain('authors = ctx.snap.authors');
|
expect(bookSync).toContain('authors = ctx.snap.authors');
|
||||||
expect(bookSync).toContain('last_open = ctx.snap.last_open');
|
expect(bookSync).toContain('last_open = ctx.snap.last_open');
|
||||||
expect(bookSync).toContain('source = "current_file"');
|
expect(bookSync).toContain('source = "current_file"');
|
||||||
|
expect(bookSync).toContain('book_file_id = book and book.fileId or nil');
|
||||||
expect(bookSync).toContain('metadata_ambiguous = ctx.snap.metadata_ambiguous');
|
expect(bookSync).toContain('metadata_ambiguous = ctx.snap.metadata_ambiguous');
|
||||||
expect(bookSync).toContain('if ctx.snap.stats_metadata_ambiguous then');
|
expect(bookSync).toContain('if ctx.snap.stats_metadata_ambiguous then');
|
||||||
expect(bookSync).toContain('ctx.state:setMatched(match.hash, match.bookFileId, match.bookId, ctx.snap.file)');
|
expect(bookSync).toContain('ctx.state:setMatched(match.hash, match.bookFileId, match.bookId, ctx.snap.file)');
|
||||||
|
|||||||
@@ -138,6 +138,55 @@ describe('KoreaderPluginService', () => {
|
|||||||
expect(result.matches).toEqual([{ hash: HASH_A, bookId: 20, bookFileId: 11 }]);
|
expect(result.matches).toEqual([{ hash: HASH_A, bookId: 20, bookFileId: 11 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('links an opened file using its previously verified file identity', async () => {
|
||||||
|
koreaderRepo.resolveBookFilesByHashes.mockResolvedValue(new Map());
|
||||||
|
bookService.verifyFileAccess.mockResolvedValue({ id: 11, bookId: 21, libraryId: 1, format: 'epub', role: 'content' });
|
||||||
|
const dto = {
|
||||||
|
...deviceFields(),
|
||||||
|
hashes: [HASH_B],
|
||||||
|
books: [{ hash: HASH_B, source: 'current_file', bookFileId: 11, title: 'Recovered book' }],
|
||||||
|
} as MatchCheckDto;
|
||||||
|
|
||||||
|
const result = await service.matchCheck(makeUser(), dto);
|
||||||
|
|
||||||
|
expect(koreaderRepo.upsertBookHashLink).toHaveBeenCalledWith(7, HASH_B, 11, {
|
||||||
|
title: 'Recovered book',
|
||||||
|
authors: null,
|
||||||
|
lastOpen: null,
|
||||||
|
});
|
||||||
|
expect(result.matches).toEqual([{ hash: HASH_B, bookId: 21, bookFileId: 11 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not trust a file id reported only by a statistics row', async () => {
|
||||||
|
koreaderRepo.resolveBookFilesByHashes.mockResolvedValue(new Map());
|
||||||
|
const dto = {
|
||||||
|
...deviceFields(),
|
||||||
|
hashes: [HASH_B],
|
||||||
|
books: [{ hash: HASH_B, source: 'statistics', bookFileId: 11 }],
|
||||||
|
} as MatchCheckDto;
|
||||||
|
|
||||||
|
const result = await service.matchCheck(makeUser(), dto);
|
||||||
|
|
||||||
|
expect(bookService.verifyFileAccess).not.toHaveBeenCalled();
|
||||||
|
expect(koreaderRepo.upsertBookHashLink).not.toHaveBeenCalled();
|
||||||
|
expect(result.matches).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not link an opened file when the reported file is inaccessible', async () => {
|
||||||
|
koreaderRepo.resolveBookFilesByHashes.mockResolvedValue(new Map());
|
||||||
|
bookService.verifyFileAccess.mockRejectedValue(new Error('forbidden'));
|
||||||
|
const dto = {
|
||||||
|
...deviceFields(),
|
||||||
|
hashes: [HASH_B],
|
||||||
|
books: [{ hash: HASH_B, source: 'current_file', bookFileId: 11 }],
|
||||||
|
} as MatchCheckDto;
|
||||||
|
|
||||||
|
const result = await service.matchCheck(makeUser(), dto);
|
||||||
|
|
||||||
|
expect(koreaderRepo.upsertBookHashLink).not.toHaveBeenCalled();
|
||||||
|
expect(result.matches).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not rewrite an existing link when the explicit catalog file already resolves', async () => {
|
it('does not rewrite an existing link when the explicit catalog file already resolves', async () => {
|
||||||
const dto = {
|
const dto = {
|
||||||
...deviceFields(),
|
...deviceFields(),
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ export class KoreaderPluginService {
|
|||||||
const linked = new Map<string, ResolvedBookFile>();
|
const linked = new Map<string, ResolvedBookFile>();
|
||||||
for (const hash of hashes) {
|
for (const hash of hashes) {
|
||||||
const candidate = candidates.get(hash);
|
const candidate = candidates.get(hash);
|
||||||
if (!candidate?.bookFileId || candidate.source !== 'file') continue;
|
if (!candidate?.bookFileId || (candidate.source !== 'file' && candidate.source !== 'current_file')) continue;
|
||||||
if (resolved.get(hash)?.bookFileId === candidate.bookFileId) continue;
|
if (resolved.get(hash)?.bookFileId === candidate.bookFileId) continue;
|
||||||
|
|
||||||
const file = await this.bookService.verifyFileAccess(candidate.bookFileId, user).catch(() => null);
|
const file = await this.bookService.verifyFileAccess(candidate.bookFileId, user).catch(() => null);
|
||||||
|
|||||||
@@ -265,10 +265,10 @@ describe('KOReader page stats derivation (e2e)', { timeout: 180_000 }, () => {
|
|||||||
expect(await waitForAchievement(session!.userId, 'power_hour')).toBe(true);
|
expect(await waitForAchievement(session!.userId, 'power_hour')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// A file hash is not unique: identical bytes in two places are two book file rows. The resolver
|
// A file hash is not unique: identical bytes in two places are two book file rows. The server
|
||||||
// has to land on the same one every time, or a device's statistics split across both and the
|
// must refuse to guess, then honor an accessible file identity previously verified by the
|
||||||
// plugin sees its sync target move, which discards its local upload cursor.
|
// plugin so every later upload has one stable target.
|
||||||
it('resolves a hash shared by two book files to the same row on every upload', async () => {
|
it('requires and persists an explicit identity for a hash shared by two book files', async () => {
|
||||||
const duplicatePath = join(library.folderPath, 'page-stats-book-copy.epub');
|
const duplicatePath = join(library.folderPath, 'page-stats-book-copy.epub');
|
||||||
await copyFile(epubPath, duplicatePath);
|
await copyFile(epubPath, duplicatePath);
|
||||||
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
||||||
@@ -282,6 +282,31 @@ describe('KOReader page stats derivation (e2e)', { timeout: 180_000 }, () => {
|
|||||||
expect(sharing[0]!.id).toBe(epub.bookFileId);
|
expect(sharing[0]!.id).toBe(epub.bookFileId);
|
||||||
|
|
||||||
const firstStart = BASE_EPOCH + 20_000_000;
|
const firstStart = BASE_EPOCH + 20_000_000;
|
||||||
|
const ambiguous = await uploadPageStats([{ page: 1, startTime: firstStart, durationSeconds: 55, totalPages: TOTAL_PAGES }]);
|
||||||
|
expect(ambiguous.results).toEqual([]);
|
||||||
|
expect(ambiguous.unmatched).toEqual([fileHash]);
|
||||||
|
|
||||||
|
const beforeLink = await ctx.db
|
||||||
|
.select({ id: schema.koreaderPageStats.id })
|
||||||
|
.from(schema.koreaderPageStats)
|
||||||
|
.where(and(eq(schema.koreaderPageStats.deviceId, DEVICE_ID), gte(schema.koreaderPageStats.startTime, firstStart)));
|
||||||
|
expect(beforeLink).toEqual([]);
|
||||||
|
|
||||||
|
const matchResponse = await ctx.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/koreader/plugin/match-check',
|
||||||
|
headers: deviceHeaders(),
|
||||||
|
payload: {
|
||||||
|
deviceId: DEVICE_ID,
|
||||||
|
deviceModel: 'E2E',
|
||||||
|
pluginVersion: '1.5.3',
|
||||||
|
hashes: [fileHash],
|
||||||
|
books: [{ hash: fileHash, source: 'current_file', bookFileId: epub.bookFileId }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(matchResponse.statusCode).toBe(201);
|
||||||
|
expect(matchResponse.json().matches).toEqual([{ hash: fileHash, bookId: epub.bookId, bookFileId: epub.bookFileId }]);
|
||||||
|
|
||||||
await uploadPageStats([{ page: 1, startTime: firstStart, durationSeconds: 55, totalPages: TOTAL_PAGES }]);
|
await uploadPageStats([{ page: 1, startTime: firstStart, durationSeconds: 55, totalPages: TOTAL_PAGES }]);
|
||||||
await uploadPageStats([{ page: 2, startTime: firstStart + 60, durationSeconds: 55, totalPages: TOTAL_PAGES }]);
|
await uploadPageStats([{ page: 2, startTime: firstStart + 60, durationSeconds: 55, totalPages: TOTAL_PAGES }]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user