- 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>
110 lines
6.1 KiB
JavaScript
110 lines
6.1 KiB
JavaScript
'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 };
|