'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(); }; }); }