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:
neon
2026-09-23 01:15:20 +00:00
parent 759ec730d9
commit 4a4b62bbef
19 changed files with 377 additions and 35 deletions
@@ -94,10 +94,15 @@ function BookOrbitBookSync.capture(plugin)
metadata = BookOrbitStatsReader.primeIdentity(digest)
end
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 = {}
for _, id in ipairs(metadata.ids or {}) do
table.insert(stats_ids, id)
if current_stats_id then
table.insert(stats_ids, current_stats_id)
else
for _, id in ipairs(metadata.ids or {}) do
table.insert(stats_ids, id)
end
end
logger.dbg(string.format("BookOrbit: lifecycle capture identity=%s elapsedMs=%.1f",
cached and "cached" or "uncached", elapsedMs(started_ms)))
@@ -109,6 +114,8 @@ function BookOrbitBookSync.capture(plugin)
last_open = metadata.last_open or ts,
metadata_ambiguous = false,
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,
percentage = plugin:getLastPercent(),
progress = plugin:getLastProgress(),
@@ -293,6 +300,7 @@ stepMatch = function(ctx)
authors = ctx.snap.authors,
last_open = ctx.snap.last_open,
source = "current_file",
book_file_id = book and book.fileId or nil,
metadata_ambiguous = ctx.snap.metadata_ambiguous,
},
})
@@ -344,7 +352,10 @@ stepStats = function(ctx)
return step(ctx, stepAnnotations)
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)
if not events or #events == 0 then
if not acknowledge(ctx, "stats", false) then return end
@@ -365,6 +376,7 @@ stepStats = function(ctx)
end
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
if more then
return step(ctx, stepStats)
@@ -42,6 +42,19 @@ local function copyArray(values)
return result
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)
return type(digest) == "string" and digest ~= "" and digest:match("^%x+$") ~= nil
end
@@ -408,6 +421,13 @@ function Outbox:enqueue(snapshot, opts)
local stored_snapshot = {}
for key, value in pairs(snapshot) do stored_snapshot[key] = value end
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 entry = {
@@ -20,6 +20,8 @@ local util = require("util")
local T = require("ffi/util").template
local _ = require("gettext")
local BookOrbitStateManager = require("bookorbit_state_manager")
local API_CALL_DEBOUNCE_DELAY = time.s(25)
-- 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.
function ProgressSync:getDocumentDigest()
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
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)
if not ok or not computed then return nil end
if doc_settings then
doc_settings:saveSetting("partial_md5_checksum", computed)
if not ok or not computed then return cached end
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
return computed
end
@@ -166,6 +166,34 @@ function BookOrbitState:rememberFile(file, md5)
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()
if self.flush_handler then
return self.flush_handler(self)
@@ -415,6 +415,24 @@ function StateManager.linkFile(digest, book_file_id, book_id, file)
})
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.
function StateManager.reset()
shared = nil
@@ -445,6 +445,20 @@ local function historyEntry(ctx, file)
local file_exists = lfs.attributes(file, "mode") == "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
local doc_settings = DocSettings:open(file)
md5 = doc_settings:readSetting("partial_md5_checksum")
@@ -476,6 +490,9 @@ local function historyEntry(ctx, file)
end
ctx.candidates[md5] = cand
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
book.file = file
end
+4 -2
View File
@@ -48,7 +48,7 @@ local BookOrbitProgressSync = require("bookorbit_progress_sync")
local BookOrbitSweep = require("bookorbit_sweep")
local BookOrbitUpdater = require("bookorbit_updater")
local PLUGIN_VERSION = "1.5.2"
local PLUGIN_VERSION = "1.5.3"
local SYNC_STRATEGY = {
PROMPT = 1,
@@ -938,7 +938,8 @@ function BookOrbit:matchOpenBookForAutoSync(on_done)
digests = { digest },
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
-- a book whose server-side file was deleted or re-imported could stay
-- wrongly matched forever. The bound here is a correctness fix.
@@ -956,6 +957,7 @@ function BookOrbit:matchOpenBookForAutoSync(on_done)
[digest] = {
title = titleFromFile(self.ui.document.file),
source = "current_file",
book_file_id = local_book and local_book.fileId or nil,
},
})
if not body then
@@ -8,6 +8,9 @@ local FakeScheduler = require("helpers/fake_scheduler")
local scheduler
local calls
local last_match_candidates
local stats_watermarks
local page_stat_events
local match_fresh = false
local exchange_skippable = false
@@ -49,8 +52,9 @@ package.loaded["bookorbit_api"] = {
new = function()
return {
isConfigured = function() return true end,
matchCheck = function()
matchCheck = function(_, _, candidates)
table.insert(calls, "match")
last_match_candidates = candidates
return {
libraryVersion = "lib-v1",
matches = { { hash = "abcdef", bookFileId = 2, bookId = 1 } },
@@ -60,6 +64,13 @@ package.loaded["bookorbit_api"] = {
table.insert(calls, "state")
return { results = {} }
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()
table.insert(calls, "progress")
return {}
@@ -106,7 +117,10 @@ package.loaded["bookorbit_state"] = {
}
package.loaded["bookorbit_stats_reader"] = {
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 }
@@ -121,7 +135,15 @@ end
local function run(opts)
scheduler = FakeScheduler.new()
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 finished
assert(BookSync.run{
@@ -130,6 +152,7 @@ local function run(opts)
digest = "abcdef",
file = "/books/a.epub",
stats_ids = { 42 },
stats_identity_repaired = opts.stats_identity_repaired == true,
annotations = {},
ann_count = 0,
ann_signature = "0::0:0",
@@ -164,6 +187,8 @@ exchange_skippable = false
local requests, acks = run{}
assertEqual(requests, "match,annotations,progress",
"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")
match_fresh = true
@@ -188,4 +213,14 @@ assertEqual(requests, "state,progress",
"a locally changed state uploads without forcing a pull")
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")
@@ -103,6 +103,7 @@ local plugin = {
doc_settings = { readSetting = function() return {} end },
statistics = {
settings = { is_enabled = true },
id_curr_book = 42,
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(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
-- drain resolves it through getBookIds() instead.
-- A repaired digest can be a cached miss while KOReader still has historical
-- 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 = {}
plugin.ui.statistics.id_curr_book = 115
plugin.bookorbit_document_digest = { file = "/books/a.epub", digest = "abc123", repaired = true }
BookOrbitStatsReader.forgetIdentity()
sqlite.opens = 0
BookOrbitStatsReader.primeIdentity("abc123")
local missing = BookSync.capture(plugin)
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")
assertEqual(entry, nil, "the cached miss stores no row")
assertEqual(was_cached, true, "a miss is still cached, so the handler stops looking")
@@ -42,6 +42,7 @@ local snapshot = {
progress = "/4/2",
annotations = { { datetime = "2026-01-01", pos0 = "/1" } },
stats_ids = { 17, 23 },
stats_identity_repaired = 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")
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.generation, first.generation + 1, "dedupe publishes a new generation")
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
local failed, failure = outbox:enqueue(snapshot, { reason = "close" })
@@ -27,9 +27,16 @@ package.loaded["ui/uimanager"] = {
unschedule = function() 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 }
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
package.loaded["util"] = {
partialMD5 = function(file)
@@ -53,14 +60,16 @@ ProgressSync.install(plugin)
plugin.ui = {
document = {
file = "/tmp/book.epub",
file = "/books/book.epub",
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(#repairs, 1, "missing cached identity is recorded")
local saved_digest
plugin.bookorbit_document_digest = nil
plugin.ui.doc_settings = {
readSetting = function()
return nil
@@ -69,15 +78,26 @@ plugin.ui.doc_settings = {
saved_digest = value
end,
}
assertEqual(plugin:getDocumentDigest(), "computed:/tmp/book.epub", "digest computes with empty doc settings")
assertEqual(saved_digest, "computed:/tmp/book.epub", "computed digest is cached when possible")
assertEqual(plugin:getDocumentDigest(), "computed:/books/book.epub", "digest computes with empty doc settings")
assertEqual(saved_digest, "computed:/books/book.epub", "computed digest is cached when possible")
plugin.bookorbit_document_digest = nil
plugin.ui.doc_settings = {
readSetting = function()
return "cached"
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
assertEqual(plugin:getDocumentDigest(), nil, "missing UI returns nil")
@@ -49,9 +49,10 @@ package.loaded["ui/uimanager"] = {
unschedule = function() 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["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
@@ -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(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
-- than serving a stale map.
local state = StateManager.session({ digests = { "bbb" }, global = false })
@@ -146,7 +187,7 @@ state:setUnmatched("bbb")
state:flush()
attribute_calls = 0
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[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")
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
-- runs, sees it is no longer the current generation and writes nothing.
do
@@ -78,8 +78,9 @@ local function installClient(handle)
local responses = handle.responses
return {
isConfigured = function() return true end,
matchCheck = function(_, hashes)
matchCheck = function(_, hashes, candidates)
table.insert(handle.calls.match, hashes)
table.insert(handle.calls.match_candidates, candidates)
if responses.match_error then return nil, responses.match_error end
local matches = {}
for _, hash in ipairs(hashes) do
@@ -158,6 +159,7 @@ function SweepHarness.install(opts)
},
calls = {
match = {},
match_candidates = {},
page_stats = {},
book_states = {},
progress = {},