Initial release
Build and Push Docker Image / build (push) Successful in 17s

This commit is contained in:
Stardream
2026-06-16 19:26:32 +10:00
commit e3bf77296c
142 changed files with 5481 additions and 0 deletions
+267
View File
@@ -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();
};
});
}