#90: repair stale lnw rows in place on the next chapter visit

A row keyed under the Chapter Slug its address was built from, while the
page pointer names a different Series, is stale. Rewrite its key, identity
and address in the cache, the retry-queue entry (if any) and the per-device
last-checked map together, then sync through the queue-backed path so an
offline repair parks and replays. Pure transform exported for the harness;
wired before render() at both detect() call sites. A row already under the
repaired key merges, keeping the farther-ahead progress, favourite OR'd and
the stronger lifecycle bucket.
This commit is contained in:
2026-08-11 12:56:17 +07:00
parent e864770eb8
commit 8c172e3579
2 changed files with 317 additions and 1 deletions
+110 -1
View File
@@ -244,6 +244,90 @@
return ADAPTERS.find((a) => a.site === site) || null;
}
// A stale lightnovelworld row is one keyed under the Chapter Slug its
// address was built from, while the page's pointer names a different
// Series. All three storage sites must move together: the cache row alone
// leaves a queued write replaying under a key whose row no longer exists
// (that write is silently lost), and a last-checked timestamp left behind
// re-fetches the repaired row on the next visit. A repair must never cost a
// Reader the chapter they were tracking, so when a row already holds the
// repaired key the two rows merge and the farther-ahead progress wins.
// Pure: no storage access, no module state, no clock.
function repairLnwStaleRow(list, queue, lastChecked, page) {
if (
!page ||
page.site !== "lightnovelworld" ||
!page.chapterSlug ||
!page.seriesId ||
page.chapterSlug === page.seriesId
) {
return { list, queue, lastChecked };
}
const oldKey = "lightnovelworld:" + page.chapterSlug;
const newKey = "lightnovelworld:" + page.seriesId;
const stale = list.find((b) => b.key === oldKey);
if (!stale) return { list, queue, lastChecked };
let listOut;
const existing = list.find((b) => b.key === newKey);
if (existing) {
// Duplicate case (spec user story 2): one row must survive, and it is
// the one already under the repaired key — with the stale row's
// progress carried across when it is ahead, favourite OR'd, and the
// stronger lifecycle bucket kept (finished > archived > reading, so a
// merge can never silently un-archive or un-finish a row).
const merged = Object.assign({}, existing);
if (
existing.last_chapter_num == null ||
(stale.last_chapter_num != null && stale.last_chapter_num > existing.last_chapter_num)
) {
merged.last_chapter = stale.last_chapter;
merged.last_chapter_num = stale.last_chapter_num;
merged.last_chapter_url = stale.last_chapter_url;
}
merged.favorite = !!(existing.favorite || stale.favorite);
const rank = (s) => ({ finished: 2, archived: 1, reading: 0 }[s || "reading"] || 0);
merged.status = rank(stale.status) > rank(existing.status) ? stale.status : existing.status;
merged.updated_at = Math.max(existing.updated_at || 0, stale.updated_at || 0);
listOut = list.filter((b) => b.key !== oldKey).map((b) => (b.key === newKey ? merged : b));
} else {
listOut = list.map((b) =>
b.key === oldKey
? Object.assign({}, b, {
key: newKey,
series_id: page.seriesId,
series_url: page.seriesUrl,
})
: b
);
}
let queueOut = queue;
if (queue.some((e) => e.key === oldKey)) {
queueOut = queue
.map((e) => {
if (e.key !== oldKey) return e;
// An entry already under the repaired key wins: both markers read
// the same row from the cache at send time, so keeping both would
// replay one write twice.
if (queue.some((x) => x.key === newKey)) return null;
return Object.assign({}, e, { key: newKey });
})
.filter(Boolean);
}
let lastCheckedOut = lastChecked;
if (oldKey in lastChecked) {
lastCheckedOut = Object.assign({}, lastChecked);
// Max, not overwrite: a timestamp already under the repaired key must
// not be rolled back to an older one.
lastCheckedOut[newKey] = Math.max(lastCheckedOut[newKey] || 0, lastCheckedOut[oldKey]);
delete lastCheckedOut[oldKey];
}
return { list: listOut, queue: queueOut, lastChecked: lastCheckedOut };
}
// Highest chapter the site lists, or null when the markup yields nothing.
// seriesId is only consulted by adapters whose pages carry other series'
// chapter links; the rest ignore it.
@@ -1258,9 +1342,33 @@
}
}
// Runs the repair on every navigation, silently: rewrites a stale
// lightnovelworld row to the page's discovered identity, persists all three
// sites, and syncs through the queue-backed path so an offline repair parks
// and replays later. No toast — the Reader never asked for this. When the
// transform returns its inputs unchanged there is nothing to do.
function repairStaleLnwRow() {
const lastChecked = loadLastChecked();
const out = repairLnwStaleRow(state.list, queue, lastChecked, state.page);
if (out.list === state.list && out.queue === queue && out.lastChecked === lastChecked) return;
state.list = out.list;
reindex(); // byKey answers under the old key until rebuilt
saveCache(state.list);
queue.splice(0, queue.length, ...out.queue); // closures hold this array instance
saveQueue(queue);
saveLastChecked(out.lastChecked);
// Queue-backed sync, outcome swallowed: the repaired row is PUT (with its
// bucket when archived — the only status a userscript write may send) and
// the old server row is deleted so no duplicate survives on the wire.
const key = keyOf(state.page);
pushBookmark(key, statusOf(state.byKey[key]) === "archived").catch(() => {});
pushDelete("lightnovelworld:" + state.page.chapterSlug).catch(() => {});
}
let lastUrl = location.href;
function onNavigate() {
state.page = detect();
repairStaleLnwRow();
render();
maybeAutoUpdate();
maybeCaptureLatestOnSeriesPage();
@@ -1373,6 +1481,7 @@
function init() {
buildUI();
state.page = detect();
repairStaleLnwRow();
render();
installNavWatcher();
installLongPress();
@@ -1620,7 +1729,7 @@
// Exposes pure logic only — see userscript/test/novel-logic.test.js.
// ============================================================
if (typeof window === "undefined" && typeof module === "object" && module.exports) {
module.exports = { novelfull, lightnovelworld, anchorsFromHTML, statusOf, kindOf, maxChapter, escapeRe };
module.exports = { novelfull, lightnovelworld, anchorsFromHTML, statusOf, kindOf, maxChapter, escapeRe, repairLnwStaleRow };
}
// ============================================================