'use strict'; // Bangumi API caller — still used by the token test and public-tag refresh (read-only helpers). async function apiFetch(path, token){ const headers = {'Accept':'application/json'}; if(token) headers.Authorization = 'Bearer ' + token.trim(); const res = await fetch(bgmApiBase() + path, {headers, mode:'cors'}); if(!res.ok){ const text=await res.text().catch(()=>res.statusText); throw new Error(`HTTP ${res.status}: ${text.slice(0,200)}`); } return res.json(); } async function testLogin(){ const token = $('tokenInput').value.trim() || runtimeToken || state.settings.token; if(!token){ toast('请先粘贴 Access Token'); return; } $('loginLog').textContent = ''; log('loginLog','读取 /v0/me ...'); try{ const me = await apiFetch('/v0/me', token); const username = me.username || me.name || $('usernameInput').value.trim(); $('usernameInput').value = username || ''; log('loginLog',`登录成功:${username || JSON.stringify(me).slice(0,80)}`); toast('登录测试成功'); }catch(e){ log('loginLog','失败:' + e.message); toast('登录失败:' + e.message); } } function readToken(){ return $('tokenInput').value.trim() || runtimeToken || state.settings.token || ''; } async function saveSettingsFromUI(){ const btn=$('saveSettingsBtn'); if(btn){ btn.disabled=true; btn.textContent='保存中…'; } try{ state.settings.username = $('usernameInput').value.trim(); if(String(state.settings.autoSyncRangeMode) !== 'interval'){ state.settings.autoSyncRangeValue = Math.max(1, Math.min(999, Number($('recentMonthsInput')?.value || 3))); state.settings.autoSyncRangeUnit = $('autoSyncRangeUnit')?.value || 'month'; if(state.settings.autoSyncRangeUnit === 'month') state.settings.recentMonths = state.settings.autoSyncRangeValue; } state.settings.pageSize = clampNum($('pageSizeInput')?.value, 40, 360, 120); state.settings.commentLines = clampNum($('commentLinesInput')?.value, 0, 6, 3); state.settings.borderWidth = clampNum($('borderWidthInput')?.value, 0, 4, 1); state.settings.token = $('tokenInput').value.trim(); state.settings.autoCacheCovers = !!$('autoCacheCoversInput')?.checked; if($('autoSyncEnabledInput')) state.settings.autoSyncEnabled = !!$('autoSyncEnabledInput').checked; state.settings.bgmApiBase = ($('bgmApiBaseInput')?.value || '').trim(); state.settings.imageProxy = ($('imageProxyInput')?.value || '').trim(); state.settings.siteBase = ($('siteBaseInput')?.value || '').trim(); if(typeof refreshCronUI === 'function') refreshCronUI(); // writes state.settings.autoSyncCron from the editor runtimeToken = $('tokenInput').value.trim() || runtimeToken; applyAppearance(false); refreshSegmentedControls(); resetRenderLimit(); if(typeof snapshotAppearance==='function') snapshotAppearance(); // saved values become the new baseline (close won't revert) renderStats(); applyAuthVisibility(); toast('设置已保存'); // Persist asynchronously without blocking the UI (settings subtree only, never collections). saveSettings().catch(()=>{}); if($('publicModeInput') && $('publicNsfwInput')){ localPostJSON('/api/admin/public-mode',{enabled:!!$('publicModeInput').checked, showNsfw:!!$('publicNsfwInput').checked}).catch(()=>{}); } }catch(e){ toast('保存失败:'+e.message); } finally{ if(btn){ btn.disabled=false; btn.textContent='保存设置'; } } } function fillSettingsUI(){ $('tokenInput').value = state.settings.token || runtimeToken; $('usernameInput').value = state.settings.username||''; if(state.settings.autoSyncRangeValue == null) state.settings.autoSyncRangeValue = Number(state.settings.recentMonths || 3); if(!state.settings.autoSyncRangeUnit) state.settings.autoSyncRangeUnit = 'month'; if($('recentMonthsInput')) $('recentMonthsInput').value = state.settings.autoSyncRangeValue || 3; if($('autoSyncRangeUnit')) $('autoSyncRangeUnit').value = state.settings.autoSyncRangeUnit || 'month'; if($('pageSizeInput')) $('pageSizeInput').value = pageSize(); if($('commentLinesInput')) $('commentLinesInput').value = clampNum(state.settings.commentLines,0,6,3); if($('borderWidthInput')) $('borderWidthInput').value = clampNum(state.settings.borderWidth,0,4,1); if($('autoCacheCoversInput')) $('autoCacheCoversInput').checked = !!state.settings.autoCacheCovers; if($('bgmApiBaseInput')) $('bgmApiBaseInput').value = state.settings.bgmApiBase || ''; if($('imageProxyInput')) $('imageProxyInput').value = state.settings.imageProxy || ''; if($('siteBaseInput')) $('siteBaseInput').value = state.settings.siteBase || ''; if($('autoSyncEnabledInput')) $('autoSyncEnabledInput').checked = (state.settings.autoSyncEnabled === true || state.settings.autoSyncEnabled === 'true'); if(!state.settings.autoSyncCron && (state.settings.autoSyncTime || Array.isArray(state.settings.autoSyncDays))){ const [h,m]=String(state.settings.autoSyncTime||'02:00').split(':').map(Number); const days=Array.isArray(state.settings.autoSyncDays)?state.settings.autoSyncDays:[]; const dow=(days.length===0||days.length>=7)?'*':[...days].sort((a,b)=>a-b).join(','); state.settings.autoSyncCron=`${m||0} ${h||0} * * ${dow}`; } if(typeof applyCronToControls==='function') applyCronToControls(state.settings.autoSyncCron || '0 2 * * *'); if(typeof refreshAutoSyncCollapse==='function') refreshAutoSyncCollapse(); if(typeof refreshAutoSyncRange==='function') refreshAutoSyncRange(); refreshSegmentedControls(); apiJSON('/api/session').then(s=>{ if($('publicModeInput')) $('publicModeInput').checked=!!s?.publicMode; if($('publicNsfwInput')) $('publicNsfwInput').checked=!!s?.publicShowNsfw; }).catch(()=>{}); } // --- Server-side sync: the container runs it; the browser only kicks it off and polls progress. --- // Open the pre-sync confirm dialog; the shared modal adapts to recent/full mode. function openSyncConfirm(mode){ const isRecent = mode === 'recent'; const modal = $('syncConfirmModal'); if(!modal) return; modal.dataset.mode = isRecent ? 'recent' : 'full'; if($('syncConfirmTitle')) $('syncConfirmTitle').textContent = isRecent ? '同步最近' : '完整同步'; if($('syncConfirmHelp')) $('syncConfirmHelp').textContent = isRecent ? '只扫描最近指定月份内更新过的收藏,不触发安全箱判断,适合日常同步。' : '完整分页核对全部收藏,并对远端缺失的条目做安全箱判断。'; // Months row is recent-only; the subject-data toggle is full-only; cover toggle shows in both. const monthsRow = $('syncConfirmMonthsRow'); if(monthsRow) monthsRow.style.display = isRecent ? '' : 'none'; const subjectsRow = $('syncConfirmSubjectsRow'); if(subjectsRow) subjectsRow.style.display = isRecent ? 'none' : ''; if(isRecent){ // Months default to the auto-sync range until the user overrides it once (then sticky). const autoMonths = Number(state.settings.recentMonths || 3); const shown = state.settings.manualRecentMonths != null ? state.settings.manualRecentMonths : autoMonths; if($('syncConfirmMonthsInput')) $('syncConfirmMonthsInput').value = shown; if($('syncConfirmCoversInput')) $('syncConfirmCoversInput').checked = !!state.settings.manualRecentCovers; }else{ if($('syncConfirmCoversInput')) $('syncConfirmCoversInput').checked = !!state.settings.fullCovers; if($('syncConfirmSubjectsInput')) $('syncConfirmSubjectsInput').checked = state.settings.fullRefreshSubjects !== false; } openModal('syncConfirmModal'); } // Read the dialog choices, persist the months range, then start the server sync. async function confirmStartSync(){ const mode = $('syncConfirmModal')?.dataset.mode === 'full' ? 'full' : 'recent'; let opts; if(mode === 'recent'){ const autoMonths = Number(state.settings.recentMonths || 3); const m = Math.max(1, Math.min(120, Number($('syncConfirmMonthsInput')?.value || autoMonths))); const covers = !!$('syncConfirmCoversInput')?.checked; // Manual recent months are independent of the auto-sync range; once the user picks a value // different from the auto default, remember it (sticky) for next time. if(state.settings.manualRecentMonths != null || m !== autoMonths) state.settings.manualRecentMonths = m; state.settings.manualRecentCovers = covers; // Recent sync always refreshes subject data (few items, cheap). opts = { recentMonths: m, cacheCovers: covers, refreshSubjects: true }; }else{ const covers = !!$('syncConfirmCoversInput')?.checked; const subjects = !!$('syncConfirmSubjectsInput')?.checked; state.settings.fullCovers = covers; state.settings.fullRefreshSubjects = subjects; opts = { cacheCovers: covers, refreshSubjects: subjects }; } saveSettings().catch(()=>{}); closeModal('syncConfirmModal'); return startServerSync(mode, opts); } let _syncPollTimer = null; let _lastSyncMsg = ''; async function startServerSync(mode, opts = {}){ try{ const r = await apiJSON('/api/sync', {method:'POST', body:JSON.stringify({mode, ...opts})}); if(!r?.started){ toast('同步已在进行中'); } }catch(e){ toast('无法启动同步:' + e.message); return; } openModal('syncModal'); $('syncLog').textContent=''; setProgress(0); _lastSyncMsg=''; if($('syncTitle')) $('syncTitle').textContent = mode === 'recent' ? '同步最近' : '完整同步中'; if($('syncHelp')) $('syncHelp').textContent = '同步在服务器后台进行。可关闭此窗口,进度会显示在左上角;完成后目录会自动刷新。'; syncInProgress = true; pollSyncStatus(); } async function pollSyncStatus(){ if(_syncPollTimer){ clearTimeout(_syncPollTimer); _syncPollTimer=null; } let st = null; try{ st = await apiJSON('/api/sync/status'); }catch{} if(st){ syncInProgress = !!st.running; if(typeof st.percent === 'number') setProgress(st.percent); if(st.message && st.message !== _lastSyncMsg){ _lastSyncMsg = st.message; log('syncLog', st.message); } if(!st.running){ const r = st.lastResult; if(st.phase === 'error') toast('同步失败:' + (st.error || '')); else if(r && r.aborted) toast('安全保护触发:本次没有覆盖本地'); else if(st.phase === 'done' && r) toast(`同步完成:新增 ${r.added||0},修改 ${r.modified||0}`); try{ await loadState(); }catch{} resetRenderLimit(); render(); const mb=$('syncMiniBar'); if(mb && mb.classList.contains('show')){ mb.classList.add('smi-done'); if($('syncMiniLabel')) $('syncMiniLabel').textContent='同步完成'; if($('syncMiniFill')) $('syncMiniFill').style.width='100%'; setTimeout(()=>mb.classList.remove('show','smi-done'), 2400); } return; } } _syncPollTimer = setTimeout(pollSyncStatus, 1500); } // On page load, if the container is mid-sync, resume into the unobtrusive mini bar // (top-right) rather than popping the big modal. The user can click it to expand. async function resumeSyncIfRunning(){ try{ const st = await apiJSON('/api/sync/status'); if(st && st.running){ $('syncLog').textContent=''; setProgress(st.percent||0); _lastSyncMsg=''; if($('syncTitle')) $('syncTitle').textContent = (st.mode==='full'?'完整同步中':'同步最近') + (st.trigger==='auto'?'(定时)':''); if($('syncHelp')) $('syncHelp').textContent = '服务器正在后台同步;完成后目录会自动刷新。'; syncInProgress = true; showSyncMini(); pollSyncStatus(); } }catch{} } async function fetchSubjectPublicTags(subjectId, token){ // Fetch public tags via the API only (no website scraping). // Note: API tags may differ slightly in count/order from the website's "tagged by" section. const js = await apiFetch(`/v0/subjects/${encodeURIComponent(subjectId)}`, token || ''); return { tags: normalizePublicTags(js.tags || js.subject?.tags || []).sort((a,b)=>(Number(b.count)||0)-(Number(a.count)||0) || String(a.name).localeCompare(String(b.name),'zh-Hans-CN')), source: 'api' }; } async function enrichPublicTagsCurrent(){ const token = readToken(); const arr = visibleCollections(); if(!arr.length){ toast('当前筛选下没有条目'); return; } if(!confirm(`将为当前筛选下 ${arr.length} 个条目逐个刷新公共标签,并覆盖旧的公共标签缓存。条目较多时会比较慢,继续吗?`)) return; openModal('syncModal'); $('syncLog').textContent=''; setProgress(0); if($('syncTitle')) $('syncTitle').textContent = '刷新公共标签'; if($('syncHelp')) $('syncHelp').textContent = '公共标签来自 Bangumi API,不抓取官网网页;可能与网页标签区的展示数量和顺序略有差异。'; let ok=0, empty=0, fail=0; const started_at=nowISO(); for(let i=0;i