Merge ticket/90-lnw-stale-row-repair (#90)
This commit is contained in:
@@ -244,6 +244,102 @@
|
||||
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;
|
||||
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;
|
||||
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.
|
||||
@@ -1243,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();
|
||||
@@ -1258,9 +1355,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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. 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 null;
|
||||
}
|
||||
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 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);
|
||||
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();
|
||||
applyLnwStaleRowRepair();
|
||||
render();
|
||||
maybeAutoUpdate();
|
||||
maybeCaptureLatestOnSeriesPage();
|
||||
@@ -1373,13 +1502,14 @@
|
||||
function init() {
|
||||
buildUI();
|
||||
state.page = detect();
|
||||
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();
|
||||
@@ -1620,7 +1750,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 };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -47,6 +47,7 @@ const {
|
||||
lightnovelworld,
|
||||
kindOf,
|
||||
maxChapter,
|
||||
repairLnwStaleRow,
|
||||
} = require("../novel-bookmark.user.js");
|
||||
|
||||
function loc(href) {
|
||||
@@ -331,6 +332,213 @@ test("latestChapterFromAnchors returns null when nothing matches", () => {
|
||||
assert.equal(maxChapter([], /chapter-([0-9.]+)/), null);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// repairLnwStaleRow — the row migration (spec seam 3)
|
||||
// ============================================================
|
||||
|
||||
// A row as bookmarkCurrent builds it, keyed under the invented Chapter Slug
|
||||
// identity the old adapter derived from the chapter address.
|
||||
function staleRow(over) {
|
||||
return Object.assign(
|
||||
{
|
||||
key: "lightnovelworld:my-longevity-simulation",
|
||||
site: "lightnovelworld",
|
||||
kind: "novel",
|
||||
series_id: "my-longevity-simulation",
|
||||
title: "My Longevity Simulation",
|
||||
series_url: "https://lightnovelworld.net/novel/my-longevity-simulation/",
|
||||
last_chapter: "Chapter 7",
|
||||
last_chapter_num: 7,
|
||||
last_chapter_url: "https://lightnovelworld.net/my-longevity-simulation-chapter-7/",
|
||||
updated_at: 1000,
|
||||
},
|
||||
over
|
||||
);
|
||||
}
|
||||
|
||||
// The divergent novel from research §3: the address's slug is a Chapter Slug,
|
||||
// the page pointer names the real Series.
|
||||
function lnwPage(over) {
|
||||
return Object.assign(
|
||||
{
|
||||
type: "chapter",
|
||||
site: "lightnovelworld",
|
||||
seriesId: "immortality-simulator",
|
||||
chapterSlug: "my-longevity-simulation",
|
||||
seriesUrl: "https://lightnovelworld.net/novel/immortality-simulator/",
|
||||
},
|
||||
over
|
||||
);
|
||||
}
|
||||
|
||||
test("repairLnwStaleRow rewrites the row, the queue entry and the last-checked map together", () => {
|
||||
const list = [staleRow()];
|
||||
const queue = [
|
||||
{ key: "lightnovelworld:my-longevity-simulation", op: "put", sendStatus: false, attempts: 2 },
|
||||
];
|
||||
const lastChecked = { "lightnovelworld:my-longevity-simulation": 12345 };
|
||||
const out = repairLnwStaleRow(list, queue, lastChecked, lnwPage());
|
||||
assert.equal(out.list.length, 1);
|
||||
assert.equal(out.list[0].key, "lightnovelworld:immortality-simulator");
|
||||
assert.equal(out.list[0].series_id, "immortality-simulator");
|
||||
assert.equal(out.list[0].series_url, "https://lightnovelworld.net/novel/immortality-simulator/");
|
||||
assert.deepEqual(out.queue, [
|
||||
{ key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: false, attempts: 2 },
|
||||
]);
|
||||
assert.deepEqual(out.lastChecked, { "lightnovelworld:immortality-simulator": 12345 });
|
||||
});
|
||||
|
||||
test("repairLnwStaleRow keeps Progress, Favourite and Lifecycle bucket on the rewritten row", () => {
|
||||
const stale = staleRow({
|
||||
last_chapter: "Chapter 42",
|
||||
last_chapter_num: 42,
|
||||
last_chapter_url: "https://lightnovelworld.net/my-longevity-simulation-chapter-42/",
|
||||
favorite: true,
|
||||
status: "archived",
|
||||
updated_at: 777,
|
||||
});
|
||||
const out = repairLnwStaleRow([stale], [], {}, lnwPage());
|
||||
const b = out.list[0];
|
||||
assert.equal(b.last_chapter, "Chapter 42");
|
||||
assert.equal(b.last_chapter_num, 42);
|
||||
assert.equal(
|
||||
b.last_chapter_url,
|
||||
"https://lightnovelworld.net/my-longevity-simulation-chapter-42/"
|
||||
);
|
||||
assert.equal(b.favorite, true);
|
||||
assert.equal(b.status, "archived");
|
||||
assert.equal(b.updated_at, 777);
|
||||
assert.equal(b.title, "My Longevity Simulation");
|
||||
});
|
||||
|
||||
test("repairLnwStaleRow changes nothing when the Chapter Slug and Series slug agree", () => {
|
||||
const list = [
|
||||
staleRow({ key: "lightnovelworld:a-will-eternal", series_id: "a-will-eternal" }),
|
||||
];
|
||||
const queue = [{ key: "lightnovelworld:a-will-eternal", op: "put", sendStatus: true, attempts: 1 }];
|
||||
const lastChecked = { "lightnovelworld:a-will-eternal": 99 };
|
||||
const out = repairLnwStaleRow(
|
||||
list,
|
||||
queue,
|
||||
lastChecked,
|
||||
lnwPage({
|
||||
chapterSlug: "a-will-eternal",
|
||||
seriesId: "a-will-eternal",
|
||||
seriesUrl: "https://lightnovelworld.net/novel/a-will-eternal/",
|
||||
})
|
||||
);
|
||||
assert.equal(out.list, list); // same references, nothing rewritten
|
||||
assert.equal(out.queue, queue);
|
||||
assert.equal(out.lastChecked, lastChecked);
|
||||
});
|
||||
|
||||
test("repairLnwStaleRow changes nothing when no row sits under the old key", () => {
|
||||
const list = [
|
||||
staleRow({
|
||||
key: "lightnovelworld:immortality-simulator",
|
||||
series_id: "immortality-simulator",
|
||||
series_url: "https://lightnovelworld.net/novel/immortality-simulator/",
|
||||
}),
|
||||
];
|
||||
const lastChecked = { "lightnovelworld:immortality-simulator": 99 };
|
||||
const out = repairLnwStaleRow(list, [], lastChecked, lnwPage());
|
||||
assert.equal(out.list, list);
|
||||
assert.equal(out.queue.length, 0);
|
||||
assert.equal(out.lastChecked, lastChecked);
|
||||
});
|
||||
|
||||
test("repairLnwStaleRow ignores a page that carries no Chapter Slug", () => {
|
||||
const list = [
|
||||
staleRow({
|
||||
key: "lightnovelworld:immortality-simulator",
|
||||
series_id: "immortality-simulator",
|
||||
}),
|
||||
];
|
||||
const lastChecked = { "lightnovelworld:immortality-simulator": 99 };
|
||||
// A series page (chapterSlug null) and another site both stay untouched.
|
||||
const series = repairLnwStaleRow(list, [], lastChecked, lnwPage({ chapterSlug: null }));
|
||||
const other = repairLnwStaleRow(list, [], lastChecked, {
|
||||
type: "chapter",
|
||||
site: "novelfull",
|
||||
seriesId: "x",
|
||||
});
|
||||
assert.equal(series.list, list);
|
||||
assert.equal(other.list, list);
|
||||
});
|
||||
|
||||
test("repairLnwStaleRow still moves the row and the last-checked map when the queue has no entry", () => {
|
||||
const out = repairLnwStaleRow(
|
||||
[staleRow()],
|
||||
[],
|
||||
{ "lightnovelworld:my-longevity-simulation": 5 },
|
||||
lnwPage()
|
||||
);
|
||||
assert.equal(out.list[0].key, "lightnovelworld:immortality-simulator");
|
||||
assert.deepEqual(out.queue, []);
|
||||
assert.deepEqual(out.lastChecked, { "lightnovelworld:immortality-simulator": 5 });
|
||||
});
|
||||
|
||||
test("repairLnwStaleRow carries a queued change to the repaired identity with its marker intact", () => {
|
||||
const queue = [
|
||||
{ key: "lightnovelworld:my-longevity-simulation", op: "put", sendStatus: true, attempts: 3 },
|
||||
];
|
||||
const out = repairLnwStaleRow([staleRow()], queue, {}, lnwPage());
|
||||
assert.deepEqual(out.queue, [
|
||||
{ key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: true, attempts: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("repairLnwStaleRow merges a duplicate under the repaired key, keeping the farther-ahead progress", () => {
|
||||
const canonical = staleRow({
|
||||
key: "lightnovelworld:immortality-simulator",
|
||||
series_id: "immortality-simulator",
|
||||
series_url: "https://lightnovelworld.net/novel/immortality-simulator/",
|
||||
last_chapter: "Chapter 5",
|
||||
last_chapter_num: 5,
|
||||
last_chapter_url: "https://lightnovelworld.net/immortality-simulator-chapter-5/",
|
||||
favorite: false,
|
||||
});
|
||||
const stale = staleRow({
|
||||
last_chapter: "Chapter 42",
|
||||
last_chapter_num: 42,
|
||||
last_chapter_url: "https://lightnovelworld.net/my-longevity-simulation-chapter-42/",
|
||||
favorite: true,
|
||||
status: "archived",
|
||||
});
|
||||
const queue = [
|
||||
{ key: "lightnovelworld:my-longevity-simulation", op: "put", sendStatus: true, attempts: 3 },
|
||||
{ key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: false, attempts: 0 },
|
||||
];
|
||||
const out = repairLnwStaleRow([canonical, stale], queue, {}, lnwPage());
|
||||
assert.equal(out.list.length, 1);
|
||||
const merged = out.list[0];
|
||||
assert.equal(merged.key, "lightnovelworld:immortality-simulator");
|
||||
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 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: true, attempts: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("repairLnwStaleRow does not regress progress when the repaired-key row is ahead", () => {
|
||||
const canonical = staleRow({
|
||||
key: "lightnovelworld:immortality-simulator",
|
||||
series_id: "immortality-simulator",
|
||||
series_url: "https://lightnovelworld.net/novel/immortality-simulator/",
|
||||
last_chapter: "Chapter 100",
|
||||
last_chapter_num: 100,
|
||||
last_chapter_url: "https://lightnovelworld.net/immortality-simulator-chapter-100/",
|
||||
});
|
||||
const stale = staleRow({ last_chapter: "Chapter 42", last_chapter_num: 42 });
|
||||
const out = repairLnwStaleRow([canonical, stale], [], {}, lnwPage());
|
||||
assert.equal(out.list.length, 1);
|
||||
assert.equal(out.list[0].last_chapter_num, 100);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// kindOf
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user