diff --git a/containers/libreportal/frontend/components/backup/index.js b/containers/libreportal/frontend/components/backup/index.js index 5d7d67d..4e2f88a 100644 --- a/containers/libreportal/frontend/components/backup/index.js +++ b/containers/libreportal/frontend/components/backup/index.js @@ -16,6 +16,7 @@ LP.features.register({ scripts: [ '/components/backup/js/backup-schema.js', '/components/backup/js/backup-page.js', + '/components/backup/js/backup-cron-schedule.js', '/core/lib/backup-app-card.js', ], diff --git a/containers/libreportal/frontend/components/backup/js/backup-cron-schedule.js b/containers/libreportal/frontend/components/backup/js/backup-cron-schedule.js new file mode 100644 index 0000000..4ee66b2 --- /dev/null +++ b/containers/libreportal/frontend/components/backup/js/backup-cron-schedule.js @@ -0,0 +1,122 @@ +// 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); + }, +}); diff --git a/containers/libreportal/frontend/components/backup/js/backup-page.js b/containers/libreportal/frontend/components/backup/js/backup-page.js index 16caab6..bc58a1e 100644 --- a/containers/libreportal/frontend/components/backup/js/backup-page.js +++ b/containers/libreportal/frontend/components/backup/js/backup-page.js @@ -2348,123 +2348,6 @@ class BackupPage { return new Date(iso).toLocaleDateString(); } - // 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); - } } window.BackupPage = BackupPage;