MEGAINFRA-WEBAPP/foreman/mod/hooks/schedule.mjs
DEls 7719e4f36a feat(foreman): scheduler updates for the runner and mod hooks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 23:52:12 +02:00

198 lines
7.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Foreman's schedule language, shared by the pane (mod) and the runner (bin/runner.mjs).
//
// manual only when you press Run now
// mon-fri 08:00 11:00 14:00 set times on set days
// daily 07:30 every day
// mon,wed,fri 09:00 a list of days
// every 2h every two hours, all day
// every 30m mon-fri 07:00-17:00 every 30 minutes inside a window
// once 2026-10-12 08:00 one time only ("once tomorrow 08:00" is saved as a date)
//
// Times are the Mac's local time.
const DAY_NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
const DAY_LABEL = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6]
const MIN_EVERY = 5
const dayIndex = name => DAY_NAMES.indexOf(name.slice(0, 3))
function parseTime(token) {
let m = /^(\d{1,2}):(\d{2})$/.exec(token)
if (m) {
const h = Number(m[1])
const min = Number(m[2])
return h < 24 && min < 60 ? h * 60 + min : null
}
m = /^(\d{1,2})(?::(\d{2}))?(am|pm)$/.exec(token)
if (m) {
let h = Number(m[1]) % 12
if (m[3] === 'pm') h += 12
const min = Number(m[2] ?? 0)
return min < 60 ? h * 60 + min : null
}
return null
}
function parseDays(token) {
if (token === 'daily' || token === 'everyday' || token === 'every-day') return ALL_DAYS
if (token === 'weekdays') return [1, 2, 3, 4, 5]
if (token === 'weekends' || token === 'weekend') return [0, 6]
const range = /^([a-z]{3,9})-([a-z]{3,9})$/.exec(token)
if (range) {
const a = dayIndex(range[1])
const b = dayIndex(range[2])
if (a < 0 || b < 0) return null
const out = []
for (let d = a; ; d = (d + 1) % 7) {
out.push(d)
if (d === b) break
}
return out
}
const list = token.split(',').filter(Boolean).map(dayIndex)
return list.length && list.every(d => d >= 0) ? list : null
}
/** @returns {import('./schedule.d.mts').Schedule} */
export function parseSchedule(text) {
const t = String(text ?? '').trim().toLowerCase()
if (t === '' || t === 'manual' || t === 'off') return { kind: 'manual' }
const tokens = t.split(/\s+/)
if (tokens[0] === 'once') {
const date = /^(\d{4})-(\d{2})-(\d{2})$/.exec(tokens[1] ?? '')
const time = parseTime(tokens[2] ?? '')
if (!date || time === null || tokens.length > 3)
return { kind: 'error', error: 'One-off runs look like "once 2026-10-12 08:00" (or "once tomorrow 08:00").' }
const at = new Date(Number(date[1]), Number(date[2]) - 1, Number(date[3]), Math.floor(time / 60), time % 60)
if (Number.isNaN(at.getTime()) || at.getDate() !== Number(date[3])) return { kind: 'error', error: `There is no date ${tokens[1]}.` }
return { kind: 'once', at: at.getTime() }
}
let days = null
const times = []
let everyMin = null
let window = null
for (let i = 0; i < tokens.length; i++) {
const tok = tokens[i]
if (tok === 'at' || tok === 'and') continue
if (tok === 'every') {
const amount = tokens[++i] ?? ''
const m = /^(\d+)\s*(m|min|mins|minutes?|h|hr|hrs|hours?)$/.exec(amount)
if (!m) return { kind: 'error', error: `After "every" put an amount like 2h or 30m (got "${amount}").` }
everyMin = Number(m[1]) * (m[2].startsWith('h') ? 60 : 1)
if (everyMin < MIN_EVERY) return { kind: 'error', error: `The shortest interval is every ${MIN_EVERY}m.` }
continue
}
const win = /^(\S+)-(\d{1,2}:\d{2}(?:am|pm)?)$/.exec(tok)
if (win && parseTime(win[1]) !== null && parseTime(win[2]) !== null) {
window = [parseTime(win[1]), parseTime(win[2])]
continue
}
const time = parseTime(tok)
if (time !== null) {
times.push(time)
continue
}
const d = parseDays(tok)
if (d) {
days = [...new Set([...(days ?? []), ...d])].sort()
continue
}
return { kind: 'error', error: `Didn't understand "${tok}". Try "mon-fri 08:00 14:00" or "every 2h".` }
}
const useDays = days ?? ALL_DAYS
if (everyMin !== null) return { kind: 'every', days: useDays, everyMin, window }
if (times.length) return { kind: 'times', days: useDays, times: [...new Set(times)].sort((a, b) => a - b) }
return { kind: 'error', error: 'Add a time (08:00), an interval (every 2h), or "manual".' }
}
function slotsOn(s, dayStart) {
if (s.kind === 'once') {
const at = new Date(s.at)
return at.toDateString() === dayStart.toDateString() ? [at] : []
}
if (s.kind !== 'times' && s.kind !== 'every') return []
if (!s.days.includes(dayStart.getDay())) return []
const mins =
s.kind === 'times'
? s.times
: (() => {
const [from, to] = s.window ?? [0, 24 * 60 - 1]
const out = []
for (let m = from; m <= to; m += s.everyMin) out.push(m)
return out
})()
return mins.map(m => new Date(dayStart.getFullYear(), dayStart.getMonth(), dayStart.getDate(), Math.floor(m / 60), m % 60))
}
const startOfDay = (d, offset) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + offset)
/** The latest scheduled moment at or before `now`, or null. */
export function lastSlot(s, now = new Date()) {
if (s.kind === 'once') return s.at <= now.getTime() ? new Date(s.at) : null
for (let back = 0; back <= 7; back++) {
const slots = slotsOn(s, startOfDay(now, -back)).filter(x => x <= now)
if (slots.length) return slots[slots.length - 1]
}
return null
}
/** The next scheduled moment after `now`, or null. */
export function nextSlot(s, now = new Date()) {
if (s.kind === 'once') return s.at > now.getTime() ? new Date(s.at) : null
for (let ahead = 0; ahead <= 7; ahead++) {
const slot = slotsOn(s, startOfDay(now, ahead)).find(x => x > now)
if (slot) return slot
}
return null
}
/** Every slot between from and to (inclusive), oldest first. */
export function slotsBetween(s, from, to) {
const out = []
for (let d = startOfDay(from, 0); d <= to; d = startOfDay(d, 1)) {
for (const x of slotsOn(s, d)) if (x >= from && x <= to) out.push(x)
}
return out
}
const hhmm = m => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
function daysLabel(days) {
const key = [...days].sort().join(',')
if (key === '0,1,2,3,4,5,6') return 'every day'
if (key === '1,2,3,4,5') return 'Mon–Fri'
if (key === '0,6') return 'weekends'
return days.map(d => DAY_LABEL[d]).join(', ')
}
/** A short human description. */
export function describeSchedule(s) {
if (s.kind === 'manual') return 'Manual only'
if (s.kind === 'error') return `⚠ ${s.error}`
if (s.kind === 'once') {
const at = new Date(s.at)
return `Once on ${at.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' })} at ${hhmm(at.getHours() * 60 + at.getMinutes())}`
}
if (s.kind === 'times') return `${daysLabel(s.days)} at ${s.times.map(hhmm).join(', ')}`
const every = s.everyMin % 60 === 0 ? `${s.everyMin / 60}h` : `${s.everyMin}m`
return `Every ${every}, ${daysLabel(s.days)}${s.window ? `, ${hhmm(s.window[0])}–${hhmm(s.window[1])}` : ''}`
}
/** How late a missed slot may still run (the Mac was asleep, say). */
export function graceMs(s) {
const twoHours = 2 * 60 * 60 * 1000
if (s.kind === 'once') return 12 * 60 * 60 * 1000 // a one-off still runs if the Mac wakes up later that day
return s.kind === 'every' ? Math.min(twoHours, s.everyMin * 60 * 1000) : twoHours
}
const ymd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
/** Turns "once today/tomorrow HH:MM" into a fixed date, so a saved one-off never drifts. */
export function normalizeSchedule(text, now = new Date()) {
const t = String(text ?? '').trim()
const m = /^once\s+(today|tomorrow)\b(.*)$/i.exec(t)
if (!m) return t
const day = new Date(now.getFullYear(), now.getMonth(), now.getDate() + (m[1].toLowerCase() === 'tomorrow' ? 1 : 0))
return `once ${ymd(day)}${m[2]}`
}