@nodaro/shared 3.11.0 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +2047 -84
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1556 -27
- package/dist/index.d.ts +1556 -27
- package/dist/index.js +1861 -85
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/caption-styles.test.ts +207 -0
- package/src/__tests__/edl-multicam.test.ts +304 -0
- package/src/__tests__/edl.test.ts +822 -0
- package/src/__tests__/fan-out-rows.test.ts +208 -0
- package/src/__tests__/instagram-scrape.test.ts +66 -0
- package/src/__tests__/llm-models.test.ts +48 -11
- package/src/__tests__/meta-ads-scrape.test.ts +284 -0
- package/src/__tests__/node-runtime-keys.test.ts +15 -0
- package/src/__tests__/presentation-utils.test.ts +67 -0
- package/src/__tests__/producer-types.test.ts +19 -0
- package/src/__tests__/schedule-rules.test.ts +265 -0
- package/src/__tests__/speaker-layouts.test.ts +203 -0
- package/src/__tests__/transcribe-capabilities.test.ts +104 -0
- package/src/__tests__/transcribe-preflight.test.ts +60 -0
- package/src/__tests__/trigger-feeds.test.ts +39 -0
- package/src/__tests__/video-duration-auto.test.ts +65 -0
- package/src/__tests__/video-duration.test.ts +56 -0
- package/src/__tests__/video-link.test.ts +137 -0
- package/src/__tests__/workflow-export-strip.test.ts +59 -1
- package/src/caption-styles.ts +240 -0
- package/src/credit-identifiers.ts +31 -0
- package/src/edit-plan-contract.ts +96 -0
- package/src/edl-multicam.ts +185 -0
- package/src/edl.ts +747 -0
- package/src/entity-image-handle.ts +24 -1
- package/src/fan-out-rows.ts +213 -0
- package/src/index.ts +206 -3
- package/src/instagram-scrape.ts +204 -0
- package/src/llm-models.ts +80 -3
- package/src/meta-ads-scrape.ts +463 -0
- package/src/model-catalog.ts +48 -5
- package/src/model-constants.ts +148 -5
- package/src/node-mappable-fields.ts +2 -0
- package/src/node-runtime-keys.ts +28 -0
- package/src/presentation-utils.ts +49 -0
- package/src/producer-types.ts +20 -0
- package/src/schedule-rules.ts +484 -0
- package/src/speaker-layouts.ts +220 -0
- package/src/transcribe-preflight.ts +101 -0
- package/src/trigger-feeds.ts +59 -0
- package/src/trigger-node-types.ts +20 -0
- package/src/video-duration-auto.ts +18 -0
- package/src/video-duration.ts +32 -0
- package/src/video-link.ts +167 -0
- package/src/workflow-export.ts +37 -1
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedule Trigger rules — the ONE model both sides read.
|
|
3
|
+
*
|
|
4
|
+
* A schedule is a list of rules; the workflow runs whenever any rule says
|
|
5
|
+
* "this minute". The editor previews a schedule (headline, today's timeline,
|
|
6
|
+
* the next runs, runs per day) and the server decides whether to fire — from
|
|
7
|
+
* the same functions here, so what the panel shows is what the cron does.
|
|
8
|
+
*
|
|
9
|
+
* Kinds, with the fields each reads (everything else on the rule is ignored):
|
|
10
|
+
* - `minutes` — `every` (1–59): at minute 0, N, 2N… of every hour (cron `*\/N`).
|
|
11
|
+
* - `hours` — `every` (1–23) at `minute`: at hour 0, N, 2N… of every day.
|
|
12
|
+
* - `days` — `every` (1–31) at `hour:minute`: every Nth calendar day.
|
|
13
|
+
* - `weeks` — `every` (1–52) on `weekdays` at `hour:minute`: every Nth week.
|
|
14
|
+
* - `months` — `every` (1–12) on `dayOfMonth` at `hour:minute`: every Nth month
|
|
15
|
+
* (a day the month lacks — 31 in April — runs on its last day).
|
|
16
|
+
* - `cron` — a 5-field cron expression, for the person who wants one.
|
|
17
|
+
*
|
|
18
|
+
* "Every Nth day / week / month" counts from a fixed origin (the Unix epoch,
|
|
19
|
+
* in the schedule's timezone; weeks start on Monday) rather than from the
|
|
20
|
+
* moment the rule was saved — so the preview and the server agree, a re-save
|
|
21
|
+
* never shifts the phase, and two people reading the rule get the same days.
|
|
22
|
+
*
|
|
23
|
+
* Time is read in the schedule's timezone (an IANA name; missing or unknown
|
|
24
|
+
* → UTC — callers validate with `isValidTimezone` and refuse the unknown
|
|
25
|
+
* ones, so that fallback is only ever a defence). Resolution is one minute:
|
|
26
|
+
* the cron ticks once a minute and asks "does this minute match?" — there is
|
|
27
|
+
* no sub-minute scheduling.
|
|
28
|
+
*
|
|
29
|
+
* Daylight-saving: a wall-clock minute that does not exist on the day the
|
|
30
|
+
* clocks jump forward is skipped that day. On the day they fall back, a rule
|
|
31
|
+
* that names a time of day (`hours` / `days` / `weeks` / `months` / `cron`)
|
|
32
|
+
* runs once — the second pass is the same wall-clock minute
|
|
33
|
+
* (`localMinuteKey`) as the fire just before it, and both the server and the
|
|
34
|
+
* preview drop it — while a `minutes` rule keeps its cadence through the
|
|
35
|
+
* repeated hour, because real time keeps passing.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export const SCHEDULE_RULE_KINDS = ["minutes", "hours", "days", "weeks", "months", "cron"] as const
|
|
39
|
+
export type ScheduleRuleKind = (typeof SCHEDULE_RULE_KINDS)[number]
|
|
40
|
+
|
|
41
|
+
export interface ScheduleRule {
|
|
42
|
+
readonly id: string
|
|
43
|
+
readonly kind: ScheduleRuleKind
|
|
44
|
+
/** Every N units (see `SCHEDULE_EVERY_LIMITS`). */
|
|
45
|
+
readonly every?: number
|
|
46
|
+
/** 0–23, in the schedule's timezone. Read by days / weeks / months. */
|
|
47
|
+
readonly hour?: number
|
|
48
|
+
/** 0–59. Read by every kind but `minutes` and `cron`. */
|
|
49
|
+
readonly minute?: number
|
|
50
|
+
/** 0 = Sunday … 6 = Saturday (the cron / JavaScript convention). Read by `weeks`. */
|
|
51
|
+
readonly weekdays?: ReadonlyArray<number>
|
|
52
|
+
/** 1–31. Read by `months`. */
|
|
53
|
+
readonly dayOfMonth?: number
|
|
54
|
+
/** A 5-field cron expression. Read by `cron`. */
|
|
55
|
+
readonly cron?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ScheduleSpec {
|
|
59
|
+
readonly rules: ReadonlyArray<ScheduleRule>
|
|
60
|
+
/** IANA timezone; missing or unknown means UTC. */
|
|
61
|
+
readonly timezone?: string
|
|
62
|
+
/** Stop after this many runs; missing means unlimited. */
|
|
63
|
+
readonly maxExecutions?: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `every` limits per kind — the panel's range hints and the normaliser's clamps. */
|
|
67
|
+
export const SCHEDULE_EVERY_LIMITS: Readonly<Record<Exclude<ScheduleRuleKind, "cron">, readonly [number, number]>> = {
|
|
68
|
+
minutes: [1, 59],
|
|
69
|
+
hours: [1, 23],
|
|
70
|
+
days: [1, 31],
|
|
71
|
+
weeks: [1, 52],
|
|
72
|
+
months: [1, 12],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Normalisation
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
function int(value: unknown): number | null {
|
|
80
|
+
if (typeof value === "number" && Number.isFinite(value)) return Math.trunc(value)
|
|
81
|
+
if (typeof value === "string" && /^-?\d+$/.test(value.trim())) return parseInt(value.trim(), 10)
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function clamp(value: number | null, lo: number, hi: number, fallback: number): number {
|
|
86
|
+
if (value === null) return fallback
|
|
87
|
+
return Math.min(hi, Math.max(lo, value))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A count of units: zero or less is "not typed yet", never "every minute". */
|
|
91
|
+
function positiveInt(value: unknown): number | null {
|
|
92
|
+
const n = int(value)
|
|
93
|
+
return n !== null && n >= 1 ? n : null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A 5-field cron expression — the only shape the matcher understands. */
|
|
97
|
+
export function isCronExpression(value: unknown): value is string {
|
|
98
|
+
return typeof value === "string" && value.trim().split(/\s+/).filter(Boolean).length === 5
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One rule as the user typed it → one rule the engine can run, or `null` when
|
|
103
|
+
* there is nothing to run (unknown kind, a cron rule with no expression, a
|
|
104
|
+
* weeks rule with no weekday). Out-of-range numbers are clamped, never
|
|
105
|
+
* refused: a half-typed "0" must not silently disable a schedule.
|
|
106
|
+
*/
|
|
107
|
+
export function normalizeScheduleRule(raw: unknown, fallbackId = "rule-1"): ScheduleRule | null {
|
|
108
|
+
if (!raw || typeof raw !== "object") return null
|
|
109
|
+
const r = raw as Record<string, unknown>
|
|
110
|
+
if (typeof r.kind !== "string" || !(SCHEDULE_RULE_KINDS as readonly string[]).includes(r.kind)) return null
|
|
111
|
+
const kind = r.kind as ScheduleRuleKind
|
|
112
|
+
const id = typeof r.id === "string" && r.id.trim() ? r.id.trim() : fallbackId
|
|
113
|
+
const minute = clamp(int(r.minute), 0, 59, 0)
|
|
114
|
+
const hour = clamp(int(r.hour), 0, 23, 0)
|
|
115
|
+
switch (kind) {
|
|
116
|
+
case "minutes":
|
|
117
|
+
return { id, kind, every: clamp(positiveInt(r.every),...SCHEDULE_EVERY_LIMITS.minutes, 5) }
|
|
118
|
+
case "hours":
|
|
119
|
+
return { id, kind, every: clamp(positiveInt(r.every),...SCHEDULE_EVERY_LIMITS.hours, 1), minute }
|
|
120
|
+
case "days":
|
|
121
|
+
return { id, kind, every: clamp(positiveInt(r.every),...SCHEDULE_EVERY_LIMITS.days, 1), hour, minute }
|
|
122
|
+
case "weeks": {
|
|
123
|
+
const weekdays = Array.isArray(r.weekdays)
|
|
124
|
+
? [...new Set(r.weekdays.map(int).filter((d): d is number => d !== null && d >= 0 && d <= 6))].sort((a, b) => a - b)
|
|
125
|
+
: []
|
|
126
|
+
if (weekdays.length === 0) return null
|
|
127
|
+
return { id, kind, every: clamp(positiveInt(r.every),...SCHEDULE_EVERY_LIMITS.weeks, 1), hour, minute, weekdays }
|
|
128
|
+
}
|
|
129
|
+
case "months":
|
|
130
|
+
return {
|
|
131
|
+
id,
|
|
132
|
+
kind,
|
|
133
|
+
every: clamp(positiveInt(r.every),...SCHEDULE_EVERY_LIMITS.months, 1),
|
|
134
|
+
hour,
|
|
135
|
+
minute,
|
|
136
|
+
dayOfMonth: clamp(int(r.dayOfMonth), 1, 31, 1),
|
|
137
|
+
}
|
|
138
|
+
case "cron": {
|
|
139
|
+
const cron = typeof r.cron === "string" ? r.cron.trim().split(/\s+/).join(" ") : ""
|
|
140
|
+
return isCronExpression(cron) ? { id, kind, cron } : null
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Every usable rule, in order; unusable ones dropped. */
|
|
146
|
+
export function normalizeScheduleRules(raw: unknown): ScheduleRule[] {
|
|
147
|
+
if (!Array.isArray(raw)) return []
|
|
148
|
+
return raw.map((r, i) => normalizeScheduleRule(r, `rule-${i + 1}`)).filter((r): r is ScheduleRule => r !== null)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The schedule an OLD node carried — `interval` ("5m" / "1h" / "1d" or one of
|
|
153
|
+
* the editor's cron presets) and/or `cron` — as rules. Seconds cannot be
|
|
154
|
+
* scheduled (the cron ticks once a minute) and become "every minute".
|
|
155
|
+
*/
|
|
156
|
+
export function legacyScheduleToRules(data: { interval?: unknown; cron?: unknown; cronExpression?: unknown }): ScheduleRule[] {
|
|
157
|
+
const interval = typeof data.interval === "string" ? data.interval.trim() : ""
|
|
158
|
+
const explicit = [data.cron, data.cronExpression].find((v): v is string => typeof v === "string" && v.trim() !== "")?.trim() ?? ""
|
|
159
|
+
const m = interval.match(/^(\d+)([smhd])$/)
|
|
160
|
+
if (m) {
|
|
161
|
+
const n = parseInt(m[1], 10)
|
|
162
|
+
// "0m" never fired (it parsed to 0 ms); it must not start firing now.
|
|
163
|
+
if (n < 1) return []
|
|
164
|
+
switch (m[2]) {
|
|
165
|
+
case "s": return [{ id: "rule-1", kind: "minutes", every: 1 }]
|
|
166
|
+
case "m": return n < 60
|
|
167
|
+
? [{ id: "rule-1", kind: "minutes", every: n }]
|
|
168
|
+
: [{ id: "rule-1", kind: "hours", every: Math.min(23, Math.max(1, Math.round(n / 60))), minute: 0 }]
|
|
169
|
+
case "h": return n < 24
|
|
170
|
+
? [{ id: "rule-1", kind: "hours", every: n, minute: 0 }]
|
|
171
|
+
: [{ id: "rule-1", kind: "days", every: Math.min(31, Math.max(1, Math.round(n / 24))), hour: 0, minute: 0 }]
|
|
172
|
+
default: return [{ id: "rule-1", kind: "days", every: Math.min(31, n), hour: 0, minute: 0 }]
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const expression = interval === "" || interval === "custom" ? explicit : interval
|
|
176
|
+
if (!isCronExpression(expression)) return []
|
|
177
|
+
const fromCron = cronToRule(expression)
|
|
178
|
+
// Through the normaliser like every other rule: a cron nobody could say in
|
|
179
|
+
// words ("0 9 * * 5-0" — an empty weekday set) must come out as "not
|
|
180
|
+
// configured", never as a rule that silently matches nothing or everything.
|
|
181
|
+
return normalizeScheduleRules([fromCron ?? { id: "rule-1", kind: "cron", cron: expression.split(/\s+/).join(" ") }])
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A cron step; a zero or negative step is not a step (it never matched before either). */
|
|
185
|
+
function cronStep(field: string): number | null {
|
|
186
|
+
const m = field.match(/^\*\/(\d+)$/)
|
|
187
|
+
if (!m) return null
|
|
188
|
+
const n = parseInt(m[1], 10)
|
|
189
|
+
return n >= 1 ? n : null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The plain shapes a cron expression can be said in words; anything else stays a cron rule. */
|
|
193
|
+
function cronToRule(expression: string): ScheduleRule | null {
|
|
194
|
+
const [min, hour, dom, month, dow] = expression.trim().split(/\s+/)
|
|
195
|
+
if (month !== "*") return null
|
|
196
|
+
const minN = /^\d+$/.test(min) ? parseInt(min, 10) : null
|
|
197
|
+
const hourN = /^\d+$/.test(hour) ? parseInt(hour, 10) : null
|
|
198
|
+
const stepMin = cronStep(min)
|
|
199
|
+
const stepHour = cronStep(hour)
|
|
200
|
+
if (stepMin !== null && hour === "*" && dom === "*" && dow === "*") return { id: "rule-1", kind: "minutes", every: stepMin }
|
|
201
|
+
if (min === "*" && hour === "*" && dom === "*" && dow === "*") return { id: "rule-1", kind: "minutes", every: 1 }
|
|
202
|
+
if (minN !== null && stepHour !== null && dom === "*" && dow === "*") return { id: "rule-1", kind: "hours", every: stepHour, minute: minN }
|
|
203
|
+
if (minN !== null && hour === "*" && dom === "*" && dow === "*") return { id: "rule-1", kind: "hours", every: 1, minute: minN }
|
|
204
|
+
if (minN !== null && hourN !== null && dom === "*" && dow === "*") return { id: "rule-1", kind: "days", every: 1, hour: hourN, minute: minN }
|
|
205
|
+
if (minN !== null && hourN !== null && dom === "*" && /^[0-6](,[0-6])*$|^[0-6]-[0-6]$/.test(dow)) {
|
|
206
|
+
const weekdays = dow.includes("-")
|
|
207
|
+
? (() => { const [a, b] = dow.split("-").map(Number); return Array.from({ length: b - a + 1 }, (_, i) => a + i) })()
|
|
208
|
+
: dow.split(",").map(Number)
|
|
209
|
+
return { id: "rule-1", kind: "weeks", every: 1, hour: hourN, minute: minN, weekdays }
|
|
210
|
+
}
|
|
211
|
+
if (minN !== null && hourN !== null && /^\d+$/.test(dom) && dow === "*") {
|
|
212
|
+
return { id: "rule-1", kind: "months", every: 1, hour: hourN, minute: minN, dayOfMonth: parseInt(dom, 10) }
|
|
213
|
+
}
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Local time
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
export interface LocalTime {
|
|
222
|
+
readonly year: number
|
|
223
|
+
/** 1–12 */
|
|
224
|
+
readonly month: number
|
|
225
|
+
/** 1–31 */
|
|
226
|
+
readonly day: number
|
|
227
|
+
readonly hour: number
|
|
228
|
+
readonly minute: number
|
|
229
|
+
/** 0 = Sunday … 6 = Saturday */
|
|
230
|
+
readonly weekday: number
|
|
231
|
+
/** Calendar days since 1970-01-01, in the schedule's timezone. */
|
|
232
|
+
readonly epochDay: number
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Formatters are cached under the zone's CANONICAL name (`resolvedOptions`),
|
|
237
|
+
* never under the string a request spelled it with — "aSiA/jErUsAlEm" is
|
|
238
|
+
* accepted by Intl, and a caller can spell a zone thousands of ways. The
|
|
239
|
+
* spelling → canonical map is bounded too, since that is the one a request
|
|
240
|
+
* can grow.
|
|
241
|
+
*/
|
|
242
|
+
const formatters = new Map<string, Intl.DateTimeFormat>()
|
|
243
|
+
const canonicalZone = new Map<string, string>()
|
|
244
|
+
const MAX_ZONE_SPELLINGS = 1024
|
|
245
|
+
|
|
246
|
+
function formatterFor(timezone: string | undefined): Intl.DateTimeFormat | null {
|
|
247
|
+
const spelled = timezone && timezone.trim() ? timezone.trim() : "UTC"
|
|
248
|
+
const known = canonicalZone.get(spelled)
|
|
249
|
+
if (known) return formatters.get(known) ?? null
|
|
250
|
+
try {
|
|
251
|
+
const fmt = new Intl.DateTimeFormat("en-US", {
|
|
252
|
+
timeZone: spelled,
|
|
253
|
+
hourCycle: "h23",
|
|
254
|
+
year: "numeric",
|
|
255
|
+
month: "2-digit",
|
|
256
|
+
day: "2-digit",
|
|
257
|
+
hour: "2-digit",
|
|
258
|
+
minute: "2-digit",
|
|
259
|
+
})
|
|
260
|
+
const canonical = fmt.resolvedOptions().timeZone
|
|
261
|
+
if (!formatters.has(canonical)) formatters.set(canonical, fmt)
|
|
262
|
+
if (canonicalZone.size >= MAX_ZONE_SPELLINGS) canonicalZone.clear()
|
|
263
|
+
canonicalZone.set(spelled, canonical)
|
|
264
|
+
return formatters.get(canonical) ?? fmt
|
|
265
|
+
} catch {
|
|
266
|
+
return null
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** A timezone this runtime can read the clock in (an IANA name such as `Asia/Jerusalem`). */
|
|
271
|
+
export function isValidTimezone(value: unknown): value is string {
|
|
272
|
+
return typeof value === "string" && value.trim() !== "" && formatterFor(value) !== null
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** One wall-clock minute, as a key: the same key twice means the clocks fell back. */
|
|
276
|
+
export function localMinuteKey(local: LocalTime): string {
|
|
277
|
+
return `${local.epochDay}:${local.hour}:${local.minute}`
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function localFromComponents(year: number, month: number, day: number, hour: number, minute: number): LocalTime {
|
|
281
|
+
const epochDay = Math.floor(Date.UTC(year, month - 1, day) / 86400000)
|
|
282
|
+
// 1970-01-01 was a Thursday (4).
|
|
283
|
+
const weekday = (((epochDay % 7) + 7 + 4) % 7)
|
|
284
|
+
return { year, month, day, hour: hour === 24 ? 0 : hour, minute, weekday, epochDay }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The wall-clock time in the schedule's timezone (UTC when it is missing or unknown). */
|
|
288
|
+
export function localTimeIn(date: Date, timezone?: string): LocalTime {
|
|
289
|
+
const fmt = formatterFor(timezone)
|
|
290
|
+
if (!fmt) return localFromComponents(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes())
|
|
291
|
+
const parts: Record<string, number> = {}
|
|
292
|
+
for (const p of fmt.formatToParts(date)) {
|
|
293
|
+
if (p.type === "literal") continue
|
|
294
|
+
parts[p.type] = parseInt(p.value, 10)
|
|
295
|
+
}
|
|
296
|
+
return localFromComponents(parts.year, parts.month, parts.day, parts.hour, parts.minute)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** How far the timezone's wall clock is ahead of UTC at this instant, in minutes. */
|
|
300
|
+
export function timezoneOffsetMinutes(date: Date, timezone?: string): number {
|
|
301
|
+
const local = localTimeIn(date, timezone)
|
|
302
|
+
const asUtc = Date.UTC(local.year, local.month - 1, local.day, local.hour, local.minute)
|
|
303
|
+
const truncated = Math.floor(date.getTime() / 60000) * 60000
|
|
304
|
+
return Math.round((asUtc - truncated) / 60000)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function daysInMonth(year: number, month: number): number {
|
|
308
|
+
return new Date(Date.UTC(year, month, 0)).getUTCDate()
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// Matching
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
/** Standard 5-field cron field: `*`, `N`, `a-b`, `a,b`, `*\/N`, `a-b/N`, `a/N`. */
|
|
316
|
+
export function matchesCronField(field: string, value: number, min: number, max: number): boolean {
|
|
317
|
+
if (field === "*") return true
|
|
318
|
+
if (field.includes(",")) return field.split(",").some((part) => matchesCronField(part.trim(), value, min, max))
|
|
319
|
+
if (field.includes("/")) {
|
|
320
|
+
const [range, step] = field.split("/")
|
|
321
|
+
const stepNum = parseInt(step, 10)
|
|
322
|
+
if (Number.isNaN(stepNum) || stepNum <= 0) return false
|
|
323
|
+
if (range === "*") return value % stepNum === 0
|
|
324
|
+
if (range.includes("-")) {
|
|
325
|
+
const [start, end] = parseRange(range)
|
|
326
|
+
if (start === null || end === null) return false
|
|
327
|
+
return value >= start && value <= end && (value - start) % stepNum === 0
|
|
328
|
+
}
|
|
329
|
+
const start = parseInt(range, 10)
|
|
330
|
+
if (Number.isNaN(start)) return false
|
|
331
|
+
return value >= start && value <= max && (value - start) % stepNum === 0
|
|
332
|
+
}
|
|
333
|
+
if (field.includes("-")) {
|
|
334
|
+
const [start, end] = parseRange(field)
|
|
335
|
+
if (start === null || end === null) return false
|
|
336
|
+
return value >= start && value <= end
|
|
337
|
+
}
|
|
338
|
+
const num = parseInt(field, 10)
|
|
339
|
+
return !Number.isNaN(num) && num === value
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function parseRange(range: string): [number | null, number | null] {
|
|
343
|
+
const parts = range.split("-")
|
|
344
|
+
if (parts.length !== 2) return [null, null]
|
|
345
|
+
const start = parseInt(parts[0], 10)
|
|
346
|
+
const end = parseInt(parts[1], 10)
|
|
347
|
+
return [Number.isNaN(start) ? null : start, Number.isNaN(end) ? null : end]
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Does this wall-clock minute match the cron expression? */
|
|
351
|
+
export function matchesCron(expression: string, local: LocalTime): boolean {
|
|
352
|
+
const fields = expression.trim().split(/\s+/)
|
|
353
|
+
if (fields.length !== 5) return false
|
|
354
|
+
// Every field must match (day-of-month AND day-of-week — the stricter of
|
|
355
|
+
// the two readings crontabs disagree on; documented). Sunday answers to 7
|
|
356
|
+
// as well as 0, as it does in every crontab.
|
|
357
|
+
return (
|
|
358
|
+
matchesCronField(fields[0], local.minute, 0, 59) &&
|
|
359
|
+
matchesCronField(fields[1], local.hour, 0, 23) &&
|
|
360
|
+
matchesCronField(fields[2], local.day, 1, 31) &&
|
|
361
|
+
matchesCronField(fields[3], local.month, 1, 12) &&
|
|
362
|
+
(matchesCronField(fields[4], local.weekday, 0, 6) || (local.weekday === 0 && matchesCronField(fields[4], 7, 0, 7)))
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Does this wall-clock minute match the rule? */
|
|
367
|
+
export function ruleMatches(rule: ScheduleRule, local: LocalTime): boolean {
|
|
368
|
+
const every = Math.max(1, rule.every ?? 1)
|
|
369
|
+
const minute = rule.minute ?? 0
|
|
370
|
+
const hour = rule.hour ?? 0
|
|
371
|
+
switch (rule.kind) {
|
|
372
|
+
case "minutes":
|
|
373
|
+
return local.minute % every === 0
|
|
374
|
+
case "hours":
|
|
375
|
+
return local.minute === minute && local.hour % every === 0
|
|
376
|
+
case "days":
|
|
377
|
+
return local.minute === minute && local.hour === hour && local.epochDay % every === 0
|
|
378
|
+
case "weeks": {
|
|
379
|
+
// Monday-start weeks counted from the epoch (1970-01-01 was a Thursday, so +3 lands Monday 1969-12-29 on week 0).
|
|
380
|
+
const weekIndex = Math.floor((local.epochDay + 3) / 7)
|
|
381
|
+
return (
|
|
382
|
+
local.minute === minute &&
|
|
383
|
+
local.hour === hour &&
|
|
384
|
+
(rule.weekdays ?? []).includes(local.weekday) &&
|
|
385
|
+
weekIndex % every === 0
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
case "months": {
|
|
389
|
+
const wanted = Math.min(rule.dayOfMonth ?? 1, daysInMonth(local.year, local.month))
|
|
390
|
+
const monthIndex = local.year * 12 + (local.month - 1)
|
|
391
|
+
return local.minute === minute && local.hour === hour && local.day === wanted && monthIndex % every === 0
|
|
392
|
+
}
|
|
393
|
+
case "cron":
|
|
394
|
+
return typeof rule.cron === "string" && matchesCron(rule.cron, local)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Does the schedule run at this instant (any rule, this minute)? */
|
|
399
|
+
export function scheduleMatchesAt(spec: ScheduleSpec, at: Date): boolean {
|
|
400
|
+
if (spec.rules.length === 0) return false
|
|
401
|
+
const local = localTimeIn(at, spec.timezone)
|
|
402
|
+
return spec.rules.some((rule) => ruleMatches(rule, local))
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
// Occurrences (previews)
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
/** Clocks change on a quarter hour at the latest (Adelaide and Lord Howe move mid-UTC-hour), so the offset is re-read per 15-minute slot. */
|
|
410
|
+
const OFFSET_SLOT_MS = 15 * 60000
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Every minute in [from, until] at which the schedule runs, oldest first, at
|
|
414
|
+
* most `cap`. Scans minute by minute with the timezone offset re-read once per
|
|
415
|
+
* quarter hour, so a 62-day horizon costs ~90k cheap checks and ~6k clock
|
|
416
|
+
* reads. A wall-clock minute the clocks fall back onto is listed once.
|
|
417
|
+
*/
|
|
418
|
+
export function scheduleOccurrences(spec: ScheduleSpec, from: Date, until: Date, cap: number): Date[] {
|
|
419
|
+
const out: Date[] = []
|
|
420
|
+
if (spec.rules.length === 0 || cap <= 0) return out
|
|
421
|
+
const rules = spec.rules
|
|
422
|
+
let t = Math.ceil(from.getTime() / 60000) * 60000
|
|
423
|
+
const end = until.getTime()
|
|
424
|
+
let slot = -1
|
|
425
|
+
let offset = 0
|
|
426
|
+
let lastKey = ""
|
|
427
|
+
while (t <= end && out.length < cap) {
|
|
428
|
+
const thisSlot = Math.floor(t / OFFSET_SLOT_MS)
|
|
429
|
+
if (thisSlot !== slot) {
|
|
430
|
+
slot = thisSlot
|
|
431
|
+
offset = timezoneOffsetMinutes(new Date(t), spec.timezone)
|
|
432
|
+
}
|
|
433
|
+
const shifted = new Date(t + offset * 60000)
|
|
434
|
+
const local = localFromComponents(
|
|
435
|
+
shifted.getUTCFullYear(),
|
|
436
|
+
shifted.getUTCMonth() + 1,
|
|
437
|
+
shifted.getUTCDate(),
|
|
438
|
+
shifted.getUTCHours(),
|
|
439
|
+
shifted.getUTCMinutes(),
|
|
440
|
+
)
|
|
441
|
+
if (rules.some((rule) => ruleMatches(rule, local))) {
|
|
442
|
+
const key = localMinuteKey(local)
|
|
443
|
+
if (key !== lastKey) out.push(new Date(t))
|
|
444
|
+
lastKey = key
|
|
445
|
+
}
|
|
446
|
+
t += 60000
|
|
447
|
+
}
|
|
448
|
+
return out
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const DAY_MS = 86400000
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* How far ahead a preview must look to find the schedule's next run: two
|
|
455
|
+
* periods of its slowest rule (a quarterly rule needs half a year, a yearly
|
|
456
|
+
* one two), never less than two months and never more than ~two years.
|
|
457
|
+
*/
|
|
458
|
+
export function previewHorizonMs(rules: ReadonlyArray<ScheduleRule>): number {
|
|
459
|
+
let longest = 0
|
|
460
|
+
for (const rule of rules) {
|
|
461
|
+
const every = Math.max(1, rule.every ?? 1)
|
|
462
|
+
const period =
|
|
463
|
+
rule.kind === "days" ? every * DAY_MS
|
|
464
|
+
: rule.kind === "weeks" ? every * 7 * DAY_MS
|
|
465
|
+
: rule.kind === "months" ? every * 31 * DAY_MS
|
|
466
|
+
: rule.kind === "cron" ? 366 * DAY_MS
|
|
467
|
+
: DAY_MS
|
|
468
|
+
if (period > longest) longest = period
|
|
469
|
+
}
|
|
470
|
+
return Math.min(800 * DAY_MS, Math.max(62 * DAY_MS, longest * 2 + DAY_MS))
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* The next `count` runs strictly after `from` (the minute `from` is in does
|
|
475
|
+
* not count, the very next one does), looking `horizonMs` ahead — by default
|
|
476
|
+
* as far as the rules need (`previewHorizonMs`).
|
|
477
|
+
*/
|
|
478
|
+
export function nextScheduleRuns(spec: ScheduleSpec, from: Date, count: number, horizonMs = previewHorizonMs(spec.rules)): Date[] {
|
|
479
|
+
return scheduleOccurrences(spec, new Date(from.getTime() + 1), new Date(from.getTime() + horizonMs), count)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Putting a rule into words is deliberately NOT here: the editor says it in
|
|
483
|
+
// the person's language (i18n), and nothing in the wire contract needs the
|
|
484
|
+
// English. The rule's fields are the structured form to render from.
|