diff --git a/userscript/novel-bookmark.user.js b/userscript/novel-bookmark.user.js index 3d4884d..0241f11 100644 --- a/userscript/novel-bookmark.user.js +++ b/userscript/novel-bookmark.user.js @@ -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 }; } // ============================================================ diff --git a/userscript/test/novel-logic.test.js b/userscript/test/novel-logic.test.js index 9b8ef5b..61380b1 100644 --- a/userscript/test/novel-logic.test.js +++ b/userscript/test/novel-logic.test.js @@ -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 // ============================================================