@nodaro/shared 3.10.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.
Files changed (72) hide show
  1. package/dist/index.cjs +2242 -86
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1779 -32
  4. package/dist/index.d.ts +1779 -32
  5. package/dist/index.js +2041 -87
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/caption-styles.test.ts +207 -0
  9. package/src/__tests__/edl-multicam.test.ts +304 -0
  10. package/src/__tests__/edl.test.ts +822 -0
  11. package/src/__tests__/fan-out-rows.test.ts +208 -0
  12. package/src/__tests__/instagram-scrape.test.ts +66 -0
  13. package/src/__tests__/llm-models.test.ts +48 -11
  14. package/src/__tests__/meta-ads-scrape.test.ts +284 -0
  15. package/src/__tests__/node-runtime-keys.test.ts +15 -0
  16. package/src/__tests__/parameter-node-value.test.ts +13 -1
  17. package/src/__tests__/presentation-utils.test.ts +67 -0
  18. package/src/__tests__/producer-types.test.ts +19 -0
  19. package/src/__tests__/schedule-rules.test.ts +265 -0
  20. package/src/__tests__/speaker-layouts.test.ts +203 -0
  21. package/src/__tests__/transcribe-capabilities.test.ts +104 -0
  22. package/src/__tests__/transcribe-preflight.test.ts +60 -0
  23. package/src/__tests__/trigger-feeds.test.ts +39 -0
  24. package/src/__tests__/video-analysis.test.ts +15 -0
  25. package/src/__tests__/video-duration-auto.test.ts +65 -0
  26. package/src/__tests__/video-duration.test.ts +56 -0
  27. package/src/__tests__/video-frame-fit.test.ts +189 -0
  28. package/src/__tests__/video-link.test.ts +137 -0
  29. package/src/__tests__/workflow-export-strip.test.ts +59 -1
  30. package/src/caption-styles.ts +240 -0
  31. package/src/catalog-projection.ts +3 -0
  32. package/src/character-motion-metadata.ts +19 -0
  33. package/src/credit-identifiers.ts +31 -0
  34. package/src/edit-plan-contract.ts +96 -0
  35. package/src/edl-multicam.ts +185 -0
  36. package/src/edl.ts +747 -0
  37. package/src/entity-image-handle.ts +24 -1
  38. package/src/fan-out-rows.ts +213 -0
  39. package/src/i18n/character-motion.ar.ts +126 -75
  40. package/src/i18n/character-motion.de.ts +126 -75
  41. package/src/i18n/character-motion.es.ts +126 -75
  42. package/src/i18n/character-motion.fr.ts +126 -75
  43. package/src/i18n/character-motion.he.ts +126 -75
  44. package/src/i18n/character-motion.hi.ts +126 -75
  45. package/src/i18n/character-motion.ja.ts +126 -75
  46. package/src/i18n/character-motion.ko.ts +126 -75
  47. package/src/i18n/character-motion.pt-BR.ts +126 -75
  48. package/src/i18n/character-motion.ru.ts +126 -75
  49. package/src/i18n/character-motion.zh-CN.ts +126 -75
  50. package/src/index.ts +211 -3
  51. package/src/instagram-scrape.ts +204 -0
  52. package/src/llm-models.ts +80 -3
  53. package/src/meta-ads-scrape.ts +463 -0
  54. package/src/model-catalog.ts +48 -5
  55. package/src/model-constants.ts +148 -5
  56. package/src/node-mappable-fields.ts +2 -0
  57. package/src/node-runtime-keys.ts +28 -0
  58. package/src/parameter-node-value.ts +31 -5
  59. package/src/presentation-utils.ts +49 -0
  60. package/src/producer-types.ts +20 -0
  61. package/src/schedule-rules.ts +484 -0
  62. package/src/speaker-layouts.ts +220 -0
  63. package/src/transcribe-preflight.ts +101 -0
  64. package/src/trigger-feeds.ts +59 -0
  65. package/src/trigger-node-types.ts +20 -0
  66. package/src/video-analysis.ts +15 -0
  67. package/src/video-duration-auto.ts +18 -0
  68. package/src/video-duration.ts +32 -0
  69. package/src/video-frame-fit.ts +228 -0
  70. package/src/video-link.ts +167 -0
  71. package/src/video-output-canvas.ts +119 -0
  72. 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.