This commit is contained in:
+267
@@ -0,0 +1,267 @@
|
||||
'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();
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user