Files
Bangumi-Vault/app/js/model.js
T
Stardream e3bf77296c
Build and Push Docker Image / build (push) Successful in 17s
Initial release
2026-06-16 20:27:36 +10:00

171 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 => `<div class="hist-change"><span>${esc(x.label || x.field)}</span>${esc(x.from)}${esc(x.to)}</div>`).join('') + (changes.length > 6 ? `<div class="hist-change">还有 ${changes.length - 6} 项变化…</div>` : '');
}
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<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)))}; }
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 || '';
}