Chapter dates: read date setting and tiered release dates (BL-0047)

- Chapter rows show when you first finished a chapter (setting: date + hour,
  date, off) from a new durable progress doneAt; older reads fall back to the
  reading clock's earliest remembered completion.
- chapter.json keeps added (arrival in this library, now kept on replace) plus
  x-dates.source (source upload, bulk imports flagged), x-dates.release
  (official, for a later metadata job) and x-dates.ecosystem (first entry into
  any Manga Circus library; carried over peering, earliest wins, clamped).
- Rows show one release date by tier: official > source upload > ecosystem >
  library, with all known dates in the tooltip (setting to hide).
- Fixes: WeebCentral dates came from the chapter page, which has none (every
  chapter got the download time); they now come from the full chapter list.
  MangaDex no longer writes a group's publishAt into added. Peering sends dates.
- tools/backfill-chapter-dates.js migrates an existing library (dry run default).
- Coverage dry run on the server: docs/chapter-dates-coverage-2026-10-24.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MaashkinoozheandClaude Opus 5.5 committed 2026-10-24 01:01:33 +02:00
1 parent b2bb34174c
commit e0dacd81c6
21 files changed
+470 -32

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
'use strict';
// Chapter dates (BL-0047). chapter.json keeps three independent dates:
// added when the chapter entered this library (kept on replace)
// x-dates.source when the source site uploaded it; batch=true marks a bulk import, not a release
// x-dates.release the official public release (filled by a metadata job; precision "day")
// x-dates.ecosystem when it first entered any Manga Circus library; travels over peering, earliest wins
// The reader shows one release date per chapter by tier: official > source (not batch) > ecosystem > library.
const BATCH_SIZE = 3; // this many uploads ...
const BATCH_GAP_MS = 10 * 60e3; // ... each within 10 minutes of the next form a bulk import
const iso = value => {
const time = typeof value === 'number' ? value : Date.parse(value);
return Number.isFinite(time) && time > 0 ? new Date(time).toISOString() : null;
};
const text = (value, max = 120) => (typeof value === 'string' ? value.trim().slice(0, max) : '');
// Returns one flag per item: true when its upload time belongs to a bulk import, null without a time.
function batchFlags(times) {
const order = times.map((value, index) => ({ index, time: Date.parse(value) }))
.filter(item => Number.isFinite(item.time)).sort((a, b) => a.time - b.time);
const flags = times.map(() => null);
for (let start = 0; start < order.length;) {
let end = start + 1;
while (end < order.length && order[end].time - order[end - 1].time <= BATCH_GAP_MS) end++;
for (let i = start; i < end; i++) flags[order[i].index] = end - start >= BATCH_SIZE;
start = end;
}
return flags;
}
function sourceDate(at, name, batch) {
const value = iso(at);
return value ? { at: value, name: text(name), batch: !!batch } : null;
}
function cleanSource(value) {
return value && typeof value === 'object' ? sourceDate(value.at, value.name, value.batch) : null;
}
function cleanRelease(value) {
if (!value || typeof value !== 'object' || !iso(value.at)) return null;
const precision = ['day', 'month', 'year'].includes(value.precision) ? value.precision : 'day';
return { at: String(value.at).slice(0, 30), precision, source: text(value.source) };
}
function cleanEcosystem(value) {
if (!value || typeof value !== 'object' || !iso(value.at)) return null;
return { at: iso(value.at), via: text(value.via) || 'this server' };
}
// chapter.json fields for a (re)published chapter. A prior "added" and an earlier ecosystem
// date always survive; a peer's ecosystem claim may lower the date but never raise it.
function publishedDates(priorMeta = {}, incoming = {}, now = Date.now()) {
const prior = priorMeta['x-dates'] && typeof priorMeta['x-dates'] === 'object' ? priorMeta['x-dates'] : {};
const added = iso(priorMeta.added) || iso(incoming.added) || iso(now);
const source = cleanSource(incoming.source) || cleanSource(prior.source);
const release = cleanRelease(incoming.release) || cleanRelease(prior.release);
let claim = cleanEcosystem(incoming.ecosystem);
if (claim) {
// Nobody can have had the chapter before the source uploaded it, or in the future.
const floor = source ? Date.parse(source.at) : 0, time = Math.min(Date.parse(claim.at), now);
claim = { ...claim, at: iso(Math.max(time, floor)) };
}
const candidates = [cleanEcosystem(prior.ecosystem), claim, { at: added, via: 'this server' }].filter(Boolean);
const ecosystem = candidates.reduce((best, item) => (Date.parse(item.at) < Date.parse(best.at) ? item : best));
return { added, 'x-dates': { version: 1, ...(source ? { source } : {}), ...(release ? { release } : {}), ecosystem } };
}
// Dates of one library chapter from its versions ({ added: ms, meta: chapter.json }).
function chapterDates(versions) {
const known = {};
const keep = (slot, time, value) => {
if (Number.isFinite(time) && (!known[slot] || time < known[slot].time)) known[slot] = { time, ...value };
};
for (const version of versions) {
const dates = (version.meta && version.meta['x-dates']) || {};
const release = cleanRelease(dates.release), source = cleanSource(dates.source), ecosystem = cleanEcosystem(dates.ecosystem);
if (release) keep('official', Date.parse(release.at), { at: release.at, precision: release.precision, name: release.source });
if (source) keep(source.batch ? 'batch' : 'source', Date.parse(source.at), { at: source.at, name: source.name });
if (ecosystem) keep('ecosystem', Date.parse(ecosystem.at), { at: ecosystem.at, name: ecosystem.via });
if (Number.isFinite(version.added)) keep('library', version.added, { at: iso(version.added) });
}
const tier = ['official', 'source', 'ecosystem', 'library'].find(slot => known[slot]) || null;
const out = {};
for (const [slot, { time, ...value }] of Object.entries(known)) out[slot] = value;
return { tier, release: tier ? out[tier] : null, known: out };
}
// Ecosystem dates offered to peers: the earliest per chapter number, falling back to "added"
// for chapters published before x-dates existed (this server was then the one that had it).
function ecosystemOf(meta, fallbackMs) {
const dates = (meta && meta['x-dates']) || {};
return cleanEcosystem(dates.ecosystem)?.at || iso(meta && meta.added) || iso(fallbackMs);
}
// What a gateway sends with a chapter: only cleaned, non-identifying date fields.
function peerDates(meta, fallbackMs) {
const dates = (meta && meta['x-dates']) || {};
const source = cleanSource(dates.source), release = cleanRelease(dates.release), ecosystem = ecosystemOf(meta, fallbackMs);
return { ...(ecosystem ? { ecosystem } : {}), ...(source ? { source } : {}), ...(release ? { release } : {}) };
}
// Incoming peer dates in publishedDates() shape.
function fromPeer(value, alias) {
if (!value || typeof value !== 'object') return {};
const ecosystem = iso(value.ecosystem);
return { ...(ecosystem ? { ecosystem: { at: ecosystem, via: `peer ${text(alias, 40)}`.trim() } } : {}),
...(cleanSource(value.source) ? { source: cleanSource(value.source) } : {}),
...(cleanRelease(value.release) ? { release: cleanRelease(value.release) } : {}) };
}
module.exports = { BATCH_SIZE, BATCH_GAP_MS, batchFlags, sourceDate, publishedDates, chapterDates, ecosystemOf, peerDates, fromPeer };