'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 };