Compare commits

..
Author SHA1 Message Date
MaashkinoozheandClaude Opus 5.5 618992ec14 Reader: stale reading sessions never move "Continue" back
A reader tab left open on one device (or restored by the browser) used to
save its old chapter as the newest position as soon as it was shown again.
Progress saves now carry the tab id and the newest progress time the tab
knows (`seen`); the server refuses a save when another tab or device saved
a newer position since. The reader then asks: jump to the newest position,
or keep reading here (which makes this chapter the head again). It checks on
open, when the tab becomes visible, and on every save. Reader links carry
`at` and the reader keeps it in the address bar, so reloads and restored
tabs still know what they had seen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:49:52 +01:00
9 changed files with 228 additions and 24 deletions

No files matched your search

+4 -2
View File
@@ -198,8 +198,9 @@ All requests and responses are JSON. POSTs require `Content-Type: application/js
| `POST /api/password` `{old, new}` | user | Change password | | `POST /api/password` `{old, new}` | user | Change password |
| `GET /api/library` | user | Manga list with covers, counts, the user's read count, unread notifications, last read, and optional personal Reading Story aggregates | | `GET /api/library` | user | Manga list with covers, counts, the user's read count, unread notifications, last read, and optional personal Reading Story aggregates |
| `GET /api/manga?m=` | user | Manga details, books, chapters with ranked versions, progress, optional chapter-time summaries, and `continue` target | | `GET /api/manga?m=` | user | Manga details, books, chapters with ranked versions, progress, optional chapter-time summaries, and `continue` target |
| `GET /api/chapter?m=&c=[&v=]` | user | Pages (`u, w, h, t, r`), direction, versions, prev/next, progress, optional Active Reading Clock summary, plus all books and chapters of the manga (with the user's read state) for the chapter picker. Also marks that chapter's notifications read | | `GET /api/chapter?m=&c=[&v=]` | user | Pages (`u, w, h, t, r`), direction, versions, prev/next, progress, `latest` (as in `/api/progress/latest`), optional Active Reading Clock summary, plus all books and chapters of the manga (with the user's read state) for the chapter picker. Also marks that chapter's notifications read |
| `POST /api/progress` `{m, c, v, p, n, done, shift}` | user | Save the reading position | | `POST /api/progress` `{m, c, v, p, n, done, shift[, tab, seen]}` | user | Save the reading position. With `seen`, a save is refused (`{ok:false, stale:true, latest}`) when another tab saved a newer position of this manga since; otherwise `{ok:true, t}` |
| `GET /api/progress/latest?m=` | user | The manga's newest saved position (`latest`: `t, w, c, label, v, p, n, done, continue`) or `null` |
| `POST /api/reading/pulse` `{m, c, v, session, seq, …deltas}` | user | When opted in, idempotently merge a clamped active-time pulse, verified page coverage and input totals under a single-reader lease | | `POST /api/reading/pulse` `{m, c, v, session, seq, …deltas}` | user | When opted in, idempotently merge a clamped active-time pulse, verified page coverage and input totals under a single-reader lease |
| `GET /api/reading/export` | user | Export the user's private derived reading-time summary without raw input or page-exposure diagnostics | | `GET /api/reading/export` | user | Export the user's private derived reading-time summary without raw input or page-exposure diagnostics |
| `POST /api/reading/delete` `{m}` or `{all:true,password}` | user | Delete one manga's reading history, or all history after password confirmation, without changing normal reading progress | | `POST /api/reading/delete` `{m}` or `{all:true,password}` | user | Delete one manga's reading history, or all history after password confirmation, without changing normal reading progress |
@@ -352,6 +353,7 @@ mode = adapt && intended ∉ compatible ? compatible[0] : intended
- Saved 700 ms after the position settles, and on `pagehide` (a `fetch` with `keepalive`). - Saved 700 ms after the position settles, and on `pagehide` (a `fetch` with `keepalive`).
- Stores the page index in the full page list, so skipping extras doesn't corrupt it. - Stores the page index in the full page list, so skipping extras doesn't corrupt it.
- On open, the saved page is used for the same version, and mapped proportionally for another version. A finished chapter reopens at the start. - On open, the saved page is used for the same version, and mapped proportionally for another version. A finished chapter reopens at the start.
- **Stale reading sessions.** A tab left open on one device (or restored by the browser) must not move "Continue" back after the user read on elsewhere. Each browser tab has an id (`sessionStorage`) that its saves store as `w`; reader links carry `at`, the newest progress time the linking page knew (`continue.at`), and the reader keeps `at` current in its address bar so a reload or restored tab still knows it. A link without `at` (bookmark, notification) counts as a deliberate open. When the manga's newest position is later than `at` and came from another tab, the reader saves nothing and asks: jump to the newest position, or keep reading here, which makes this chapter the head again. It checks on open, when the tab becomes visible again, and with every save (the server refuses the stale save). The comparison is by save time, not chapter order; the question names no device.
**Active Reading Clock (opt-in):** **Active Reading Clock (opt-in):**
- Runs only while the reader is visible and focused, a sufficiently exposed page is decoded, a server lease is held, and accepted reader input occurred in the last 50 seconds. - Runs only while the reader is visible and focused, a sufficiently exposed page is decoded, a server lease is held, and accepted reader input occurred in the last 50 seconds.
+8
View File
@@ -429,6 +429,14 @@ body.reader { position: fixed; top: 0; left: 0; right: 0; bottom: 0; overflow: h
.reading-gate-message[data-tone="retry"] { border-color: #9b7233; background: #382b1a; color: #ffdaa0; } .reading-gate-message[data-tone="retry"] { border-color: #9b7233; background: #382b1a; color: #ffdaa0; }
.reading-gate-message[data-tone="error"] { border-color: #944c55; background: #3c2025; color: #ffc0c6; } .reading-gate-message[data-tone="error"] { border-color: #944c55; background: #3c2025; color: #ffc0c6; }
@media (max-width: 660px) { .reading-gate { margin: 28px 12px 100px; padding: 18px; } } @media (max-width: 660px) { .reading-gate { margin: 28px 12px 100px; padding: 18px; } }
/* Stale reading session: a newer position was saved elsewhere since this page was opened. */
.stale-session { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center;
padding: 16px; background: rgba(0, 0, 0, .6); }
.stale-box { box-sizing: border-box; width: min(440px, 100%); padding: 20px; border-radius: 12px; background: var(--card);
border: 1px solid var(--line); box-shadow: 0 12px 40px rgba(0, 0, 0, .6); }
.stale-box h2 { margin: 0 0 8px; font-size: 18px; }
.stale-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0 10px; }
.stale-actions .btn { flex: 1 1 auto; justify-content: center; text-align: center; }
.pg { position: absolute; overflow: hidden; background: #19191c; } .pg { position: absolute; overflow: hidden; background: #19191c; }
.pg img { display: block; width: 100%; height: 100%; -webkit-user-drag: none; } .pg img { display: block; width: 100%; height: 100%; -webkit-user-drag: none; }
#canvas[data-page-tone="inverted"] .pg img { filter: invert(1); } #canvas[data-page-tone="inverted"] .pg img { filter: invert(1); }
+1 -1
View File
@@ -8,7 +8,7 @@
h('div', { class: 'info' }, h('h1', {}, c.title), h('p', { class: 'muted' }, `${c.members.length} manga · ${c.read}/${c.chapters} chapters read · ${c.unread} new`), h('div', { class: 'info' }, h('h1', {}, c.title), h('p', { class: 'muted' }, `${c.members.length} manga · ${c.read}/${c.chapters} chapters read · ${c.unread} new`),
c.description ? h('p', {}, c.description) : null, c.description ? h('p', {}, c.description) : null,
c.reading.activeMs ? h('p', {}, `${readingDuration(c.reading.activeMs)} together`) : null, c.reading.activeMs ? h('p', {}, `${readingDuration(c.reading.activeMs)} together`) : null,
h('div', { class: 'actions' }, h('a', { class: 'btn primary', href: readUrl(c.continue.m, c.continue.c, c.continue.v) }, c.continue.text), h('div', { class: 'actions' }, h('a', { class: 'btn primary', href: readUrl(c.continue.m, c.continue.c, c.continue.v, c.continue.at) }, c.continue.text),
me.admin ? h('a', { class: 'btn', href: 'collections.html?' + qsa({ id: c.id }) }, 'Edit collection') : null)))); me.admin ? h('a', { class: 'btn', href: 'collections.html?' + qsa({ id: c.id }) }, 'Edit collection') : null))));
main.append(h('h2', {}, 'Reading order'), h('div', { class: 'collection-members' }, c.members.map(m => main.append(h('h2', {}, 'Reading order'), h('div', { class: 'collection-members' }, c.members.map(m =>
h('a', { class: 'collection-member', href: mangaUrl(m.id) }, m.cover ? h('img', { src: m.cover, alt: '', loading: 'lazy' }) : null, h('a', { class: 'collection-member', href: mangaUrl(m.id) }, m.cover ? h('img', { src: m.cover, alt: '', loading: 'lazy' }) : null,
+16 -1
View File
@@ -39,6 +39,20 @@ const store = {
get(k) { try { return localStorage.getItem('mc.' + k); } catch (e) { return null; } }, get(k) { try { return localStorage.getItem('mc.' + k); } catch (e) { return null; } },
set(k, v) { try { localStorage.setItem('mc.' + k, v); } catch (e) { /* private mode */ } }, set(k, v) { try { localStorage.setItem('mc.' + k, v); } catch (e) { /* private mode */ } },
}; };
// One id per browser tab (it survives navigation and reloads in that tab): progress saves carry it, so
// a reader can tell its own saves from those of another tab or device.
const tabId = (() => {
let id = null;
return () => {
if (id) return id;
try { id = sessionStorage.getItem('mc.tab'); } catch (e) { /* storage blocked */ }
if (!/^[a-zA-Z0-9_-]{8,40}$/.test(id || '')) {
id = Array.from(crypto.getRandomValues(new Uint8Array(12)), b => b.toString(16).padStart(2, '0')).join('');
try { sessionStorage.setItem('mc.tab', id); } catch (e) { /* this page keeps it in memory */ }
}
return id;
};
})();
function ago(t) { function ago(t) {
const s = (Date.now() - t) / 1000; const s = (Date.now() - t) / 1000;
@@ -60,7 +74,8 @@ function readingDuration(ms, compact = false) {
} }
const qsa = obj => { const p = new URLSearchParams(); for (const k in obj) if (obj[k] != null && obj[k] !== '') p.set(k, obj[k]); return p.toString(); }; const qsa = obj => { const p = new URLSearchParams(); for (const k in obj) if (obj[k] != null && obj[k] !== '') p.set(k, obj[k]); return p.toString(); };
const readUrl = (m, c, v) => 'read.html?' + qsa({ m, c, v }); // `at`: the manga's latest progress time the linking page knew, so the reader can tell it opened from a stale page.
const readUrl = (m, c, v, at) => 'read.html?' + qsa({ m, c, v, at: at || null });
const mangaUrl = m => 'manga.html?' + qsa({ m }); const mangaUrl = m => 'manga.html?' + qsa({ m });
const libraryUrlForTags = tags => { const libraryUrlForTags = tags => {
const p = new URLSearchParams(); const p = new URLSearchParams();
+1 -1
View File
@@ -72,7 +72,7 @@
data.reading.rereads ? h('span', { class: 'story-pill' }, `${data.reading.rereads} reread${data.reading.rereads === 1 ? '' : 's'}`) : null), data.reading.rereads ? h('span', { class: 'story-pill' }, `${data.reading.rereads} reread${data.reading.rereads === 1 ? '' : 's'}`) : null),
h('div', { class: 'reading-summary-links' }, h('div', { class: 'reading-summary-links' },
most ? h('a', { href: mangaUrl(most.id) }, `Most time with ${most.title}`) : null, most ? h('a', { href: mangaUrl(most.id) }, `Most time with ${most.title}`) : null,
latest && data.reading.lastChapterId ? h('a', { href: readUrl(latest.id, data.reading.lastChapterId) }, `Continue ${latest.title} →`) : null))); latest && data.reading.lastChapterId ? h('a', { href: readUrl(latest.id, data.reading.lastChapterId, null, latest.lastRead) }, `Continue ${latest.title} →`) : null)));
} }
main.append( main.append(
h('div', { class: 'library-controls' }, h('div', { class: 'library-controls' },
+6 -6
View File
@@ -48,7 +48,7 @@
h('div', { class: 'chips' }, tags.map(tag => h('a', { class: 'chip chip-link', href: libraryUrlForTags([tag]), title: `Show manga tagged ${tag}` }, tag))), h('div', { class: 'chips' }, tags.map(tag => h('a', { class: 'chip chip-link', href: libraryUrlForTags([tag]), title: `Show manga tagged ${tag}` }, tag))),
meta.description ? h('p', { class: 'desc' }, meta.description) : null, meta.description ? h('p', { class: 'desc' }, meta.description) : null,
h('div', { class: 'actions' }, h('div', { class: 'actions' },
M.continue ? h('a', { class: 'btn primary', href: readUrl(M.id, M.continue.c, M.continue.v) }, M.continue.text) : null, M.continue ? h('a', { class: 'btn primary', href: readUrl(M.id, M.continue.c, M.continue.v, M.continue.at) }, M.continue.text) : null,
follow)))); follow))));
let storyRhythm = null; let storyRhythm = null;
@@ -73,7 +73,7 @@
storyStat(M.reading.typicalChapterMs ? readingDuration(M.reading.typicalChapterMs) : '—', 'typical chapter'), storyStat(M.reading.typicalChapterMs ? readingDuration(M.reading.typicalChapterMs) : '—', 'typical chapter'),
storyStat(M.reading.rereads, `reread${M.reading.rereads === 1 ? '' : 's'}`)), storyStat(M.reading.rereads, `reread${M.reading.rereads === 1 ? '' : 's'}`)),
lastChapter ? h('p', { class: 'story-last muted' }, 'Last timed ', lastChapter ? h('p', { class: 'story-last muted' }, 'Last timed ',
h('a', { href: readUrl(M.id, lastChapter.id) }, `${lastChapter.label}${lastChapter.title ? ' · ' + lastChapter.title : ''}`), h('a', { href: readUrl(M.id, lastChapter.id, null, M.continue?.at) }, `${lastChapter.label}${lastChapter.title ? ' · ' + lastChapter.title : ''}`),
` · ${ago(M.reading.updatedAt)}`) : null, ` · ${ago(M.reading.updatedAt)}`) : null,
h('div', { class: 'story-rhythm-head' }, h('b', {}, 'Reading rhythm'), h('span', { class: 'muted small' }, 'Completed chapters · active time')), h('div', { class: 'story-rhythm-head' }, h('b', {}, 'Reading rhythm'), h('span', { class: 'muted small' }, 'Completed chapters · active time')),
storyRhythm, storyRhythm,
@@ -110,7 +110,7 @@
const passes = chapter.reading.completedPasses; const passes = chapter.reading.completedPasses;
const title = `${label} · ${readingDuration(value)}${passes > 1 ? ` · ${passes} timed reads · ${readingDuration(chapter.reading.totalActiveMs)} total` : ''}`; const title = `${label} · ${readingDuration(value)}${passes > 1 ? ` · ${passes} timed reads · ${readingDuration(chapter.reading.totalActiveMs)} total` : ''}`;
storyRhythm.append(h('a', { storyRhythm.append(h('a', {
class: 'rhythm-bar', role: 'listitem', href: readUrl(M.id, chapter.id), title, class: 'rhythm-bar', role: 'listitem', href: readUrl(M.id, chapter.id, null, M.continue?.at), title,
'aria-label': title, style: `height:${height}px`, 'aria-label': title, style: `height:${height}px`,
})); }));
} }
@@ -221,7 +221,7 @@
if (done) { await api('api/unread', { m: M.id, c: ch.id }); ch.progress = null; ch.readAt = null; } if (done) { await api('api/unread', { m: M.id, c: ch.id }); ch.progress = null; ch.readAt = null; }
else { else {
const version = ch.versions[0]; const version = ch.versions[0];
await api('api/progress', { m: M.id, c: ch.id, v: version.id, p: version.pages - 1, n: version.pages, done: true }); await api('api/progress', { m: M.id, c: ch.id, v: version.id, p: version.pages - 1, n: version.pages, done: true, tab: tabId() });
ch.progress = { v: version.id, p: version.pages - 1, n: version.pages, done: true }; ch.progress = { v: version.id, p: version.pages - 1, n: version.pages, done: true };
ch.readAt = { at: Date.now(), approx: false }; ch.readAt = { at: Date.now(), approx: false };
} }
@@ -234,7 +234,7 @@
const timing = chapterTiming(ch), released = chapterRelease(ch.dates); const timing = chapterTiming(ch), released = chapterRelease(ch.dates);
wrap.textContent = ''; wrap.textContent = '';
wrap.append(h('div', { class: 'ch' + (done ? ' read' : '') }, wrap.append(h('div', { class: 'ch' + (done ? ' read' : '') },
h('a', { class: 'ch-main' + (timing ? ' has-time' : '') + (M.chapterDates?.release ? ' has-date' : ''), href: readUrl(M.id, ch.id) }, h('a', { class: 'ch-main' + (timing ? ' has-time' : '') + (M.chapterDates?.release ? ' has-date' : ''), href: readUrl(M.id, ch.id, null, M.continue?.at) },
h('span', { class: 'ch-label' }, ch.label), h('span', { class: 'ch-label' }, ch.label),
h('span', { class: 'ch-pages' }, `${pages} p.`), h('span', { class: 'ch-pages' }, `${pages} p.`),
M.chapterDates?.release ? h('span', { class: 'ch-date' + (released ? ' tier-' + released.tier : ''), title: released ? released.title : 'No date known', 'aria-label': released ? released.title : 'No date known' }, M.chapterDates?.release ? h('span', { class: 'ch-date' + (released ? ' tier-' + released.tier : ''), title: released ? released.title : 'No date known', 'aria-label': released ? released.title : 'No date known' },
@@ -259,7 +259,7 @@
h('button', { class: version.my > 0 ? 'on' : '', title: 'Better', onclick: () => vote(1) }, '▲'), h('button', { class: version.my > 0 ? 'on' : '', title: 'Better', onclick: () => vote(1) }, '▲'),
h('b', {}, version.score), h('b', {}, version.score),
h('button', { class: version.my < 0 ? 'on' : '', title: 'Worse', onclick: () => vote(-1) }, '▼')), h('button', { class: version.my < 0 ? 'on' : '', title: 'Worse', onclick: () => vote(-1) }, '▼')),
h('a', { class: 'btn small', href: readUrl(M.id, ch.id, version.id) }, 'Read'))); h('a', { class: 'btn small', href: readUrl(M.id, ch.id, version.id, M.continue?.at) }, 'Read')));
}); });
} }
draw(); draw();
+62 -7
View File
@@ -155,6 +155,55 @@
// A link to a page (e.g. "Open in reader" from a moment) wins over the saved position. // A link to a page (e.g. "Open in reader" from a moment) wins over the saved position.
if (P.get('p') != null) { const k = list.findIndex(x => x.i >= +P.get('p')); if (k >= 0) cur = k; } if (P.get('p') != null) { const k = list.findIndex(x => x.i >= +P.get('p')); if (k >= 0) cur = k; }
// Stale reading sessions: a tab left open (or restored by the browser) on another device must not move
// the "Continue" point back. `seen` is the newest progress time this tab knows: the link's `at`, kept
// current in the address bar, so a reload or restored tab still knows it. Without `at` the open was a
// deliberate one (bookmark, notification) and starts from the current head.
const tab = tabId();
let seen = Number(P.get('at')) || (D.latest ? D.latest.t : 0), stale = null, staleCard = null;
const chapterUrl = (c, v) => readUrl(D.manga.id, c, v, seen);
const newer = latest => !!latest && latest.t > seen && latest.w !== tab;
function markSeen(t) {
seen = Math.max(seen, t || 0);
const q = new URLSearchParams(location.search);
q.set('at', seen);
history.replaceState(history.state, '', '?' + q);
}
// Another tab or device saved a newer position. The same place is no conflict; anything else asks.
function checkLatest(latest) {
if (!newer(latest)) return;
if (latest.c === D.chapter.id && list[cur] && latest.p === list[cur].i) { markSeen(latest.t); return; }
showStale(latest);
}
function showStale(latest) {
stale = latest;
clearTimeout(saveTimer);
if (staleCard) staleCard.remove();
const where = latest.done ? `${latest.label} (finished)` : `${latest.label} · page ${latest.p + 1}`;
const go = h('a', { class: 'btn primary', href: readUrl(D.manga.id, latest.continue.c, latest.continue.v, latest.t) }, latest.continue.text);
const stay = h('button', { class: 'btn', type: 'button' }, 'Keep reading here');
stay.onclick = () => {
staleCard.remove(); staleCard = null; stale = null;
markSeen(latest.t);
saveProgress();
stage.focus({ preventScroll: true });
};
staleCard = h('div', { class: 'stale-session', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'stale-title' },
h('div', { class: 'stale-box' },
h('h2', { id: 'stale-title' }, 'You have read further since'),
h('p', {}, 'This page is an older reading session. Your newest reading position is ', h('b', {}, where), ` (${ago(latest.t)}).`),
h('div', { class: 'stale-actions' }, go, stay),
h('p', { class: 'muted small' }, `Keeping ${D.chapter.label} makes it your “Continue” point again.`)));
document.body.append(staleCard);
go.focus();
}
async function recheck() {
if (stale || document.hidden) return;
try { checkLatest((await api('api/progress/latest?' + qsa({ m: D.manga.id }))).latest); } catch { /* offline: the next save checks again */ }
}
document.addEventListener('visibilitychange', recheck);
window.addEventListener('pageshow', e => { if (e.persisted) recheck(); });
// Book mode: pages pair up inside each run of single pages. Runs restart after every double page / cover, // Book mode: pages pair up inside each run of single pages. Runs restart after every double page / cover,
// so one missing pair never shifts the rest of the chapter. "Shift" makes a run start with a lone page. // so one missing pair never shifts the rest of the chapter. "Shift" makes a run start with a lone page.
function computeSpreads() { function computeSpreads() {
@@ -240,7 +289,7 @@
const n = D.next; const n = D.next;
return h('div', { class: 'end' }, h('div', { class: 'endbox' }, return h('div', { class: 'end' }, h('div', { class: 'endbox' },
h('div', { class: 'muted' }, 'End of ' + D.chapter.label + (D.chapter.title ? ' · ' + D.chapter.title : '')), h('div', { class: 'muted' }, 'End of ' + D.chapter.label + (D.chapter.title ? ' · ' + D.chapter.title : '')),
n ? h('a', { class: 'btn primary', href: readUrl(D.manga.id, n.id), onclick: () => saveProgress(true) }, `Next: ${n.label}${n.title ? ' · ' + n.title : ''} ›`) n ? h('a', { class: 'btn primary', href: chapterUrl(n.id), onclick: e => { e.currentTarget.href = chapterUrl(n.id); saveProgress(true); } }, `Next: ${n.label}${n.title ? ' · ' + n.title : ''} ›`)
: h('div', {}, 'You are up to date.'), : h('div', {}, 'You are up to date.'),
h('a', { class: 'btn', href: mangaUrl(D.manga.id) }, 'Back to ' + D.manga.title))); h('a', { class: 'btn', href: mangaUrl(D.manga.id) }, 'Back to ' + D.manga.title)));
} }
@@ -589,7 +638,7 @@
function goChapter(c) { function goChapter(c) {
if (!c) return toast('You are up to date'); if (!c) return toast('You are up to date');
saveProgress(true); saveProgress(true);
location.href = readUrl(D.manga.id, c.id); location.href = chapterUrl(c.id);
} }
// Flow: jump to the top of the next page, but never further than ~one screen. // Flow: jump to the top of the next page, but never further than ~one screen.
function flowStep(dir) { function flowStep(dir) {
@@ -701,8 +750,13 @@
} }
function saveProgress(keepalive) { function saveProgress(keepalive) {
clearTimeout(saveTimer); clearTimeout(saveTimer);
const body = JSON.stringify({ m: D.manga.id, c: D.chapter.id, v: D.version, p: list[cur].i, n: all.length, done, shift: [...shift] }); if (stale) return; // nothing is saved until the reader chose where to go on
fetch('api/progress', { method: 'POST', keepalive: !!keepalive, headers: { 'Content-Type': 'application/json' }, body }).catch(() => {}); const body = JSON.stringify({ m: D.manga.id, c: D.chapter.id, v: D.version, p: list[cur].i, n: all.length, done, shift: [...shift], tab, seen });
fetch('api/progress', { method: 'POST', keepalive: !!keepalive, headers: { 'Content-Type': 'application/json' }, body })
.then(r => r.json()).then(r => {
if (r.stale) checkLatest(r.latest);
else if (r.ok && r.t) markSeen(r.t);
}).catch(() => {});
} }
addEventListener('pagehide', () => saveProgress(true)); addEventListener('pagehide', () => saveProgress(true));
@@ -717,17 +771,17 @@
groups[c.book].append(h('option', { value: c.id }, `${c.label}${c.title ? ' · ' + c.title : ''}${c.extra ? ' (extra)' : ''}${c.done ? ' ✓' : ''}`)); groups[c.book].append(h('option', { value: c.id }, `${c.label}${c.title ? ' · ' + c.title : ''}${c.extra ? ' (extra)' : ''}${c.done ? ' ✓' : ''}`));
} }
chsel.value = D.chapter.id; chsel.value = D.chapter.id;
chsel.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, chsel.value); }; chsel.onchange = () => { saveProgress(true); location.href = chapterUrl(chsel.value); };
$('#back').href = mangaUrl(D.manga.id); $('#back').href = mangaUrl(D.manga.id);
for (const [id, c] of [['#prevch', D.prev], ['#nextch', D.next]]) { for (const [id, c] of [['#prevch', D.prev], ['#nextch', D.next]]) {
const a = $(id); const a = $(id);
if (c) { a.href = readUrl(D.manga.id, c.id); a.title = c.label + (c.title ? ' · ' + c.title : ''); a.onclick = () => saveProgress(true); } if (c) { a.href = chapterUrl(c.id); a.title = c.label + (c.title ? ' · ' + c.title : ''); a.onclick = () => { a.href = chapterUrl(c.id); saveProgress(true); }; }
else a.classList.add('disabled'); else a.classList.add('disabled');
} }
for (const v of D.versions) ver.append(h('option', { value: v.id }, (v.source || v.id) + (v.score ? ` (${v.score > 0 ? '+' : ''}${v.score})` : ''))); for (const v of D.versions) ver.append(h('option', { value: v.id }, (v.source || v.id) + (v.score ? ` (${v.score > 0 ? '+' : ''}${v.score})` : '')));
ver.value = D.version; ver.value = D.version;
ver.hidden = D.versions.length < 2; ver.hidden = D.versions.length < 2;
ver.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, D.chapter.id, ver.value); }; ver.onchange = () => { saveProgress(true); location.href = chapterUrl(D.chapter.id, ver.value); };
for (const b of modeBtns) b.onclick = () => { if (b.dataset.mode !== mode) setMode(b.dataset.mode, true); }; for (const b of modeBtns) b.onclick = () => { if (b.dataset.mode !== mode) setMode(b.dataset.mode, true); };
toneButton.onclick = cycleTone; toneButton.onclick = cycleTone;
$('#moment').onclick = saveMoment; $('#moment').onclick = saveMoment;
@@ -748,6 +802,7 @@
for (const bar of [$('#top'), $('#bot')]) bar.addEventListener('click', e => { if (e.target.closest('button')) stage.focus({ preventScroll: true }); }); for (const bar of [$('#top'), $('#bot')]) bar.addEventListener('click', e => { if (e.target.closest('button')) stage.focus({ preventScroll: true }); });
setMode(mode, false); setMode(mode, false);
if (newer(D.latest)) checkLatest(D.latest); else markSeen(seen);
if (S.readingClock) readingClock = createReadingClock({ if (S.readingClock) readingClock = createReadingClock({
button: $('#readclock'), button: $('#readclock'),
panel: $('#clockpanel'), panel: $('#clockpanel'),
+33 -6
View File
@@ -438,14 +438,28 @@ function readAtOf(progress, record) {
if (progress.doneAt) return { at: progress.doneAt, approx: false }; if (progress.doneAt) return { at: progress.doneAt, approx: false };
return earliestCompletion(record); return earliestCompletion(record);
} }
function continueOf(m, prog, s) { // The most recently saved chapter position of a manga: the head that "Continue" follows.
const ok = c => !(s.skipExtras && c.extra); function lastOf(m, prog) {
let last = null; let last = null;
for (const ch of m.chapters) { const p = prog[ch.id]; if (p && (!last || p.t > last.p.t)) last = { ch, p }; } for (const ch of m.chapters) { const p = prog[ch.id]; if (p && (!last || p.t > last.p.t)) last = { ch, p }; }
if (!last) { const first = m.chapters.find(ok) || m.chapters[0]; return { c: first.id, text: 'Start reading · ' + first.label }; } return last;
if (!last.p.done) return { c: last.ch.id, v: last.p.v, text: `Continue · ${last.ch.label} · page ${last.p.p + 1}` }; }
// `at` is the head's time; reader links carry it so a reader opened from a stale page notices a newer head.
function continueOf(m, prog, s) {
const ok = c => !(s.skipExtras && c.extra);
const last = lastOf(m, prog);
if (!last) { const first = m.chapters.find(ok) || m.chapters[0]; return { c: first.id, at: 0, text: 'Start reading · ' + first.label }; }
const at = last.p.t;
if (!last.p.done) return { c: last.ch.id, v: last.p.v, at, text: `Continue · ${last.ch.label} · page ${last.p.p + 1}` };
const next = m.chapters.slice(m.chapters.indexOf(last.ch) + 1).find(ok); const next = m.chapters.slice(m.chapters.indexOf(last.ch) + 1).find(ok);
return next ? { c: next.id, text: 'Read next · ' + next.label } : { c: last.ch.id, v: last.p.v, text: 'Read again · ' + last.ch.label }; return next ? { c: next.id, at, text: 'Read next · ' + next.label } : { c: last.ch.id, v: last.p.v, at, text: 'Read again · ' + last.ch.label };
}
// What a reader tab compares itself with (stale reading sessions, docs/architecture.md).
function latestOf(m, prog, s) {
const last = lastOf(m, prog);
if (!last) return null;
const { t, w, v, p, n, done } = last.p;
return { t, w: w || '', c: last.ch.id, label: last.ch.label, v, p, n, done, continue: continueOf(m, prog, s) };
} }
// From the saved page on, then the pages before it; afterwards the next chapter's preferred version. // From the saved page on, then the pages before it; afterwards the next chapter's preferred version.
function warmChapter(u, m, ch, v, progress, s) { function warmChapter(u, m, ch, v, progress, s) {
@@ -777,6 +791,7 @@ const routes = {
pages: v.pages.map(p => ({ u: url(v.rel + '/' + p.f), w: p.w, h: p.h, t: p.t, r: p.r })), pages: v.pages.map(p => ({ u: url(v.rel + '/' + p.f), w: p.w, h: p.h, t: p.t, r: p.r })),
prev: neighbour(m, ch, -1, s), next: neighbour(m, ch, 1, s), prev: neighbour(m, ch, -1, s), next: neighbour(m, ch, 1, s),
progress: prog[ch.id] || null, progress: prog[ch.id] || null,
latest: latestOf(m, prog, s),
reading: s.readingClock ? publicSummary(readingOf(u, m, ch), v.id, eligiblePages(v, s)) : null, reading: s.readingClock ? publicSummary(readingOf(u, m, ch), v.id, eligiblePages(v, s)) : null,
// the user's moments on this version, so the reader can mark pages that already have one // the user's moments on this version, so the reader can mark pages that already have one
moments: momentsOf(u).filter(x => x.m === m.id && x.c === ch.id && x.v === v.id) moments: momentsOf(u).filter(x => x.m === m.id && x.c === ch.id && x.v === v.id)
@@ -787,18 +802,30 @@ const routes = {
}; };
}, },
'GET /api/progress/latest': ({ u, q }) => {
const m = mangaOf(q.get('m'));
return { latest: latestOf(m, progressOf(u, m), settingsOf(u)) };
},
'POST /api/progress': ({ u, b }) => { 'POST /api/progress': ({ u, b }) => {
const m = mangaOf(b.m), ch = chapterOf(m, b.c); const m = mangaOf(b.m), ch = chapterOf(m, b.c);
const all = (db.progress[u.name] = db.progress[u.name] || {}), mp = (all[m.id] = all[m.id] || {}), old = mp[ch.id] || {}; const all = (db.progress[u.name] = db.progress[u.name] || {}), mp = (all[m.id] = all[m.id] || {}), old = mp[ch.id] || {};
const tab = /^[a-zA-Z0-9_-]{8,40}$/.test(String(b.tab || '')) ? String(b.tab) : '';
// A reader tab sends the head time it knows (`seen`). When another tab or device saved a newer
// position since, this tab is stale: it must not become the head until the reader chooses to stay.
if (Number.isFinite(b.seen)) {
const last = lastOf(m, mp);
if (last && last.p.t > b.seen && (!tab || last.p.w !== tab)) return { ok: false, stale: true, latest: latestOf(m, mp, settingsOf(u)) };
}
const now = Date.now(), done = !!(old.done || b.done); const now = Date.now(), done = !!(old.done || b.done);
// doneAt: when the chapter was first completed; kept until it is marked unread. // doneAt: when the chapter was first completed; kept until it is marked unread.
const doneAt = old.done ? old.doneAt : done ? now : undefined; const doneAt = old.done ? old.doneAt : done ? now : undefined;
mp[ch.id] = { mp[ch.id] = {
v: String(b.v || ''), p: Math.max(0, b.p | 0), n: Math.max(0, b.n | 0), t: now, done, ...(doneAt ? { doneAt } : {}), v: String(b.v || ''), p: Math.max(0, b.p | 0), n: Math.max(0, b.n | 0), t: now, done, ...(doneAt ? { doneAt } : {}),
shift: Array.isArray(b.shift) ? b.shift.filter(Number.isInteger).slice(0, 1000) : old.shift || [], shift: Array.isArray(b.shift) ? b.shift.filter(Number.isInteger).slice(0, 1000) : old.shift || [],
...(tab ? { w: tab } : {}),
}; };
save(); save();
return { ok: true }; return { ok: true, t: now };
}, },
// Separate endpoint: an older server on rollback cannot misread cumulative // Separate endpoint: an older server on rollback cannot misread cumulative
// visit totals as legacy deltas. Old open tabs retain their original protocol. // visit totals as legacy deltas. Old open tabs retain their original protocol.
+97
View File
@@ -0,0 +1,97 @@
'use strict';
// Stale reading sessions: a reader tab left open on another device must not move "Continue" back.
const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('fs'), os = require('os'), path = require('path'), net = require('net');
const { spawn } = require('child_process');
const { Canvas } = require('../tools/png');
async function freePort() {
const listener = net.createServer();
await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve));
const port = listener.address().port;
await new Promise(resolve => listener.close(resolve));
return port;
}
test('a save from a tab that missed a newer position is refused until the reader stays', { timeout: 30000 }, async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-stale-'));
let child;
try {
const library = path.join(root, 'library'), data = path.join(root, 'data');
for (const ch of ['c001 One', 'c002 Two', 'c003 Three']) {
const dir = path.join(library, 'Moon Garden', 'v01', ch);
fs.mkdirSync(dir, { recursive: true });
for (const n of [1, 2, 3]) fs.writeFileSync(path.join(dir, `00${n}.png`), new Canvas(60, 90, [40 * n, 60, 120]).png());
}
fs.mkdirSync(data);
const port = await freePort(), output = [];
child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true,
env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0',
SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '', GREATEST_COVER_WARM: '0' } });
child.stdout.on('data', d => output.push(String(d)));
child.stderr.on('data', d => output.push(String(d)));
const base = `http://127.0.0.1:${port}`;
let ready = false;
for (let n = 0; n < 100 && !ready; n++) {
ready = await fetch(base + '/health').then(r => r.ok).catch(() => false);
if (!ready) await new Promise(resolve => setTimeout(resolve, 50));
}
assert.ok(ready, output.join(''));
const post = (cookie, url, body) => fetch(base + url, { method: 'POST',
headers: { 'content-type': 'application/json', cookie }, body: JSON.stringify(body) }).then(r => r.json());
const get = (cookie, url) => fetch(base + url, { headers: { cookie } }).then(r => r.json());
const signup = await fetch(base + '/api/signup', { method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'alice', password: 'alice-password' }) });
const alice = signup.headers.get('set-cookie').split(';')[0];
await post(alice, '/api/admin/rescan', { full: true });
const manga = await get(alice, '/api/manga?m=Moon%20Garden');
const [one, two, three] = manga.chapters.map(c => ({ c: c.id, v: c.versions[0].id }));
const save = (ch, extra) => post(alice, '/api/progress', { m: 'Moon Garden', ...ch, p: 1, n: 3, ...extra });
const latest = () => get(alice, '/api/progress/latest?m=Moon%20Garden').then(r => r.latest);
// Phone reads chapter 1, the tablet then reads on in chapter 3.
const first = await save(one, { tab: 'phone-tab-1', seen: 0 });
assert.equal(first.ok, true);
const phoneSeen = first.t;
await new Promise(resolve => setTimeout(resolve, 5));
const tablet = await save(three, { tab: 'tablet-tab-1', seen: phoneSeen });
assert.equal(tablet.ok, true);
assert.equal((await latest()).c, three.c);
// The phone's old tab wakes up and saves chapter 1 again: refused, the head stays on chapter 3.
const refused = await save(one, { tab: 'phone-tab-1', seen: phoneSeen, p: 2 });
assert.equal(refused.ok, false);
assert.equal(refused.stale, true);
assert.equal(refused.latest.c, three.c);
assert.equal(refused.latest.w, 'tablet-tab-1');
assert.equal(refused.latest.continue.c, three.c);
assert.match(refused.latest.continue.text, /Continue/);
const after = await get(alice, '/api/manga?m=Moon%20Garden');
assert.equal(after.continue.c, three.c);
assert.equal(after.continue.at, tablet.t);
assert.equal(after.chapters[0].progress.p, 1, 'the stale save changed nothing');
// The chapter view tells a reader which head it is compared with.
const view = await get(alice, `/api/chapter?m=Moon%20Garden&c=${encodeURIComponent(one.c)}`);
assert.equal(view.latest.c, three.c);
assert.equal(view.latest.t, tablet.t);
// The tablet's own saves never conflict with themselves, even with an older `seen` still in flight.
assert.equal((await save(three, { tab: 'tablet-tab-1', seen: phoneSeen, p: 2 })).ok, true);
// "Keep reading here": the phone now knows the newest time and its save becomes the head.
const head = await latest();
const stayed = await save(one, { tab: 'phone-tab-1', seen: head.t, p: 2 });
assert.equal(stayed.ok, true);
assert.equal((await latest()).c, one.c);
assert.equal((await get(alice, '/api/manga?m=Moon%20Garden')).continue.c, one.c);
// Saves without `seen` (mark as read on the manga page, older clients) behave as before.
assert.equal((await save(two, { done: true })).ok, true);
assert.equal((await latest()).c, two.c);
} finally {
if (child && child.exitCode === null) { child.kill(); await new Promise(resolve => child.once('exit', resolve)); }
fs.rmSync(root, { recursive: true, force: true });
}
});