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

183 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// 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<arr.length;i++){
const c = arr[i]; setProgress((i+1)/arr.length*100);
try{
const tagResult = await fetchSubjectPublicTags(c.subject_id, token);
const tags = Array.isArray(tagResult) ? tagResult : (tagResult.tags || []);
const row = state.collections[String(c.subject_id)];
if(row){
// History is server-authoritative now (the browser no longer holds it), so don't push
// history entries here — saveState() posts an empty history and the server preserves its own.
row.publicTags = tags;
row.public_tags_source = Array.isArray(tagResult) ? 'api' : (tagResult.source || 'api');
row.public_tags_fetched_at = nowISO();
row.content_hash = hashString(stableStringify(comparable(row)));
}
if(tags.length) ok++; else empty++;
if((i+1)%10===0){ log('syncLog',`已处理 ${i+1}/${arr.length},有标签 ${ok},空标签 ${empty},失败 ${fail}`); await saveState(); await sleep(220); }
else await sleep(120);
}catch(e){ fail++; if(fail <= 8) log('syncLog',`失败 ${c.subject_id} ${c.title||''}${e.message || e}`); await sleep(300); }
}
await saveState(); resetRenderLimit(); render(); log('syncLog',`完成:有标签 ${ok},空标签 ${empty},失败 ${fail}`); toast(`公共标签刷新完成:${ok} 个条目有标签`);
}