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

268 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// Visual cron editor + pure cron helpers (5-field: min hour day-of-month month day-of-week).
// Classic script sharing the global scope (uses $, state, updateAutoSyncHelp from other modules).
const CRON_WEEKDAY_NAMES = ['日', '一', '二', '三', '四', '五', '六'];
// Editor UI state: which frequency preset is active, and the weekly day picks.
const cronUI = { mode: 'daily', weeklyDays: new Set() };
let rangeUnitDropdown = null; // custom .selectish for the auto-sync range unit
// Parse one cron field into a sorted array of allowed values. Mirrors the server matcher.
function cronParseField(field, min, max){
const out = new Set();
for(const part of String(field).split(',')){
const m = part.trim().match(/^(\*|\d+)(?:-(\d+))?(?:\/(\d+))?$/);
if(!m) return null; // any invalid token => whole field invalid
const step = m[3] ? Number(m[3]) : 1;
if(step < 1) return null;
let lo, hi;
if(m[1] === '*'){ lo = min; hi = max; }
else { lo = Number(m[1]); hi = m[2] !== undefined ? Number(m[2]) : (m[3] ? max : lo); }
if(lo < min || hi > max || lo > hi) return null;
for(let v = lo; v <= hi; v += step) out.add(v);
}
return [...out].sort((a,b)=>a-b);
}
function cronValidate(expr){
const parts = String(expr || '').trim().split(/\s+/);
if(parts.length !== 5) return false;
const ranges = [[0,59],[0,23],[1,31],[1,12],[0,6]];
return parts.every((p,i)=>cronParseField(p, ranges[i][0], ranges[i][1]) !== null);
}
function cronMatchesClient(expr, date){
const parts = String(expr).trim().split(/\s+/);
if(parts.length !== 5) return false;
const [mi, ho, dom, mon, dow] = parts;
const miS = cronParseField(mi,0,59), hoS = cronParseField(ho,0,23), monS = cronParseField(mon,1,12);
const domS = cronParseField(dom,1,31), dowS = cronParseField(dow,0,6);
if(!miS || !hoS || !monS || !domS || !dowS) return false;
if(!miS.includes(date.getMinutes())) return false;
if(!hoS.includes(date.getHours())) return false;
if(!monS.includes(date.getMonth()+1)) return false;
const domR = dom.trim() !== '*', dowR = dow.trim() !== '*';
const domOk = domS.includes(date.getDate()), dowOk = dowS.includes(date.getDay());
if(domR && dowR) return domOk || dowOk;
if(domR) return domOk;
if(dowR) return dowOk;
return true;
}
// Next n run times after `from`, scanning minute by minute up to ~366 days.
function cronNextRuns(expr, n = 3, from = new Date()){
if(!cronValidate(expr)) return [];
const out = [];
const d = new Date(from.getTime());
d.setSeconds(0, 0);
d.setMinutes(d.getMinutes() + 1); // start from the next minute
const limit = 366 * 24 * 60;
for(let i = 0; i < limit && out.length < n; i++){
if(cronMatchesClient(expr, d)) out.push(new Date(d.getTime()));
d.setMinutes(d.getMinutes() + 1);
}
return out;
}
// Human-readable Chinese description for common cron shapes; falls back to echoing the expression.
function cronDescribe(expr){
if(!cronValidate(expr)) return '表达式无效';
const [mi, ho, dom, mon, dow] = expr.trim().split(/\s+/);
const everyMin = mi.match(/^\*\/(\d+)$/);
if(everyMin && ho === '*' && dom === '*' && mon === '*' && dow === '*') return `每隔 ${everyMin[1]} 分钟`;
if(mi === '*' && ho === '*' && dom === '*' && mon === '*' && dow === '*') return '每分钟';
const everyHour = ho.match(/^\*\/(\d+)$/);
if(/^\d+$/.test(mi) && everyHour && dom === '*' && mon === '*' && dow === '*') return `每隔 ${everyHour[1]} 小时(第 ${mi} 分)`;
const hhmm = (/^\d+$/.test(mi) && /^\d+$/.test(ho)) ? `${String(ho).padStart(2,'0')}:${String(mi).padStart(2,'0')}` : null;
if(hhmm && dom === '*' && mon === '*' && dow === '*') return `每天 ${hhmm}`;
if(hhmm && dom === '*' && mon === '*' && dow !== '*'){
const days = cronParseField(dow,0,6) || [];
return `每周${days.map(d=>CRON_WEEKDAY_NAMES[d]).join('、')} ${hhmm}`;
}
if(hhmm && dom !== '*' && mon === '*' && dow === '*'){
const days = cronParseField(dom,1,31) || [];
return `每月 ${days.join('、')}${hhmm}`;
}
return `cron${expr.trim()}`;
}
// --- editor wiring ---
function clampInt(v, lo, hi, dflt){ const n = Math.round(Number(v)); return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : dflt; }
function selectCronFreq(mode){
cronUI.mode = mode;
document.querySelectorAll('#cronFreqRow button[data-freq]').forEach(b=>b.classList.toggle('active', b.dataset.freq === mode));
const panes = { minutes:'cronFreqMinutes', hours:'cronFreqHours', daily:'cronFreqDaily', weekly:'cronFreqWeekly', custom:'cronFreqCustom' };
// minutes/hours/daily are inline rows (need flex so their gap/align-items apply); weekly/custom are blocks.
const flexRow = { minutes:1, hours:1, daily:1 };
for(const [m, id] of Object.entries(panes)){
const el = $(id); if(!el) continue;
el.style.display = (m === mode) ? (flexRow[m] ? 'flex' : 'block') : 'none';
}
refreshCronUI();
}
// Collapse the auto-sync options when auto-sync is off (values stay in state and are still saved).
function refreshAutoSyncCollapse(){
const wrap = $('autoSyncOptions'); if(!wrap) return;
wrap.style.display = (String(state.settings.autoSyncEnabled) === 'true') ? 'grid' : 'none';
}
// A rough "约 N 小时/天/分钟" estimate of the scheduling interval (gap between the next two fires).
function cronIntervalText(){
const runs = cronNextRuns(state.settings.autoSyncCron || '0 2 * * *', 2);
if(runs.length < 2) return '';
const mins = Math.round((runs[1] - runs[0]) / 60000);
if(mins < 60) return `(约 ${mins} 分钟)`;
if(mins % 1440 === 0) return `(约 ${mins/1440} 天)`;
if(mins % 60 === 0) return `(约 ${mins/60} 小时)`;
return `(约 ${Math.round(mins/60*10)/10} 小时)`;
}
const CRON_UNIT_LABEL = { minute:'分钟', hour:'小时', day:'天', month:'个月' };
// Convert the scheduling interval (gap between the next two fires) into a {value, unit} pair.
function cronIntervalParts(){
const runs = cronNextRuns(state.settings.autoSyncCron || '0 2 * * *', 2);
if(runs.length < 2) return null;
const mins = Math.round((runs[1] - runs[0]) / 60000);
if(mins <= 0) return null;
if(mins % 1440 === 0) return { value: mins/1440, unit:'day' };
if(mins % 60 === 0) return { value: mins/60, unit:'hour' };
return { value: mins, unit:'minute' };
}
// Update only the help line (so live typing in fixed mode isn't reset).
function updateAutoSyncRangeHelp(){
const help = $('autoSyncRangeHelp'); if(!help) return;
const interval = String(state.settings.autoSyncRangeMode) === 'interval';
const num = $('recentMonthsInput'), unit = $('autoSyncRangeUnit');
const v = num ? (num.value || 3) : 3;
const u = CRON_UNIT_LABEL[unit ? unit.value : 'month'] || '个月';
help.textContent = interval
? `自动同步跟随调度频率,只回看一个调度周期(≈ ${v} ${u});手动“同步最近”不受影响。`
: `自动同步回看最近 ${v} ${u}内更新的条目。手动“同步最近”的范围在它的弹窗里单独设置。`;
}
// Full apply: 'interval' fills the inputs with the computed schedule interval and disables them;
// fixed restores the user's saved value/unit and enables them.
function refreshAutoSyncRange(){
const interval = String(state.settings.autoSyncRangeMode) === 'interval';
const num = $('recentMonthsInput'), unit = $('autoSyncRangeUnit'), btn = $('autoSyncRangeBtn');
if(btn){ btn.classList.toggle('good', interval); btn.textContent = interval ? '跟随调度频率 ✓' : '跟随调度频率'; }
const dd = $('autoSyncRangeUnitDropdown');
const setUnit = (v)=>{ if(rangeUnitDropdown) rangeUnitDropdown.setValue(v, true); else if(unit) unit.value = v; };
if(interval){
const p = cronIntervalParts();
if(num){ if(p) num.value = p.value; num.disabled = true; }
if(p) setUnit(p.unit);
}else{
if(num){ num.value = state.settings.autoSyncRangeValue || 3; num.disabled = false; }
setUnit(state.settings.autoSyncRangeUnit || 'month');
}
if(dd) dd.classList.toggle('disabled', interval);
updateAutoSyncRangeHelp();
}
function refreshCronWeeklyDays(){
document.querySelectorAll('#cronWeeklyDaysRow button[data-day]').forEach(btn=>{
btn.classList.toggle('active', cronUI.weeklyDays.has(Number(btn.dataset.day)));
});
}
// Read the active preset controls and produce a cron string.
function buildCronFromControls(){
switch(cronUI.mode){
case 'minutes': {
const n = clampInt($('cronEveryMinutes')?.value, 1, 59, 30);
return `*/${n} * * * *`;
}
case 'hours': {
const n = clampInt($('cronEveryHours')?.value, 1, 23, 4);
const m = clampInt($('cronHourMinute')?.value, 0, 59, 0);
return `${m} */${n} * * *`;
}
case 'daily': {
const [h, m] = String($('cronDailyTime')?.value || '02:00').split(':').map(Number);
return `${m || 0} ${h || 0} * * *`;
}
case 'weekly': {
const [h, m] = String($('cronWeeklyTime')?.value || '08:00').split(':').map(Number);
const days = [...cronUI.weeklyDays].sort((a,b)=>a-b);
const dow = days.length ? days.join(',') : '*';
return `${m || 0} ${h || 0} * * ${dow}`;
}
case 'custom':
default:
return String($('cronCustomInput')?.value || '').trim();
}
}
// Parse a cron string and reflect it back into the preset controls (best-effort; else 'custom').
function applyCronToControls(cron){
const expr = cronValidate(cron) ? cron.trim() : '0 2 * * *';
const [mi, ho, dom, mon, dow] = expr.split(/\s+/);
if($('cronCustomInput')) $('cronCustomInput').value = expr;
let mode = 'custom';
const everyMin = mi.match(/^\*\/(\d+)$/);
const everyHour = ho.match(/^\*\/(\d+)$/);
if(everyMin && ho === '*' && dom === '*' && mon === '*' && dow === '*'){
mode = 'minutes'; if($('cronEveryMinutes')) $('cronEveryMinutes').value = everyMin[1];
} else if(/^\d+$/.test(mi) && everyHour && dom === '*' && mon === '*' && dow === '*'){
mode = 'hours'; if($('cronEveryHours')) $('cronEveryHours').value = everyHour[1]; if($('cronHourMinute')) $('cronHourMinute').value = Number(mi);
} else if(/^\d+$/.test(mi) && /^\d+$/.test(ho) && dom === '*' && mon === '*' && dow === '*'){
mode = 'daily'; if($('cronDailyTime')) $('cronDailyTime').value = `${String(ho).padStart(2,'0')}:${String(mi).padStart(2,'0')}`;
} else if(/^\d+$/.test(mi) && /^\d+$/.test(ho) && dom === '*' && mon === '*' && dow !== '*' && cronParseField(dow,0,6)){
mode = 'weekly';
if($('cronWeeklyTime')) $('cronWeeklyTime').value = `${String(ho).padStart(2,'0')}:${String(mi).padStart(2,'0')}`;
cronUI.weeklyDays = new Set(cronParseField(dow,0,6));
}
cronUI.mode = mode;
refreshCronWeeklyDays();
selectCronFreq(mode);
}
function refreshCronUI(){
const cron = buildCronFromControls();
state.settings.autoSyncCron = cron;
const exprEl = $('cronExprText'); if(exprEl) exprEl.textContent = cron || '(空)';
const nextEl = $('cronNextText');
if(nextEl){
if(!cronValidate(cron)){ nextEl.textContent = '表达式无效,请检查'; }
else {
const runs = cronNextRuns(cron, 3);
nextEl.textContent = runs.length ? '下次运行:' + runs.map(d=>d.toLocaleString()).join('') : '一年内无匹配时间';
}
}
if(typeof updateAutoSyncHelp === 'function') updateAutoSyncHelp();
// In interval mode the range tracks the cron, so re-fill it when the cron changes.
if(String(state.settings.autoSyncRangeMode) === 'interval') refreshAutoSyncRange();
}
function bindCronEditor(){
document.querySelectorAll('#cronFreqRow button[data-freq]').forEach(btn=>{ btn.onclick = ()=>selectCronFreq(btn.dataset.freq); });
const enabledInput = $('autoSyncEnabledInput');
if(enabledInput) enabledInput.onchange = ()=>{
state.settings.autoSyncEnabled = enabledInput.checked;
refreshAutoSyncCollapse();
if(typeof updateAutoSyncHelp === 'function') updateAutoSyncHelp();
};
const rangeBtn = $('autoSyncRangeBtn');
if(rangeBtn) rangeBtn.onclick = ()=>{
state.settings.autoSyncRangeMode = (String(state.settings.autoSyncRangeMode) === 'interval') ? 'fixed' : 'interval';
refreshAutoSyncRange();
};
// Live-update only the help when the user edits the value (don't reset the field).
const rangeNum = $('recentMonthsInput'); if(rangeNum) rangeNum.addEventListener('input', updateAutoSyncRangeHelp);
// Custom .selectish dropdown for the unit (matches the type/sort dropdowns); onChange refreshes the help.
if(typeof setupDropdown === 'function') rangeUnitDropdown = setupDropdown('autoSyncRangeUnitDropdown','autoSyncRangeUnit','autoSyncRangeUnitLabel', ()=>updateAutoSyncRangeHelp());
['cronEveryMinutes','cronEveryHours','cronHourMinute','cronDailyTime','cronWeeklyTime','cronCustomInput'].forEach(id=>{
const el = $(id); if(el) el.oninput = refreshCronUI;
});
document.querySelectorAll('#cronWeeklyDaysRow button[data-day]').forEach(btn=>{
btn.onclick = ()=>{
const d = Number(btn.dataset.day);
if(cronUI.weeklyDays.has(d)) cronUI.weeklyDays.delete(d); else cronUI.weeklyDays.add(d);
refreshCronWeeklyDays(); refreshCronUI();
};
});
}