'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('导入完成');
  }



