lightnovelworld Series identity is read from the chapter page (#80) #92

Merged
sulthan merged 17 commits from feat/lnw-series-identity into main 2026-08-11 18:21:51 +07:00
2 changed files with 47 additions and 25 deletions
Showing only changes of commit e1911554ec - Show all commits
+43 -22
View File
@@ -303,17 +303,29 @@
}
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);
const oldEntry = queue.find((e) => e.key === oldKey);
if (oldEntry) {
const survivor = queue.find((x) => x.key === newKey);
if (!survivor) {
queueOut = queue.map((e) => (e.key === oldKey ? Object.assign({}, e, { key: newKey }) : e));
} else {
// Both keys hold a marker and one row survives, so the two collapse
// into one entry under the repaired key (the queue's one-entry-per-key
// invariant). sendStatus is sticky — an archive intent from either
// marker survives, the queue's own rule — and the worse attempts
// count wins. The stale-key marker's op is dropped: the row it
// described is retired by the merge itself.
queueOut = queue
.filter((e) => e.key !== oldKey && e.key !== newKey)
.concat([
{
key: newKey,
op: survivor.op,
sendStatus: survivor.sendStatus || oldEntry.sendStatus,
attempts: Math.max(survivor.attempts || 0, oldEntry.attempts || 0),
},
]);
}
}
let lastCheckedOut = lastChecked;
@@ -1327,7 +1339,8 @@
// Refresh + navigation
// ============================================================
async function refresh() {
async function refresh(awaitFirst) {
if (awaitFirst) await awaitFirst; // repair sync lands before we adopt the server's view of it
await drain(); // push what we owe before adopting the server's view of it
loading = true;
render();
@@ -1345,12 +1358,16 @@
// 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() {
// and replays later. No toast — the Reader never asked for this. Returns
// the sync promise (or null when nothing changed) so init can hold the
// server view until the repair has landed. When the transform returns its
// inputs unchanged there is nothing to do.
function applyLnwStaleRowRepair() {
const lastChecked = loadLastChecked();
const out = repairLnwStaleRow(state.list, queue, lastChecked, state.page);
if (out.list === state.list && out.queue === queue && out.lastChecked === lastChecked) return;
if (out.list === state.list && out.queue === queue && out.lastChecked === lastChecked) {
return null;
}
state.list = out.list;
reindex(); // byKey answers under the old key until rebuilt
saveCache(state.list);
@@ -1359,16 +1376,20 @@
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.
// the old server bookmark is deleted so no duplicate survives on the
// wire. The delete parks under the retired key while offline — the
// teardown of the old identity, not a new record of the Chapter Slug.
const key = keyOf(state.page);
pushBookmark(key, statusOf(state.byKey[key]) === "archived").catch(() => {});
pushDelete("lightnovelworld:" + state.page.chapterSlug).catch(() => {});
return Promise.all([
pushBookmark(key, statusOf(state.byKey[key]) === "archived"),
pushDelete("lightnovelworld:" + state.page.chapterSlug),
]).catch(() => {});
}
let lastUrl = location.href;
function onNavigate() {
state.page = detect();
repairStaleLnwRow();
applyLnwStaleRowRepair();
render();
maybeAutoUpdate();
maybeCaptureLatestOnSeriesPage();
@@ -1481,14 +1502,14 @@
function init() {
buildUI();
state.page = detect();
repairStaleLnwRow();
const repair = applyLnwStaleRowRepair();
render();
installNavWatcher();
installLongPress();
window.addEventListener("online", drain); // signal returned while the page stayed open
// Sync first: both auto-record and the latest-chapter checks below need to
// know which series are bookmarked and how fresh they are.
refresh().then(() => {
refresh(repair).then(() => {
maybeAutoUpdate();
maybeCaptureLatestOnSeriesPage();
backgroundRefreshLatest();
+4 -3
View File
@@ -516,10 +516,11 @@ test("repairLnwStaleRow merges a duplicate under the repaired key, keeping the f
assert.equal(merged.last_chapter_num, 42); // the stale row is ahead — progress must not be lost
assert.equal(merged.favorite, true); // favourite survives from either row
assert.equal(merged.status, "archived"); // the stronger bucket survives
// One queue marker per key: the repaired-key entry wins, the stale-key one
// is dropped rather than replaying the write twice.
// One marker under the repaired key: sendStatus is sticky (the archive
// intent from the stale-key marker survives) and the worse attempts count
// wins — the queue's own coalescing rules.
assert.deepEqual(out.queue, [
{ key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: false, attempts: 0 },
{ key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: true, attempts: 3 },
]);
});