'use strict'; const { STATUS } = require('./constants'); function nowISO() { return new Date().toISOString(); } function uniqStrings(arr) { const seen = new Set(); const out = []; for (const v of arr || []) { const name = String(v ?? '').trim(); if (!name || seen.has(name)) continue; seen.add(name); out.push(name); } return out; } function normalizeTags(tags) { if (!tags) return []; if (Array.isArray(tags)) return uniqStrings(tags.map(t => typeof t === 'string' ? t : (t?.name || t?.label || t?.tag || ''))); if (typeof tags === 'string') return uniqStrings(tags.split(/[,,\s]+/)); return []; } function normalizePublicTags(tags) { if (!tags) return []; const list = Array.isArray(tags) ? tags : (typeof tags === 'string' ? tags.split(/[,,\s]+/) : []); const out = []; const seen = new Set(); for (const t of list) { const name = String(typeof t === 'string' ? t : (t?.name || t?.label || t?.tag || '')).trim(); if (!name || seen.has(name)) continue; seen.add(name); const count = typeof t === 'object' && t ? Number(t.count ?? t.total_cont ?? t.total ?? t.num ?? 0) || 0 : 0; out.push(count ? { name, count } : { name }); } return out; } function myTagsOf(c) { return normalizeTags(c?.myTags || c?.my_tags || c?.tags || []); } function publicTagsOf(c) { return normalizePublicTags(c?.publicTags || c?.public_tags || c?.subjectTags || c?.raw?.subject?.tags || []); } function normalizeCollection(item, fallbackType) { const s = item.subject || {}; const imgs = s.images || {}; const st = item.subject_type || s.type || fallbackType?.subject_type || 0; const ct = item.type || fallbackType?.collection_type || 0; const title = s.name_cn || s.name || `Subject ${item.subject_id || s.id}`; const raw = item; return { subject_id: Number(item.subject_id || s.id), subject_type: Number(st), collection_type: Number(ct), title, name: s.name || '', name_cn: s.name_cn || '', date: s.date || '', eps: s.eps ?? '', volumes: s.volumes ?? '', bgm_score: s.score ?? s.rating?.score ?? '', rank: s.rank ?? '', summary: s.summary || s.short_summary || '', image: imgs.large || imgs.common || imgs.medium || imgs.grid || imgs.small || '', images: imgs, rate: item.rate ?? 0, comment: item.comment || '', tags: normalizeTags(item.tags), myTags: normalizeTags(item.tags), publicTags: normalizePublicTags(s.tags || item.publicTags || item.public_tags), ep_status: item.ep_status ?? '', vol_status: item.vol_status ?? '', private: !!item.private, collection_created_at: item.created_at || item.createdAt || item.collect_created_at || '', bangumi_updated_at: item.updated_at || item.updatedAt || '', raw }; } function applySubjectDetail(n, detail) { if (!detail) return n; return { ...n, platform: detail.platform || '', nsfw: !!detail.nsfw, infobox: Array.isArray(detail.infobox) ? detail.infobox : [], meta_tags: Array.isArray(detail.meta_tags) ? detail.meta_tags : [], series: !!detail.series, locked: !!detail.locked, rating_detail: detail.rating || {}, collection_stats: detail.collection || {}, total_episodes: detail.total_episodes ?? n.eps ?? '', publicTags: detail.tags ? normalizePublicTags(detail.tags) : n.publicTags, subject_raw: detail, subject_fetched_at: nowISO(), }; } function stableStringify(obj) { if (obj === null || typeof obj !== 'object') return JSON.stringify(obj); if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']'; return '{' + Object.keys(obj).sort().map(k => JSON.stringify(k) + ':' + stableStringify(obj[k])).join(',') + '}'; } function hashString(str) { let h = 2166136261; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0).toString(16); } function comparable(n) { return { subject_id: n.subject_id, subject_type: n.subject_type, collection_type: n.collection_type, title: n.title, name: n.name, name_cn: n.name_cn, date: n.date, eps: n.eps, volumes: n.volumes, bgm_score: n.bgm_score, rank: n.rank, summary: n.summary, image: n.image, rate: n.rate, comment: n.comment, tags: myTagsOf(n), publicTags: publicTagsOf(n), ep_status: n.ep_status, vol_status: n.vol_status, private: n.private, collection_created_at: n.collection_created_at, bangumi_updated_at: n.bangumi_updated_at }; } function makeSnapshot(n) { return { ...n, content_hash: hashString(stableStringify(comparable(n))) }; } // --- change-detection for history entries (server-side simplified formatting) --- function fmtDate(s) { if (!s) return ''; const d = new Date(s); return isNaN(d) ? String(s) : d.toLocaleString(); } function fmtScore(v) { const n = Number(v); if (!n) return ''; return Number.isInteger(n) ? String(n) : n.toFixed(1).replace(/\.0$/, ''); } function shortValue(v, max = 80) { const t = Array.isArray(v) ? v.join(' / ') : String(v ?? ''); return t.length > max ? t.slice(0, max) + '…' : t; } function valuesSame(a, b) { return stableStringify(a ?? '') === stableStringify(b ?? ''); } function collectionFieldDiff(oldC, newC) { const fields = [ ['collection_type', '收藏状态', v => STATUS[v] || v || '-'], ['rate', '我的评分', v => v || '未评分'], ['ep_status', '章节进度', v => v || '-'], ['vol_status', '卷进度', v => v || '-'], ['comment', '吐槽/观后感', v => shortValue(v || '无吐槽', 120)], ['private', '私密状态', v => v ? '私密' : '公开'], ['tags', '我的标签', v => shortValue(normalizeTags(v), 120)], ['publicTags', '公共标签', v => shortValue(normalizePublicTags(v).map(x => x.count ? `${x.name}(${x.count})` : x.name), 120)], ['bangumi_updated_at', 'API 更新时间', v => fmtDate(v) || '-'], ['title', '标题', v => v || '-'], ['date', '上映日期', v => v || '-'], ['bgm_score', 'Bangumi 均分', v => fmtScore(v) || '-'], ['rank', '排名', v => v || '-'] ]; const changes = []; for (const [key, label, fmt] of fields) { const oldVal = key === 'tags' ? myTagsOf(oldC) : (key === 'publicTags' ? publicTagsOf(oldC) : oldC?.[key]); const newVal = key === 'tags' ? myTagsOf(newC) : (key === 'publicTags' ? publicTagsOf(newC) : newC?.[key]); if (!valuesSame(oldVal, newVal)) changes.push({ field: key, label, from: fmt(oldVal), to: fmt(newVal), old: oldVal ?? '', new: newVal ?? '' }); } return changes; } module.exports = { nowISO, uniqStrings, normalizeTags, normalizePublicTags, myTagsOf, publicTagsOf, normalizeCollection, applySubjectDetail, stableStringify, hashString, comparable, makeSnapshot, collectionFieldDiff };