'use strict'; // Reader-owned catalog. Membership never changes filesystem or reading IDs. const fs = require('fs'), path = require('path'), crypto = require('crypto'); const TYPES = ['prequel', 'sequel', 'side-story', 'spin-off', 'adaptation', 'alternate-version', 'alternate-timeline', 'compilation', 'remake', 'shared-universe', 'other']; const ROLES = ['main', ...TYPES]; const STATES = ['suggested', 'confirmed', 'rejected', 'conflicted']; class CatalogError extends Error { constructor(code, message) { super(message); this.code = code; } } const fail = message => { throw new CatalogError(400, message); }; const text = (value, max = 200) => String(value || '').trim().slice(0, max); function normalize(input, mangaIds, prior = {}) { if (!input || !Array.isArray(input.collections) || !Array.isArray(input.relations)) fail('Expected collections and relations arrays'); if (input.collections.length > 2000 || input.relations.length > 10000) fail('Catalog is too large'); const seen = new Set(), membership = new Set(); const collections = input.collections.map(c => { const id = text(c.id || crypto.randomUUID(), 80); if (!/^[a-zA-Z0-9_-]+$/.test(id) || seen.has(id)) fail('Collection IDs must be unique letters, numbers, dash or underscore'); seen.add(id); if (!text(c.title)) fail('Collection title is required'); if (!Array.isArray(c.members) || !c.members.length) fail('A collection needs members'); const local = new Set(); const members = c.members.map((member, index) => { const mangaId = text(member.mangaId, 500); // Keep absent members on subsequent edits: an temporarily unavailable mount must not erase them. const wasMember = (prior.collections || []).some(old => old.id === id && old.members.some(m => m.mangaId === mangaId)); if (!mangaIds.has(mangaId) && !wasMember) fail(`Unknown manga: ${mangaId}`); if (local.has(mangaId) || membership.has(mangaId)) fail(`Manga already belongs to a collection: ${mangaId}`); local.add(mangaId); membership.add(mangaId); const role = text(member.role || 'main'); if (!ROLES.includes(role)) fail('Unknown member role'); const readingOrder = Number(member.readingOrder ?? index + 1); if (!Number.isFinite(readingOrder) || readingOrder < 0) fail('Reading order must be a nonnegative number'); return { mangaId, role, readingOrder }; }).sort((a, b) => a.readingOrder - b.readingOrder); const coverMode = c.coverMode || 'selected'; if (!['selected', 'mosaic', 'custom'].includes(coverMode)) fail('Unknown cover mode'); const coverMangaId = text(c.coverMangaId || members[0].mangaId, 500); if (!local.has(coverMangaId)) fail('Selected cover must belong to this collection'); const customCover = text(c.customCover, 100); if (customCover && !/^[a-f0-9]{64}\.(jpg|png|webp|gif|avif|bmp)$/.test(customCover)) fail('Invalid custom cover'); return { id, title: text(c.title), description: text(c.description, 5000), members, coverMode, coverMangaId, ...(customCover ? { customCover } : {}), grouped: c.grouped !== false }; }); const relationKeys = new Set(); const relations = input.relations.map(r => { const from = text(r.from, 500), to = text(r.to, 500), type = text(r.type), state = r.state || 'suggested'; if (from === to || !from || !to || !TYPES.includes(type) || !STATES.includes(state)) fail('Invalid relation'); for (const id of [from, to]) if (!mangaIds.has(id) && !(prior.relations || []).some(old => old.from === id || old.to === id)) fail(`Unknown manga: ${id}`); const key = JSON.stringify([from, to, type]); if (relationKeys.has(key)) fail('Duplicate relationship'); relationKeys.add(key); const evidence = (Array.isArray(r.evidence) ? r.evidence : []).slice(0, 12).map(e => { const url = text(e.url, 1000); if (url && !/^https?:\/\/[^\s]+$/i.test(url)) fail('Evidence must be an HTTP URL'); return { provider: text(e.provider), url, note: text(e.note, 1500) }; }); return { from, to, type, state, evidence, explanation: text(r.explanation, 3000), method: text(r.method || 'manual'), confidence: Math.max(0, Math.min(1, Number(r.confidence) || 0)) }; }); return { version: 1, collections, relations }; } class Catalog { constructor(dataRoot) { this.file = path.join(dataRoot, 'catalog.json'); this.state = { version: 1, revision: 0, collections: [], relations: [], audit: [] }; if (fs.existsSync(this.file)) { const saved = JSON.parse(fs.readFileSync(this.file, 'utf8')); if (saved.version !== 1 || !Array.isArray(saved.collections) || !Array.isArray(saved.relations)) throw new Error('Unsupported catalog file'); this.state = saved; } } update(input, mangaIds, actor) { if (input.revision !== this.state.revision) throw new CatalogError(409, 'Catalog changed; reload before saving'); const value = normalize(input, mangaIds, this.state); const next = { ...value, revision: this.state.revision + 1, audit: [...(this.state.audit || []), { at: new Date().toISOString(), actor, revision: this.state.revision + 1 }].slice(-200) }; fs.writeFileSync(this.file + '.tmp', JSON.stringify(next, null, 2) + '\n'); fs.renameSync(this.file + '.tmp', this.file); this.state = next; return next; } suggest(mangas) { const byAniList = new Map(mangas.filter(m => m.meta?.['x-anilist']?.id).map(m => [String(m.meta['x-anilist'].id), m])); const relations = [...this.state.relations]; const mapping = { PREQUEL: 'prequel', SEQUEL: 'sequel', SIDE_STORY: 'side-story', SPIN_OFF: 'spin-off', ADAPTATION: 'adaptation', ALTERNATIVE: 'alternate-version', COMPILATION: 'compilation', OTHER: 'other', SOURCE: 'adaptation', PARENT: 'other' }; for (const m of mangas) for (const r of m.meta?.['x-anilist']?.relations || []) { const target = byAniList.get(String(r.id)), type = mapping[r.type] || 'other'; if (!target || target.id === m.id || relations.some(e => e.from === m.id && e.to === target.id && e.type === type)) continue; relations.push({ from: m.id, to: target.id, type, state: 'suggested', method: 'anilist', confidence: 1, explanation: `AniList identifies the target as ${r.type}. Review before grouping.`, evidence: [{ provider: 'AniList', url: m.meta['x-anilist'].url || `https://anilist.co/manga/${m.meta['x-anilist'].id}`, note: r.type }] }); } return relations; } } function collectionViews(catalog, mangas) { const byId = new Map(mangas.map(m => [m.id, m])); return catalog.collections.map(c => { const members = c.members.filter(member => byId.has(member.mangaId)).map(member => ({ ...byId.get(member.mangaId), role: member.role, readingOrder: member.readingOrder })); const chosen = byId.get(c.coverMangaId) || members[0]; const sum = key => members.reduce((n, m) => n + (+m[key] || 0), 0); const dates = members.map(m => m.firstReleaseAt).filter(Boolean); return { ...c, members, cover: c.coverMode === 'custom' && c.customCover ? `api/collection-cover?id=${encodeURIComponent(c.id)}&v=${c.customCover}` : chosen?.cover, covers: members.map(m => m.cover).filter(Boolean).slice(0, 4), chapters: sum('chapters'), read: sum('read'), unread: sum('unread'), tags: [...new Set(members.flatMap(m => m.tags || []))], authors: [...new Set(members.flatMap(m => m.authors || []))], altTitles: members.flatMap(m => [m.title, ...(m.altTitles || [])]), status: members.some(m => m.status === 'ongoing') ? 'ongoing' : members.every(m => m.status === 'completed') ? 'completed' : 'mixed', updated: Math.max(0, ...members.map(m => +m.updated || 0)), lastRead: Math.max(0, ...members.map(m => +m.lastRead || 0)), firstReleaseAt: dates.length ? Math.min(...dates) : 0, firstRelease: dates.length ? String(new Date(Math.min(...dates)).getUTCFullYear()) : '', reading: { activeMs: members.reduce((n, m) => n + (m.reading?.activeMs || 0), 0), updatedAt: Math.max(0, ...members.map(m => m.reading?.updatedAt || 0)) } }; }).filter(c => c.members.length); } module.exports = { Catalog, CatalogError, collectionViews, normalize, TYPES, ROLES };