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;
|
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.
|
// Highest chapter the site lists, or null when the markup yields nothing.
|
||||||
// seriesId is only consulted by adapters whose pages carry other series'
|
// seriesId is only consulted by adapters whose pages carry other series'
|
||||||
// chapter links; the rest ignore it.
|
// chapter links; the rest ignore it.
|
||||||
@@ -1243,7 +1339,8 @@
|
|||||||
// Refresh + navigation
|
// 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
|
await drain(); // push what we owe before adopting the server's view of it
|
||||||
loading = true;
|
loading = true;
|
||||||
render();
|
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;
|
let lastUrl = location.href;
|
||||||
function onNavigate() {
|
function onNavigate() {
|
||||||
state.page = detect();
|
state.page = detect();
|
||||||
|
applyLnwStaleRowRepair();
|
||||||
render();
|
render();
|
||||||
maybeAutoUpdate();
|
maybeAutoUpdate();
|
||||||
maybeCaptureLatestOnSeriesPage();
|
maybeCaptureLatestOnSeriesPage();
|
||||||
@@ -1373,13 +1502,14 @@
|
|||||||
function init() {
|
function init() {
|
||||||
buildUI();
|
buildUI();
|
||||||
state.page = detect();
|
state.page = detect();
|
||||||
|
const repair = applyLnwStaleRowRepair();
|
||||||
render();
|
render();
|
||||||
installNavWatcher();
|
installNavWatcher();
|
||||||
installLongPress();
|
installLongPress();
|
||||||
window.addEventListener("online", drain); // signal returned while the page stayed open
|
window.addEventListener("online", drain); // signal returned while the page stayed open
|
||||||
// Sync first: both auto-record and the latest-chapter checks below need to
|
// Sync first: both auto-record and the latest-chapter checks below need to
|
||||||
// know which series are bookmarked and how fresh they are.
|
// know which series are bookmarked and how fresh they are.
|
||||||
refresh().then(() => {
|
refresh(repair).then(() => {
|
||||||
maybeAutoUpdate();
|
maybeAutoUpdate();
|
||||||
maybeCaptureLatestOnSeriesPage();
|
maybeCaptureLatestOnSeriesPage();
|
||||||
backgroundRefreshLatest();
|
backgroundRefreshLatest();
|
||||||
@@ -1620,7 +1750,7 @@
|
|||||||
// Exposes pure logic only — see userscript/test/novel-logic.test.js.
|
// Exposes pure logic only — see userscript/test/novel-logic.test.js.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
if (typeof window === "undefined" && typeof module === "object" && module.exports) {
|
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,
|
lightnovelworld,
|
||||||
kindOf,
|
kindOf,
|
||||||
maxChapter,
|
maxChapter,
|
||||||
|
repairLnwStaleRow,
|
||||||
} = require("../novel-bookmark.user.js");
|
} = require("../novel-bookmark.user.js");
|
||||||
|
|
||||||
function loc(href) {
|
function loc(href) {
|
||||||
@@ -331,6 +332,213 @@ test("latestChapterFromAnchors returns null when nothing matches", () => {
|
|||||||
assert.equal(maxChapter([], /chapter-([0-9.]+)/), null);
|
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
|
// kindOf
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user