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