This commit is contained in:
@@ -0,0 +1,594 @@
|
||||
'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 => ({'<':'<','>':'>','&':'&','"':'"',"'":'''}[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('导入完成');
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user