// backup-cron-schedule.js — standalone cron-next utility (BackupPage prototype). // Parses a 5-field crontab + computes the next fire time for the dashboard's // "next run" hint. Extracted verbatim from backup-page.js; loaded AFTER it. Object.assign(BackupPage.prototype, { // formatRelative's future-tense sibling — "in 6h", "tomorrow at 5am", etc. // Used by the backup-status header to summarise the next scheduled run. formatRelativeFuture(when) { if (!when) return ''; const t = when.getTime(); const diff = t - Date.now(); if (diff <= 0) return 'imminent'; const s = Math.floor(diff / 1000); if (s < 60) return 'in less than a minute'; const m = Math.floor(s / 60); if (m < 60) return `in ${m}m`; const h = Math.floor(m / 60); if (h < 24) return `in ${h}h`; const sameDay = (a, b) => a.toDateString() === b.toDateString(); const tomorrow = new Date(Date.now() + 86400000); if (sameDay(when, tomorrow)) return 'tomorrow'; const days = Math.floor(h / 24); return `in ${days}d`; }, // "05:00" or "Mon 05:00" depending on whether it's later today or not. formatScheduleClock(when) { if (!when) return ''; const sameDay = (new Date()).toDateString() === when.toDateString(); const t = when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); if (sameDay) return `at ${t}`; const day = when.toLocaleDateString([], { weekday: 'short' }); return `${day} ${t}`; }, // Tiny cron-next utility — given a 5-field crontab expression // (minute hour dom month dow) returns a Date for the next fire after // now, or null if the expression is unparseable / never fires within // the lookahead window. Supports the common syntax: *, N, lists // (N,M,O), ranges (N-M), and steps (* /N or N-M/S). Doesn't try to // be a full cron implementation — just enough for the // CFG_BACKUP_CRONTAB_APP value the WebUI shows in the header. nextCronFireTime(expr) { const fields = String(expr || '').trim().split(/\s+/); if (fields.length !== 5) return null; const ranges = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 6]]; let sets; try { sets = fields.map((f, i) => this._cronFieldSet(f, ranges[i][0], ranges[i][1])); } catch (_) { return null; } if (sets.some(s => !s.length)) return null; const [minSet, hourSet, domSet, monSet, dowSet] = sets; const minOk = new Set(minSet); const hourOk = new Set(hourSet); const domOk = new Set(domSet); const monOk = new Set(monSet); const dowOk = new Set(dowSet); const domStar = fields[2] === '*'; const dowStar = fields[4] === '*'; // Start one minute from now (rounded down to the minute) and walk // forward checking each candidate. Cap at ~366 days so a totally // unmatchable expression doesn't loop forever. const start = new Date(); start.setSeconds(0, 0); start.setMinutes(start.getMinutes() + 1); const limit = start.getTime() + 366 * 86400 * 1000; const cur = new Date(start); while (cur.getTime() < limit) { const m = cur.getMinutes(); const h = cur.getHours(); const dom = cur.getDate(); const mon = cur.getMonth() + 1; const dow = cur.getDay(); // 0 = Sun // POSIX cron: if both DOM and DOW are restricted, fire when // EITHER matches. If only one is restricted, that one must // match. If both are *, day passes. let dayMatch; if (domStar && dowStar) dayMatch = true; else if (domStar) dayMatch = dowOk.has(dow); else if (dowStar) dayMatch = domOk.has(dom); else dayMatch = domOk.has(dom) || dowOk.has(dow); if (dayMatch && monOk.has(mon) && hourOk.has(h) && minOk.has(m)) { return new Date(cur); } cur.setMinutes(cur.getMinutes() + 1); } return null; }, // Expand one cron field into a sorted list of valid numeric values. // Throws on bad syntax so nextCronFireTime can drop back to null. _cronFieldSet(field, lo, hi) { const out = new Set(); for (const part of String(field).split(',')) { // step (every-Nth): "value/step" — value is "*", a single // number, or a range "a-b". let stepBase = part, step = 1; const slash = part.indexOf('/'); if (slash !== -1) { stepBase = part.slice(0, slash); step = parseInt(part.slice(slash + 1), 10); if (!Number.isFinite(step) || step < 1) throw new Error('bad step'); } let from = lo, to = hi; if (stepBase === '*' || stepBase === '') { // range stays lo..hi } else if (stepBase.includes('-')) { const [a, b] = stepBase.split('-').map(n => parseInt(n, 10)); if (!Number.isFinite(a) || !Number.isFinite(b)) throw new Error('bad range'); from = a; to = b; } else { const n = parseInt(stepBase, 10); if (!Number.isFinite(n)) throw new Error('bad value'); from = n; to = n; } if (from < lo || to > hi || from > to) throw new Error('out of range'); for (let v = from; v <= to; v += step) out.add(v); } return [...out].sort((a, b) => a - b); }, });