'use strict'; 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 publicTagNames(c){ return publicTagsOf(c).map(t=>t.name).filter(Boolean); } function allTagNames(c){ return uniqStrings([...myTagsOf(c), ...publicTagNames(c)]); } function tagKey(scope, name){ return scope + '::' + String(name || ''); } function parseTagKey(key){ if(key === '全部' || key === '无标签') return {scope:'special', name:key}; const idx = String(key || '').indexOf('::'); if(idx > 0) return {scope:String(key).slice(0, idx), name:String(key).slice(idx + 2)}; return {scope:'all', name:String(key || '')}; } function tagLabelFromKey(key){ const t = parseTagKey(key); if(t.scope === 'my') return '我的:' + t.name; if(t.scope === 'public') return '公共:' + t.name; return t.name || '全部'; } function bgmTagUrl(subjectType, name){ const path = BGM_TYPE_PATH[Number(subjectType)] || 'subject_search'; const q = encodeURIComponent(name || ''); const base = bgmSiteBase(); return path === 'subject_search' ? `${base}/subject_search/${q}?cat=all` : `${base}/${path}/tag/${q}`; } function publicTagsForDisplay(c){ return publicTagsOf(c); } 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 => formatDate(v) || '-'], ['title','标题', v => v || '-'], ['date','上映日期', v => v || '-'], ['bgm_score','Bangumi 均分', v => formatScoreValue(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; } function historyChangeHtml(h){ const changes = Array.isArray(h.changes) ? h.changes : []; if(!changes.length) return esc(h.note || ''); return changes.slice(0,6).map(x => `
${esc(x.label || x.field)}:${esc(x.from)} → ${esc(x.to)}
`).join('') + (changes.length > 6 ? `
还有 ${changes.length - 6} 项变化…
` : ''); } 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 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>>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)))}; } function getAllCollections(){ return Object.values(state.collections || {}); } function visibleCollections(){ const q = filters.q.trim().toLowerCase(); let arr = getAllCollections().filter(c => c.status !== 'confirmed_deleted'); // Guests (read-only public mode) never see the safety box, even if the filter is somehow set to it. const sf = (!authReady && filters.status === 99) ? 0 : filters.status; if(sf === 99) arr = getAllCollections().filter(c => c.status === 'missing_candidate' || c.status === 'protected_local' || c.status === 'confirmed_deleted'); else if(sf) arr = arr.filter(c => c.collection_type === sf && c.status !== 'missing_candidate'); else arr = arr.filter(c => c.status !== 'missing_candidate'); if(filters.type) arr = arr.filter(c => c.subject_type === filters.type); if(isPublicMode && !authReady && !publicShowNsfw) arr = arr.filter(c => c.nsfw !== true); else if(filters.nsfw === 'only') arr = arr.filter(c => c.nsfw === true); else if(filters.nsfw === 'hide') arr = arr.filter(c => c.nsfw !== true); if(filters.tag !== '全部') arr = arr.filter(c => { const tf = parseTagKey(filters.tag); if(tf.name === '无标签') return !myTagsOf(c).length && !publicTagNames(c).length; if(tf.scope === 'my') return myTagsOf(c).includes(tf.name); if(tf.scope === 'public') return publicTagNames(c).includes(tf.name); return allTagNames(c).includes(tf.name); }); if(filters.hasComment) arr = arr.filter(c => !!(c.comment && String(c.comment).trim())); if(q){ arr = arr.filter(c => [c.title,c.name,c.name_cn,c.comment,c.summary,c.date].join(' ').toLowerCase().includes(q)); } const cmpDate = (x) => Date.parse(x || '') || 0; arr.sort((a,b)=>{ if(filters.sort==='updated_asc') return cmpDate(a.bangumi_updated_at)-cmpDate(b.bangumi_updated_at); if(filters.sort==='rate_desc') return (Number(b.rate)||0)-(Number(a.rate)||0) || cmpDate(b.bangumi_updated_at)-cmpDate(a.bangumi_updated_at); if(filters.sort==='name_asc') return (a.title||'').localeCompare(b.title||'', 'zh-Hans-CN'); if(filters.sort==='first_seen_desc') return cmpDate(b.first_seen_at)-cmpDate(a.first_seen_at); return cmpDate(b.bangumi_updated_at)-cmpDate(a.bangumi_updated_at); }); return arr; } async function getCachedImage(id){ const c=state.collections?.[String(id)]; // v0.20: Data URL 只在离线导出页优先使用。在线/便携模式优先走真实文件或原始在线图片, // 避免超大的 base64 封面残留到运行库后影响在线海报显示。 if(window.BANGUMI_VAULT_OFFLINE_STATE && c?.cover_data_url) return {dataUrl:c.cover_data_url}; if(c?.cover_local_url) return {url:c.cover_local_url, file:c.cover_local_file||''}; try{ const cached = await idbGet('img:'+id); if(cached?.dataUrl || cached?.url) return cached; }catch{} if(c?.cover_data_url && !onlineCoverUrl(c)) return {dataUrl:c.cover_data_url}; return null; } function onlineCoverUrl(c){ return c.image || c.images?.large || c.images?.common || c.images?.medium || c.images?.grid || c.images?.small || ''; } // Rewrite an online image URL through the configured proxy/mirror (empty = no change). function applyImageProxy(url, proxy){ proxy = (proxy || '').trim(); if(!proxy || !url || !/^https?:\/\//i.test(url)) return url; return proxy.includes('{url}') ? proxy.replace('{url}', encodeURIComponent(url)) : proxy + url; } function coverUrl(c){ if(window.BANGUMI_VAULT_OFFLINE_STATE && c.cover_data_url) return c.cover_data_url; // Local cached covers (/images/...) are served by us — never proxy those; only online bgm images. return c.cover_local_url || applyImageProxy(onlineCoverUrl(c), state.settings?.imageProxy) || c.cover_data_url || ''; }