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

595 lines
46 KiB
JavaScript
Raw Permalink 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 safeExportState(){
const s=structuredClone(state);
delete s.settings.token;
// v0.20: 常规 JSON 备份不携带 base64 图片,避免 state 变巨大;离线 HTML 会临时内嵌。
for(const c of Object.values(s.collections || {})) delete c.cover_data_url;
return s;
}
function manifest(){ const arr=getAllCollections(); return {app:'Bangumi 保管库 PosterWall', schema:9, exported_at:nowISO(), username:state.settings.username||'', collections_count:arr.length, active_count:arr.filter(c=>c.status!=='confirmed_deleted'&&c.status!=='missing_candidate').length, missing_count:arr.filter(c=>c.status==='missing_candidate').length, history_count:state.history.length}; }
function buildJson(){ return JSON.stringify({manifest:manifest(), state:safeExportState()}, null, 2); }
function csvCell(s){ return '"' + String(s ?? '').replace(/"/g,'""') + '"'; }
function buildCsv(){
const head=['subject_id','分类','状态','标题','原名','中文名','日期','我的评分','Bangumi均分','进度章节','进度卷','我的标签','公共标签','全部标签','吐槽/观后感','是否私密','条目最后更新时间','本地同步时间','本地状态','首次备份','最后确认存在'];
const lines=[head.map(csvCell).join(',')];
for(const c of getAllCollections()) lines.push([c.subject_id,SUBJECT_LABEL[c.subject_type]||c.subject_type,STATUS[c.collection_type]||c.collection_type,c.title,c.name,c.name_cn,c.date,c.rate,(formatScoreValue(c.bgm_score || c.raw?.subject?.score || c.raw?.subject?.rating?.score) || ''),c.ep_status,c.vol_status,myTagsOf(c).join(' / '),publicTagNames(c).join(' / '),allTagNames(c).join(' / '),c.comment,c.private?'是':'否',c.bangumi_updated_at,c.last_synced_at || c.last_seen_at || '',c.status,c.first_seen_at,c.last_seen_at].map(csvCell).join(','));
return '\ufeff' + lines.join('\r\n');
}
async function enrichExportCollections(mode='inline', onProgress){
const arr = getAllCollections().map(c=>structuredClone(c));
let i = 0;
for(const c of arr){
i++;
if(onProgress) onProgress(i, arr.length, c);
if(mode === 'relative' && c.cover_local_file){
const file = String(c.cover_local_file).split(/[\\/]/).pop();
c.cover_local_url = 'images/' + file;
delete c.cover_data_url;
} else {
const dataUrl = await imageBlobToDataURLFromCache(c);
if(dataUrl){
c.cover_data_url = dataUrl;
// 离线 HTML 优先使用内嵌图片,避免 /images/... 在 file:// 或压缩包预览中失效。
delete c.cover_local_url;
delete c.cover_local_file;
} else {
// 没缓存到本地的条目,离线 HTML 可以在有网时继续显示远程图;离线则显示占位。
delete c.cover_data_url;
}
}
if(i % 25 === 0) await sleep(0);
}
return arr;
}
async function buildOfflineHtml(mode='inline', onProgress){
const offlineState = safeExportState();
offlineState.collections = {};
for(const c of await enrichExportCollections(mode, onProgress)) offlineState.collections[String(c.subject_id)] = c;
const payload = JSON.stringify(offlineState).replace(/</g,'\\u003c');
// v0.19: 不再用正则从 drawer-mask 删到 toast。
// 之前的清理方式会把 <section id="drawer"> 一起删掉,导致离线 HTML 点击海报后没有详情容器。
// 这里克隆整页,只移除打开态/关闭态,并清空详情容器,保留设置、导出、drawer、toast 等必要节点。
const cloned = document.documentElement.cloneNode(true);
cloned.querySelectorAll('.show,.closing').forEach(el => el.classList.remove('show','closing'));
const drawer = cloned.querySelector('#drawer');
if(drawer) drawer.innerHTML = '';
cloned.querySelectorAll('.sync-log').forEach(el => { el.textContent = ''; });
const toastEl = cloned.querySelector('#toast');
if(toastEl) toastEl.textContent = '';
// Inline the stylesheet so the exported file is self-contained.
try{
const cssText = await fetch('css/styles.css').then(r=>r.text());
const link = cloned.querySelector('link[rel="stylesheet"]');
if(link){ const styleEl = document.createElement('style'); styleEl.textContent = cssText; link.replaceWith(styleEl); }
}catch{}
// Bundle every JS module into one inline script. The modules behave as ordered classic
// scripts (shared scope), so concatenating them inside one IIFE reproduces the live app.
const moduleNames = ['core','model','view','cron','sync','covers','exports','main'];
let bundleBody = '';
for(const n of moduleNames){
try{ bundleBody += (await fetch('js/'+n+'.js').then(r=>r.text())) + '\n'; }catch{}
}
// Escape any literal closing-script token so the inline <script> isn't terminated early.
bundleBody = bundleBody.replace(/<\/(script)>/gi, '<\\/$1>');
cloned.querySelectorAll('script[src]').forEach(el => el.remove());
const bundleScript = document.createElement('script');
bundleScript.textContent = `window.BANGUMI_VAULT_OFFLINE_STATE=${payload};\n(function(){\n${bundleBody}\n})();`;
(cloned.querySelector('body') || cloned).appendChild(bundleScript);
return '<!doctype html>\n' + cloned.outerHTML;
}
async function saveOrDownload(name, blob){
downloadBlob(name, blob);
toast('已触发浏览器下载');
}
function downloadBlob(name, blob){ const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=name; document.body.appendChild(a); a.click(); setTimeout(()=>{URL.revokeObjectURL(a.href); a.remove();},1000); }
function xmlEsc(s){
return String(s ?? '')
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\uFFFE\uFFFF]/g, '')
.replace(/[<>&"']/g, m => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&apos;'}[m]));
}
function exportDateParts(dateText){
const raw = String(dateText || '').trim();
const m = raw.match(/^(\d{4})(?:[-/.](\d{1,2}))?/);
const year = m ? m[1] : '';
const monthNum = m && m[2] ? Math.max(1, Math.min(12, Number(m[2]))) : '';
const month = monthNum ? String(monthNum).padStart(2,'0') : '';
const quarter = monthNum ? ('Q' + Math.ceil(monthNum / 3)) : '';
return {year, month, quarter, ym: year && month ? `${year}-${month}` : (year || '')};
}
function scoreNum(v){ const n = Number(v); return Number.isFinite(n) && n > 0 ? n : ''; }
function scoreBand(v){
const n = Number(v);
if(!Number.isFinite(n) || n <= 0) return '未评分';
if(n >= 9) return '9-10 神作/最爱';
if(n >= 8) return '8-8.9 很喜欢';
if(n >= 7) return '7-7.9 推荐';
if(n >= 6) return '6-6.9 还行';
return '1-5.9 一般/以下';
}
function progressInfo(c){
const watched = Number(c.ep_status || 0) || 0;
const total = Number(c.eps || c.raw?.subject?.eps || c.raw?.subject?.total_episodes || 0) || 0;
const pct = total > 0 ? Math.max(0, Math.min(100, Math.round(watched / total * 100))) : '';
let band = '未知';
if(total > 0){
if(watched <= 0) band = '未开始';
else if(watched >= total) band = '已完成';
else if(pct >= 75) band = '后段';
else if(pct >= 40) band = '中段';
else band = '前段';
} else if(watched > 0) band = '有进度';
return {watched, total, pct, band};
}
function commentLen(s){ return String(s || '').replace(/\s+/g,'').length; }
function exportRows(){
return getAllCollections().map(c=>({
subject_id:c.subject_id,
分类:SUBJECT_LABEL[c.subject_type]||c.subject_type,
状态:STATUS[c.collection_type]||c.collection_type,
标题:c.title||'',
原名:c.name||'',
中文名:c.name_cn||'',
日期:c.date||'',
我的评分:c.rate||'',
Bangumi均分:formatScoreValue(c.bgm_score || c.raw?.subject?.score || c.raw?.subject?.rating?.score) || '',
进度章节:c.ep_status||'',
进度卷:c.vol_status||'',
我的标签:myTagsOf(c).join(' / '),
公共标签:publicTagNames(c).join(' / '),
全部标签:allTagNames(c).join(' / '),
吐槽观后感:c.comment||'',
是否私密:c.private?'是':'否',
条目最后更新时间:c.bangumi_updated_at||'',
本地同步时间:c.last_synced_at || c.last_seen_at || '',
本地状态:c.status||'active',
首次备份:c.first_seen_at||'',
最后确认存在:c.last_seen_at||''
}));
}
function exportRichRows(){
return getAllCollections().map((c, idx)=>{
const d = exportDateParts(c.date);
const tags = allTagNames(c); const myTags = myTagsOf(c); const publicTags = publicTagNames(c);
const p = progressInfo(c);
const bgmScore = scoreNum(c.bgm_score || c.raw?.subject?.score || c.raw?.subject?.rating?.score);
const myScore = scoreNum(c.rate);
const hasComment = String(c.comment || '').trim() ? '是' : '否';
return {
序号: idx + 1,
条目ID: c.subject_id,
条目链接: `${bgmSiteBase()}/subject/${c.subject_id}`,
分类: SUBJECT_LABEL[c.subject_type]||c.subject_type,
状态: STATUS[c.collection_type]||c.collection_type,
分类状态: `${SUBJECT_LABEL[c.subject_type]||c.subject_type} / ${STATUS[c.collection_type]||c.collection_type}`,
年份: d.year,
月份: d.month,
季度: d.quarter,
年月: d.ym,
上映日期: c.date || '',
标题: c.title || '',
中文名: c.name_cn || '',
原名: c.name || '',
我的评分: myScore,
我的评分档: scoreBand(myScore),
Bangumi均分: bgmScore,
Bangumi评分档: scoreBand(bgmScore),
已看集数: p.watched || '',
总集数: p.total || '',
进度百分比: p.pct === '' ? '' : p.pct,
进度档: p.band,
进度卷: c.vol_status || '',
有吐槽: hasComment,
吐槽字数: commentLen(c.comment),
主标签: tags[0] || '',
标签数量: tags.length,
标签1: tags[0] || '', 标签2: tags[1] || '', 标签3: tags[2] || '', 标签4: tags[3] || '', 标签5: tags[4] || '',
全部标签: tags.join(' / '),
我的标签: myTags.join(' / '),
公共标签: publicTags.join(' / '),
吐槽观后感: c.comment || '',
是否私密: c.private ? '是' : '否',
本地状态: c.status || 'active',
是否安全箱: c.status === 'missing_candidate' ? '是' : '否',
条目最后更新时间: c.bangumi_updated_at || '',
本地同步时间: c.last_synced_at || c.last_seen_at || '',
首次备份: c.first_seen_at || '',
最后确认存在: c.last_seen_at || '',
封面文件: c.cover_local_file || '',
排序用更新时间: c.bangumi_updated_at || c.last_seen_at || c.first_seen_at || ''
};
});
}
function groupCount(rows, keyFn){
const m = new Map();
for(const r of rows){ const k = keyFn(r) || '未填写'; m.set(k, (m.get(k)||0)+1); }
return [...m.entries()].sort((a,b)=>b[1]-a[1] || String(a[0]).localeCompare(String(b[0])));
}
function mean(nums){ const arr = nums.map(Number).filter(n=>Number.isFinite(n) && n>0); return arr.length ? Math.round(arr.reduce((a,b)=>a+b,0)/arr.length*10)/10 : ''; }
function colName(n){
n = Number(n) || 1;
let name = '';
while(n > 0){
const rem = (n - 1) % 26;
name = String.fromCharCode(65 + rem) + name;
n = Math.floor((n - 1) / 26);
}
return name || 'A';
}
function xlsxRef(col,row){ return colName(col)+row; }
function xlsxCellXml(value, col, row, style=0, numeric=false){
const ref = xlsxRef(col,row);
const n = Number(value);
if(numeric && value !== '' && value != null && Number.isFinite(n)) return `<c r="${ref}"${style?` s="${style}"`:''}><v>${n}</v></c>`;
return `<c r="${ref}" t="inlineStr"${style?` s="${style}"`:''}><is><t xml:space="preserve">${xmlEsc(value)}</t></is></c>`;
}
function buildSheetXml(sheet){
const rows = sheet.rows || [];
const numericCols = new Set(sheet.numericCols || []);
const wrapCols = new Set(sheet.wrapCols || []);
const widths = sheet.widths || [];
const colsXml = widths.length ? `<cols>${widths.map((w,i)=>`<col min="${i+1}" max="${i+1}" width="${w}" customWidth="1"/>`).join('')}</cols>` : '';
const data = rows.map((row, ri)=>{
const r = ri + 1;
const styleRow = row.__style || 0;
const cells = row.map((v, ci)=>{
let style = styleRow;
if(r === sheet.headerRow) style = 1;
if(wrapCols.has(ci)) style = r === sheet.headerRow ? 1 : 3;
if(row.__styles && row.__styles[ci] != null) style = row.__styles[ci];
return xlsxCellXml(v, ci+1, r, style, numericCols.has(ci));
}).join('');
const ht = row.__height ? ` ht="${row.__height}" customHeight="1"` : '';
return `<row r="${r}"${ht}>${cells}</row>`;
}).join('');
const lastCol = Math.max(1, ...rows.map(r=>r.length));
const lastRow = Math.max(1, rows.length);
const freeze = sheet.freeze ? `<sheetViews><sheetView workbookViewId="0"><pane ySplit="${sheet.freeze}" topLeftCell="A${sheet.freeze+1}" activePane="bottomLeft" state="frozen"/><selection pane="bottomLeft"/></sheetView></sheetViews>` : '<sheetViews><sheetView workbookViewId="0"/></sheetViews>';
const autoFilter = sheet.autoFilterRow ? `<autoFilter ref="A${sheet.autoFilterRow}:${colName(lastCol)}${lastRow}"/>` : '';
const mergeCells = sheet.merges?.length ? `<mergeCells count="${sheet.merges.length}">${sheet.merges.map(m=>`<mergeCell ref="${m}"/>`).join('')}</mergeCells>` : '';
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><dimension ref="A1:${colName(lastCol)}${lastRow}"/>${freeze}<sheetFormatPr defaultRowHeight="18"/>${colsXml}<sheetData>${data}</sheetData>${autoFilter}${mergeCells}</worksheet>`;
}
function xlsxStylesXml(){
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="5"><font><sz val="10"/><name val="Microsoft YaHei"/></font><font><b/><color rgb="FFFFFFFF"/><sz val="10"/><name val="Microsoft YaHei"/></font><font><b/><color rgb="FFFFFFFF"/><sz val="16"/><name val="Microsoft YaHei"/></font><font><color rgb="FF6B637D"/><sz val="10"/><name val="Microsoft YaHei"/></font><font><b/><color rgb="FF242033"/><sz val="11"/><name val="Microsoft YaHei"/></font></fonts>
<fills count="7"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FF7C5CFF"/><bgColor indexed="64"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FF2B2438"/><bgColor indexed="64"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FFEFE9FF"/><bgColor indexed="64"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FFFFF2CC"/><bgColor indexed="64"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FFE2F0D9"/><bgColor indexed="64"/></patternFill></fill></fills>
<borders count="2"><border><left/><right/><top/><bottom/><diagonal/></border><border><left style="thin"><color rgb="FFD9D2EA"/></left><right style="thin"><color rgb="FFD9D2EA"/></right><top style="thin"><color rgb="FFD9D2EA"/></top><bottom style="thin"><color rgb="FFD9D2EA"/></bottom><diagonal/></border></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="8"><xf numFmtId="0" fontId="0" fillId="0" borderId="1" xfId="0"/><xf numFmtId="0" fontId="1" fillId="2" borderId="1" xfId="0" applyFill="1" applyFont="1" applyAlignment="1"><alignment horizontal="center" vertical="center" wrapText="1"/></xf><xf numFmtId="0" fontId="2" fillId="3" borderId="1" xfId="0" applyFill="1" applyFont="1" applyAlignment="1"><alignment vertical="center"/></xf><xf numFmtId="0" fontId="0" fillId="0" borderId="1" xfId="0" applyAlignment="1"><alignment vertical="top" wrapText="1"/></xf><xf numFmtId="0" fontId="3" fillId="0" borderId="1" xfId="0"/><xf numFmtId="0" fontId="4" fillId="4" borderId="1" xfId="0" applyFill="1"/><xf numFmtId="0" fontId="4" fillId="5" borderId="1" xfId="0" applyFill="1"/><xf numFmtId="0" fontId="4" fillId="6" borderId="1" xfId="0" applyFill="1"/></cellXfs>
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
</styleSheet>`;
}
function buildXlsxBlob(){
const rich = exportRichRows();
const headers = ['序号','条目ID','条目链接','分类','状态','分类状态','年份','月份','季度','年月','上映日期','标题','中文名','原名','我的评分','我的评分档','Bangumi均分','Bangumi评分档','已看集数','总集数','进度百分比','进度档','进度卷','有吐槽','吐槽字数','主标签','标签数量','标签1','标签2','标签3','标签4','标签5','全部标签','我的标签','公共标签','吐槽观后感','是否私密','本地状态','是否安全箱','条目最后更新时间','首次备份','最后确认存在','封面文件','排序用更新时间'];
const detailRows = [headers, ...rich.map(r=>headers.map(h=>r[h]))];
const statusRows = [['分类','状态','数量','有吐槽','有评分','平均我的评分','平均Bangumi均分','安全箱数量']];
const cats = [...new Set(rich.map(r=>r.分类))].sort(); const sts = [...new Set(rich.map(r=>r.状态))].sort();
for(const cat of cats) for(const st of sts){ const arr=rich.filter(r=>r.分类===cat && r.状态===st); if(!arr.length) continue; statusRows.push([cat,st,arr.length,arr.filter(r=>r.有吐槽==='是').length,arr.filter(r=>r.我的评分).length,mean(arr.map(r=>r.我的评分)),mean(arr.map(r=>r.Bangumi均分)),arr.filter(r=>r.是否安全箱==='是').length]); }
const tagMap = new Map();
for(const r of rich){ String(r.全部标签||'').split(' / ').filter(Boolean).forEach(t=>{ const o=tagMap.get(t)||{tag:t,count:0,cats:new Set(),sts:new Set(),scores:[],bgm:[],examples:[]}; o.count++; o.cats.add(r.分类); o.sts.add(r.状态); if(r.我的评分) o.scores.push(r.我的评分); if(r.Bangumi均分) o.bgm.push(r.Bangumi均分); if(o.examples.length<8) o.examples.push(r.标题); tagMap.set(t,o); }); }
const tagRows = [['标签','数量','分类范围','状态范围','平均我的评分','平均Bangumi均分','示例条目']];
[...tagMap.values()].sort((a,b)=>b.count-a.count || a.tag.localeCompare(b.tag)).forEach(o=>tagRows.push([o.tag,o.count,[...o.cats].join(' / '),[...o.sts].join(' / '),mean(o.scores),mean(o.bgm),o.examples.join(' / ')]));
const commentRows = [['标题','分类','状态','我的评分','Bangumi均分','年份','标签','吐槽字数','吐槽/观后感','条目链接']];
rich.filter(r=>r.有吐槽==='是').sort((a,b)=>String(b.条目最后更新时间).localeCompare(String(a.条目最后更新时间))).forEach(r=>commentRows.push([r.标题,r.分类,r.状态,r.我的评分,r.Bangumi均分,r.年份,r.全部标签,r.吐槽字数,r.吐槽观后感,r.条目链接]));
const safetyRows = [['条目ID','标题','分类','状态','最后确认存在','条目最后更新时间','本地状态','吐槽/观后感','条目链接'], ...rich.filter(r=>r.是否安全箱==='是'||r.本地状态==='missing_candidate').map(r=>[r.条目ID,r.标题,r.分类,r.状态,r.最后确认存在,r.条目最后更新时间,r.本地状态,r.吐槽观后感,r.条目链接])];
const summaryRows = [
Object.assign(['Bangumi 保管库表格备份', '', '', '', '', '', ''], {__style:2, __height:28}),
['导出时间', nowISO(), '账号', state.settings.username||'', '总条目', rich.length, ''],
['活跃条目', rich.filter(r=>r.本地状态!=='confirmed_deleted'&&r.本地状态!=='missing_candidate').length, '安全箱', rich.filter(r=>r.是否安全箱==='是').length, '有吐槽', rich.filter(r=>r.有吐槽==='是').length, ''],
['有评分', rich.filter(r=>r.我的评分).length, '标签数', tagMap.size, '平均我的评分', mean(rich.map(r=>r.我的评分)), ''],
[],
['快速筛选建议','在“收藏明细”表使用筛选:分类/状态/年份/月/评分档/进度档/有吐槽/主标签/安全箱。吐槽长文请看“吐槽索引”。','','','','',''],
[],
['状态统计','','','','','',''],
...statusRows,
[],
['常用评分档','','','','','',''],
['评分档','数量','','','','',''],
...groupCount(rich, r=>r.我的评分档).map(([k,v])=>[k,v,'','','','',''])
];
const sheets = [
{name:'筛选面板', rows:summaryRows, widths:[22,14,14,14,14,14,18], merges:['A1:G1'], numericCols:[1,3,5], wrapCols:[0], freeze:0},
{name:'收藏明细', rows:detailRows, widths:[7,10,28,10,10,18,8,8,8,10,12,30,30,30,10,18,12,18,10,10,12,12,10,9,10,16,10,12,12,12,12,12,30,48,10,13,12,22,22,22,26,22], numericCols:[0,1,14,16,18,19,20,24,26], wrapCols:[11,12,13,32,33], freeze:1, autoFilterRow:1},
{name:'状态统计', rows:statusRows, widths:[12,12,10,10,10,14,16,12], numericCols:[2,3,4,5,6,7], freeze:1, autoFilterRow:1},
{name:'标签索引', rows:tagRows, widths:[22,10,24,24,14,16,60], numericCols:[1,4,5], wrapCols:[2,3,6], freeze:1, autoFilterRow:1},
{name:'吐槽索引', rows:commentRows, widths:[30,10,10,10,12,8,26,10,70,28], numericCols:[3,4,5,7], wrapCols:[6,8], freeze:1, autoFilterRow:1},
{name:'安全箱', rows:safetyRows, widths:[10,30,10,10,22,22,14,60,28], numericCols:[0], wrapCols:[1,7], freeze:1, autoFilterRow:1}
];
const sheetXmls = sheets.map(buildSheetXml);
const workbook = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>${sheets.map((s,i)=>`<sheet name="${xmlEsc(s.name)}" sheetId="${i+1}" r:id="rId${i+1}"/>`).join('')}</sheets></workbook>`;
const files = {
'[Content_Types].xml':`<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>${sheets.map((_,i)=>`<Override PartName="/xl/worksheets/sheet${i+1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join('')}</Types>`,
'_rels/.rels':'<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>',
'xl/workbook.xml':workbook,
'xl/styles.xml':xlsxStylesXml(),
'xl/_rels/workbook.xml.rels':`<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${sheets.map((_,i)=>`<Relationship Id="rId${i+1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i+1}.xml"/>`).join('')}<Relationship Id="rId${sheets.length+1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`
};
sheetXmls.forEach((xml,i)=>files[`xl/worksheets/sheet${i+1}.xml`]=xml);
return makeZip(files);
}
function wTextRuns(text){
const parts = String(text ?? '').split(/\r?\n/);
return parts.map((p,i)=>`${i?'<w:br/>':''}<w:t xml:space="preserve">${xmlEsc(p)}</w:t>`).join('');
}
function wP(text, opts={}){
const rPr = `${opts.bold?'<w:b/>':''}${opts.color?`<w:color w:val="${opts.color}"/>`:''}${opts.size?`<w:sz w:val="${opts.size}"/>`:''}`;
const pPr = `<w:pPr>${opts.heading?`<w:keepNext/>`:''}<w:spacing w:before="${opts.before||0}" w:after="${opts.after||120}" w:line="276" w:lineRule="auto"/>${opts.indent?`<w:ind w:left="${opts.indent}"/>`:''}</w:pPr>`;
return `<w:p>${pPr}<w:r>${rPr?`<w:rPr>${rPr}</w:rPr>`:''}${wTextRuns(text)}</w:r></w:p>`;
}
function wCell(content, width=2400, fill=''){
const shd = fill ? `<w:shd w:fill="${fill}"/>` : '';
return `<w:tc><w:tcPr><w:tcW w:w="${width}" w:type="dxa"/>${shd}<w:tcMar><w:top w:w="80" w:type="dxa"/><w:left w:w="100" w:type="dxa"/><w:bottom w:w="80" w:type="dxa"/><w:right w:w="100" w:type="dxa"/></w:tcMar></w:tcPr>${content}</w:tc>`;
}
function wTable(rows, widths=[]){
const trs = rows.map((row,ri)=>`<w:tr>${row.map((cell,i)=>{
const isRaw = typeof cell === 'string' && /^<w:(p|tbl)/.test(cell);
const content = isRaw ? cell : wP(cell,{after:40,bold:ri===0,color:ri===0?'FFFFFF':''});
return wCell(content, widths[i]||2400, ri===0?'7C5CFF':'');
}).join('')}</w:tr>`).join('');
return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders><w:top w:val="single" w:sz="4" w:color="D9D2EA"/><w:left w:val="single" w:sz="4" w:color="D9D2EA"/><w:bottom w:val="single" w:sz="4" w:color="D9D2EA"/><w:right w:val="single" w:sz="4" w:color="D9D2EA"/><w:insideH w:val="single" w:sz="4" w:color="D9D2EA"/><w:insideV w:val="single" w:sz="4" w:color="D9D2EA"/></w:tblBorders></w:tblPr>${trs}</w:tbl>`;
}
function buildDocxBlob(){
const rich = exportRichRows();
const active = rich.filter(r=>r.本地状态!=='confirmed_deleted'&&r.本地状态!=='missing_candidate').length;
const tagCount = new Set(rich.flatMap(r=>String(r.全部标签||'').split(' / ').filter(Boolean))).size;
const paras = [];
paras.push(wP('Bangumi 保管库备份文档', {bold:true,size:44,color:'F09199',after:180}));
paras.push(wP(`账号:${state.settings.username||''} 导出时间:${nowISO()}`, {color:'6B637D',after:180}));
paras.push(wTable([
['总条目','活跃条目','安全箱','有吐槽','有评分','标签数'],
[String(rich.length),String(active),String(rich.filter(r=>r.是否安全箱==='是').length),String(rich.filter(r=>r.有吐槽==='是').length),String(rich.filter(r=>r.我的评分).length),String(tagCount)]
], [1500,1500,1500,1500,1500,1500]));
paras.push(wP('阅读说明', {bold:true,size:28,color:'7C5CFF',before:240,after:80}));
paras.push(wP('本文档按「分类 / 收藏状态」分组,每个条目保留标题、日期、评分、进度、标签和吐槽/观后感。更细的筛选、排序和标签索引请使用同时导出的 Bangumi保管库.xlsx。'));
const cats = [...new Set(rich.map(r=>r.分类))].sort();
for(const cat of cats){
paras.push(wP(cat, {bold:true,size:34,color:'F09199',before:360,after:120,heading:true}));
const sts = [...new Set(rich.filter(r=>r.分类===cat).map(r=>r.状态))].sort();
for(const st of sts){
const arr = rich.filter(r=>r.分类===cat && r.状态===st).sort((a,b)=>String(b.排序用更新时间).localeCompare(String(a.排序用更新时间)) || String(a.标题).localeCompare(String(b.标题)));
paras.push(wP(`${st}${arr.length}`, {bold:true,size:28,color:'46A3FF',before:220,after:80,heading:true}));
for(const r of arr){
const title = r.标题 || r.中文名 || r.原名 || `subject ${r.条目ID}`;
const meta = [`ID ${r.条目ID}`, r.上映日期, r.我的评分?`我的评分 ${r.我的评分}`:'未评分', r.Bangumi均分?`Bangumi ${r.Bangumi均分}`:'', r.进度百分比!==''?`进度 ${r.已看集数}/${r.总集数} · ${r.进度百分比}%`:'' ].filter(Boolean).join(' · ');
const tags = r.全部标签 ? `标签:${r.全部标签}` : '标签:无';
const comment = r.吐槽观后感 || '没有填写吐槽。';
paras.push(wTable([
[wP(title,{bold:true,size:24,color:'242033',after:40}) + wP(meta,{color:'6B637D',after:40}) + wP(tags,{color:'6B637D',after:60}) + wP('吐槽 / 观后感',{bold:true,color:'7C5CFF',after:40}) + wP(comment,{after:60})]
], [9000]));
}
}
}
const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${paras.join('')}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="900" w:right="900" w:bottom="900" w:left="900"/></w:sectPr></w:body></w:document>`;
const files = {
'[Content_Types].xml':'<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>',
'_rels/.rels':'<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>',
'word/document.xml':documentXml
};
return makeZip(files);
}
// v0.24: Optimized Word export. The older per-item table Word builder is extremely heavy
// on 1000+ collections and can freeze during full ZIP export. This compact builder keeps
// all entries, comments, tags and metadata, but uses paragraphs instead of one table per item.
function buildDocxBlob(){
const rich = exportRichRows();
const active = rich.filter(r=>r.本地状态!=='confirmed_deleted'&&r.本地状态!=='missing_candidate').length;
const safety = rich.filter(r=>r.是否安全箱==='是').length;
const tagCount = new Set(rich.flatMap(r=>String(r.全部标签||'').split(' / ').filter(Boolean))).size;
const withComment = rich.filter(r=>r.有吐槽==='是').length;
const withScore = rich.filter(r=>r.我的评分).length;
const paras = [];
paras.push(wP('Bangumi 保管库备份文档', {bold:true,size:44,color:'F09199',after:160}));
paras.push(wP(`账号:${state.settings.username||''} 导出时间:${nowISO()}`, {color:'6B637D',after:160}));
paras.push(wTable([
['总条目','活跃条目','安全箱','有吐槽','有评分','标签数'],
[String(rich.length),String(active),String(safety),String(withComment),String(withScore),String(tagCount)]
], [1500,1500,1500,1500,1500,1500]));
paras.push(wP('阅读说明', {bold:true,size:28,color:'7C5CFF',before:220,after:80}));
paras.push(wP('本文档使用轻量目录排版,适合大量条目导出。每个条目保留标题、日期、评分、进度、标签和吐槽/观后感。更细的筛选、排序、标签索引和吐槽索引请使用同时导出的 Bangumi保管库.xlsx。'));
const statusRows = [['分类','状态','数量','有吐槽','有评分','平均我的评分','平均Bangumi均分']];
const catsForStatus = [...new Set(rich.map(r=>r.分类))].sort();
for(const cat of catsForStatus){
const sts = [...new Set(rich.filter(r=>r.分类===cat).map(r=>r.状态))].sort();
for(const st of sts){
const arr = rich.filter(r=>r.分类===cat && r.状态===st);
statusRows.push([cat, st, String(arr.length), String(arr.filter(r=>r.有吐槽==='是').length), String(arr.filter(r=>r.我的评分).length), String(mean(arr.map(r=>r.我的评分))||''), String(mean(arr.map(r=>r.Bangumi均分))||'')]);
}
}
paras.push(wP('统计概览', {bold:true,size:28,color:'7C5CFF',before:240,after:80}));
paras.push(wTable(statusRows, [1300,1300,900,1000,1000,1500,1700]));
const cats = [...new Set(rich.map(r=>r.分类))].sort();
for(const cat of cats){
paras.push(wP(cat, {bold:true,size:34,color:'F09199',before:360,after:120,heading:true}));
const sts = [...new Set(rich.filter(r=>r.分类===cat).map(r=>r.状态))].sort();
for(const st of sts){
const arr = rich.filter(r=>r.分类===cat && r.状态===st).sort((a,b)=>String(b.排序用更新时间).localeCompare(String(a.排序用更新时间)) || String(a.标题).localeCompare(String(b.标题)));
paras.push(wP(`${st}${arr.length}`, {bold:true,size:28,color:'46A3FF',before:220,after:80,heading:true}));
for(const r of arr){
const title = r.标题 || r.中文名 || r.原名 || `subject ${r.条目ID}`;
const meta = [`ID ${r.条目ID}`, r.上映日期, r.我的评分?`我的评分 ${r.我的评分}`:'未评分', r.Bangumi均分?`Bangumi ${r.Bangumi均分}`:'', r.进度百分比!==''?`进度 ${r.已看集数}/${r.总集数} · ${r.进度百分比}%`:'' ].filter(Boolean).join(' · ');
const tags = r.全部标签 ? `标签:${r.全部标签}` : '标签:无';
const comment = r.吐槽观后感 || '没有填写吐槽。';
paras.push(wP(title, {bold:true,size:24,color:'242033',before:120,after:40,heading:true}));
paras.push(wP(meta, {color:'6B637D',after:40,indent:240}));
paras.push(wP(tags, {color:'6B637D',after:40,indent:240}));
paras.push(wP(`吐槽 / 观后感:${comment}`, {after:100,indent:240}));
}
}
}
const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${paras.join('')}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="900" w:right="900" w:bottom="900" w:left="900"/></w:sectPr></w:body></w:document>`;
const files = {
'[Content_Types].xml':'<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>',
'_rels/.rels':'<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>',
'word/document.xml':documentXml
};
return makeZip(files);
}
async function exportDocx(){
openModal('syncModal');
$('syncTitle').textContent='正在导出 Word 文档';
$('syncHelp').textContent='Word 已改成轻量目录版,适合大量条目稳定导出。';
$('syncLog').textContent=''; setProgress(0);
try{
log('syncLog','1/3 准备条目与统计...'); setProgress(18); await sleep(30);
log('syncLog','2/3 生成 Word 文档...'); setProgress(48); await sleep(30);
const docx = buildDocxBlob();
log('syncLog',` Word 已生成,约 ${(docx.size/1024/1024).toFixed(1)} MB。`); setProgress(78); await sleep(20);
log('syncLog','3/3 生成完成,触发浏览器下载...');
await saveOrDownload(`Bangumi保管库_Word_${safeName(state.settings.username)}_${dateStamp()}.docx`, docx);
setProgress(100); log('syncLog','完成。');
}catch(e){ console.error(e); log('syncLog','导出失败:'+(e?.message||e)); toast('Word 导出失败,请看日志'); }
}
async function exportXlsx(){
openModal('syncModal');
$('syncTitle').textContent='正在导出 Excel 表格';
$('syncHelp').textContent='Excel 包含筛选面板、收藏明细、状态统计、标签索引、吐槽索引和安全箱。生成大量长吐槽时需要等待几十秒,请不要重复点击。';
$('syncLog').textContent=''; setProgress(0);
try{
const rows = getAllCollections().length;
log('syncLog',`准备导出 ${rows} 条收藏。`); setProgress(8); await sleep(30);
log('syncLog','1/4 生成筛选辅助列、状态统计、标签索引、吐槽索引...'); setProgress(24); await sleep(30);
log('syncLog','2/4 写入 XLSX 工作表 XML...'); setProgress(48); await sleep(30);
const xlsx = buildXlsxBlob();
log('syncLog',` Excel 已生成,约 ${(xlsx.size/1024/1024).toFixed(1)} MB。`); setProgress(74); await sleep(30);
log('syncLog','3/4 生成表格文件并触发浏览器下载...');
await saveOrDownload(`Bangumi保管库_表格_${safeName(state.settings.username)}_${dateStamp()}.xlsx`, xlsx);
setProgress(100); log('syncLog','4/4 完成。如果浏览器没有弹出下载,请检查浏览器下载拦截或下载记录。');
toast('Excel 表格已生成');
}catch(e){ console.error(e); log('syncLog','导出失败:'+(e?.message||e)); toast('Excel 导出失败,请看日志'); }
}
async function exportJson(){ await loadFullHistory(); await saveOrDownload(`Bangumi保管库_${safeName(state.settings.username)}_${dateStamp()}.json`, new Blob([buildJson()], {type:'application/json;charset=utf-8'})); }
async function exportCsv(){ await saveOrDownload(`Bangumi保管库_${safeName(state.settings.username)}_${dateStamp()}.csv`, new Blob([buildCsv()], {type:'text/csv;charset=utf-8'})); }
async function exportHtml(){
await loadFullHistory();
openModal('syncModal'); $('syncTitle').textContent='正在导出单文件离线 HTML'; $('syncHelp').textContent='单文件 HTML 会把已缓存封面临时转成 Base64;条目很多时会很慢。完整 ZIP 推荐使用真实 images/ 文件夹。'; $('syncLog').textContent=''; setProgress(0);
log('syncLog','开始生成离线 HTML...');
const html=await buildOfflineHtml('inline',(i,total)=>{ if(i===1 || i%40===0 || i===total){ setProgress(8 + i/Math.max(total,1)*82); log('syncLog',`处理封面 ${i}/${total}`); } });
setProgress(94); log('syncLog','正在保存/触发下载...');
await saveOrDownload(`Bangumi保管库_离线海报墙_${safeName(state.settings.username)}_${dateStamp()}.html`, new Blob([html], {type:'text/html;charset=utf-8'}));
setProgress(100); log('syncLog','离线 HTML 已生成。如果浏览器没有弹出下载,请检查浏览器下载拦截或下载记录。');
}
async function exportZip(){
await loadFullHistory();
openModal('syncModal');
$('syncTitle').textContent='正在导出完整 ZIP';
$('syncHelp').textContent='完整 ZIP 使用 images/ 真实图片文件。v0.24 已把 Word 改成轻量目录版,并为单独 Excel/Word 导出加入进度日志,避免 1700+ 条目卡在 Word/Excel 生成阶段。请解压 ZIP 后打开 offline-poster-wall.html。';
$('syncLog').textContent=''; setProgress(0);
toast('正在生成 ZIP,进度见弹窗...');
const exportArr = getAllCollections().map(c=>structuredClone(c));
log('syncLog',`准备导出 ${exportArr.length} 条收藏。已缓存的封面会进入 images/ 目录。`);
await sleep(30);
log('syncLog','1/7 生成相对路径离线 HTML(不内嵌 Base64...');
const html=await buildOfflineHtml('relative',(i,total)=>{ if(i===1 || i%150===0 || i===total){ setProgress(4 + i/Math.max(total,1)*12); log('syncLog',` 写入条目 ${i}/${total}`); } });
await sleep(20);
log('syncLog','2/7 收集 images/ 封面文件...');
const imageFiles = await collectCachedImageFiles(exportArr,(i,total)=>{ if(i===1 || i%100===0 || i===total){ setProgress(16 + i/Math.max(total,1)*28); log('syncLog',` 收集封面 ${i}/${total}`); } });
const imageCount = Object.keys(imageFiles).length;
log('syncLog',` 已加入 ${imageCount} 个本地封面文件。`);
await sleep(20);
setProgress(46); log('syncLog','3/7 生成 JSON / CSV / 历史记录...');
const manifestJson = JSON.stringify(manifest(),null,2);
const backupJson = buildJson();
const csv = buildCsv();
const historyJson = JSON.stringify(state.history,null,2);
const runsJson = JSON.stringify(state.runs,null,2);
await sleep(20);
setProgress(52); log('syncLog','4/7 生成 Excel 表格(完整筛选版)...');
await sleep(30);
const xlsx = new Uint8Array(await buildXlsxBlob().arrayBuffer());
log('syncLog',` Excel 已生成,约 ${(xlsx.byteLength/1024/1024).toFixed(1)} MB。`);
await sleep(20);
setProgress(62); log('syncLog','5/7 生成 Word 文档(轻量目录版,避免卡死)...');
await sleep(30);
const docx = new Uint8Array(await buildDocxBlob().arrayBuffer());
log('syncLog',` Word 已生成,约 ${(docx.byteLength/1024/1024).toFixed(1)} MB。`);
await sleep(20);
setProgress(70); log('syncLog','6/7 打包 ZIP(大文件会分批处理)...');
const files={
'manifest.json': manifestJson,
'vault-backup.json': backupJson,
'collections.csv': csv,
'offline-poster-wall.html': html,
...imageFiles,
'history.json': historyJson,
'sync-runs.json': runsJson,
'Bangumi保管库.docx': docx,
'Bangumi保管库.xlsx': xlsx,
'README.txt': 'Bangumi 保管库完整备份包。\n\n使用方式:请先解压整个 ZIP,再双击 offline-poster-wall.html。不要直接在压缩包预览里打开,否则浏览器可能无法读取 images/ 里的海报。\n\noffline-poster-wall.html 使用 images/ 目录里的真实图片文件,不再把大量封面内嵌成 Base64,因此导出速度更快,也更不容易卡死。\n\nBangumi保管库.xlsx 是完整筛选表格;Bangumi保管库.docx 是轻量目录版 Word,适合大量条目时稳定导出。vault-backup.json 可在应用中导入恢复。'
};
const zip = await makeZipAsync(files,(done,total)=>{
if(done===1 || done%80===0 || done===total){
setProgress(70 + done/Math.max(total,1)*22);
log('syncLog',` 打包文件 ${done}/${total}`);
}
});
setProgress(94); log('syncLog',`7/7 触发浏览器下载,ZIP 大小约 ${(zip.size/1024/1024).toFixed(1)} MB...`);
await saveOrDownload(`Bangumi保管库完整备份_${safeName(state.settings.username)}_${dateStamp()}.zip`, zip);
setProgress(100);
log('syncLog', 'ZIP 已生成:已触发浏览器下载。请解压后打开 offline-poster-wall.html。');
toast('ZIP 已生成,请解压后打开离线 HTML');
}
function dateStamp(){ return new Date().toISOString().slice(0,19).replace(/[-:T]/g,''); }
async function makeZipAsync(files, onProgress){
const enc = new TextEncoder(); const chunks=[]; const central=[]; let offset=0;
const entries = Object.entries(files); let done = 0;
for(const [name,content] of entries){
const nameBytes=enc.encode(name);
const data = content instanceof Uint8Array ? content : enc.encode(String(content));
const crc=crc32(data);
const local=new Uint8Array(30+nameBytes.length);
const dv=new DataView(local.buffer);
dv.setUint32(0,0x04034b50,true); dv.setUint16(4,20,true); dv.setUint16(6,0,true); dv.setUint16(8,0,true);
dv.setUint32(14,crc,true); dv.setUint32(18,data.length,true); dv.setUint32(22,data.length,true); dv.setUint16(26,nameBytes.length,true);
local.set(nameBytes,30); chunks.push(local,data);
const cent=new Uint8Array(46+nameBytes.length); const cdv=new DataView(cent.buffer);
cdv.setUint32(0,0x02014b50,true); cdv.setUint16(4,20,true); cdv.setUint16(6,20,true);
cdv.setUint32(16,crc,true); cdv.setUint32(20,data.length,true); cdv.setUint32(24,data.length,true); cdv.setUint16(28,nameBytes.length,true); cdv.setUint32(42,offset,true);
cent.set(nameBytes,46); central.push(cent); offset += local.length + data.length;
done++; if(onProgress) onProgress(done, entries.length, name);
if(done % 20 === 0) await sleep(0);
}
const centralSize = central.reduce((n,c)=>n+c.length,0);
const end=new Uint8Array(22); const edv=new DataView(end.buffer);
edv.setUint32(0,0x06054b50,true); edv.setUint16(8,central.length,true); edv.setUint16(10,central.length,true); edv.setUint32(12,centralSize,true); edv.setUint32(16,offset,true);
return new Blob([...chunks,...central,end], {type:'application/zip'});
}
function makeZip(files){
const enc = new TextEncoder(); const chunks=[]; const central=[]; let offset=0;
for(const [name,content] of Object.entries(files)){
const nameBytes=enc.encode(name); const data = content instanceof Uint8Array ? content : enc.encode(String(content)); const crc=crc32(data); const local=new Uint8Array(30+nameBytes.length);
const dv=new DataView(local.buffer); dv.setUint32(0,0x04034b50,true); dv.setUint16(4,20,true); dv.setUint16(6,0,true); dv.setUint16(8,0,true); dv.setUint32(14,crc,true); dv.setUint32(18,data.length,true); dv.setUint32(22,data.length,true); dv.setUint16(26,nameBytes.length,true); local.set(nameBytes,30);
chunks.push(local,data); const cent=new Uint8Array(46+nameBytes.length); const cdv=new DataView(cent.buffer); cdv.setUint32(0,0x02014b50,true); cdv.setUint16(4,20,true); cdv.setUint16(6,20,true); cdv.setUint32(16,crc,true); cdv.setUint32(20,data.length,true); cdv.setUint32(24,data.length,true); cdv.setUint16(28,nameBytes.length,true); cdv.setUint32(42,offset,true); cent.set(nameBytes,46); central.push(cent); offset += local.length + data.length;
}
const centralSize = central.reduce((n,c)=>n+c.length,0); const end=new Uint8Array(22); const edv=new DataView(end.buffer); edv.setUint32(0,0x06054b50,true); edv.setUint16(8,central.length,true); edv.setUint16(10,central.length,true); edv.setUint32(12,centralSize,true); edv.setUint32(16,offset,true);
return new Blob([...chunks,...central,end], {type:'application/zip'});
}
const CRC_TABLE = (()=>{let c,tab=[];for(let n=0;n<256;n++){c=n;for(let k=0;k<8;k++) c=((c&1)?(0xedb88320^(c>>>1)):(c>>>1));tab[n]=c>>>0;}return tab;})();
function crc32(data){ let c=0xffffffff; for(let i=0;i<data.length;i++) c=CRC_TABLE[(c^data[i])&0xff]^(c>>>8); return (c^0xffffffff)>>>0; }
async function importBackup(file){
const text = await file.text(); const js = JSON.parse(text); const imported = js.state || js;
if(!imported.collections) throw new Error('不是有效的 Bangumi 保管库备份 JSON');
if(!confirm('导入会替换当前本地库。确定继续?')) return;
const keepToken = state.settings.token; state = mergeState(imported); state.settings.token = keepToken; await saveState(); render(); toast('导入完成');
}