Release Time Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 05:05:59 +02:00
parent 3c82e7a305
commit 5e462f881d
30 changed files with 1830 additions and 110 deletions
+815
View File
@@ -0,0 +1,815 @@
import { assertBoundedText } from "@add-ideas/toolbox-helpers";
import { Temporal } from "temporal-polyfill";
import { previewRRule } from "./recurrence";
const MAX_ICS_TEXT = 8 * 1024 * 1024;
const MAX_LINES = 100_000;
const MAX_PROPERTIES = 100_000;
const MAX_COMPONENTS = 20_000;
const MAX_COMPONENT_DEPTH = 16;
const MAX_EVENTS = 10_000;
const MAX_VALUE = 1_048_576;
export interface IcsDiagnostic {
severity: "warning" | "error";
code: string;
message: string;
line?: number;
uid?: string;
}
export interface IcsDateValue {
raw: string;
valueType: "date" | "date-time";
timeZone: string | "floating";
local: string;
instant?: string;
}
export interface IcsExternalReference {
property: "URL" | "ATTACH";
inert: true;
kind: "uri" | "embedded-binary";
mediaType?: string;
scheme?: string;
value?: string;
encodedCharacters?: number;
}
export interface IcsEventInspection {
componentIndex: number;
uid: string;
summary: string;
status?: string;
sequence?: number;
start?: IcsDateValue;
end?: IcsDateValue;
duration?: string;
recurrenceId?: IcsDateValue;
recurrenceRange?: string;
rrule?: string;
rdates: IcsDateValue[];
exdates: IcsDateValue[];
references: IcsExternalReference[];
alarms: number;
propertyNames: string[];
}
export interface IcsOccurrence {
uid: string;
summary: string;
scheduled: string;
start: string;
source: "master" | "override";
status?: string;
}
export interface IcsTimeZoneInspection {
tzid: string;
observances: Array<{
kind: "STANDARD" | "DAYLIGHT";
start?: string;
offsetFrom?: string;
offsetTo?: string;
rrule?: string;
rdates: string[];
}>;
usableByBrowser: boolean;
}
export interface IcsInspection {
schemaVersion: 1;
version?: string;
productId?: string;
method?: string;
componentCounts: Record<string, number>;
timeZones: IcsTimeZoneInspection[];
events: IcsEventInspection[];
occurrences: IcsOccurrence[];
references: IcsExternalReference[];
diagnostics: IcsDiagnostic[];
limits: {
inputCharacters: number;
unfoldedLines: number;
components: number;
properties: number;
occurrenceLimit: number;
occurrenceLimitReached: boolean;
};
}
interface ContentLine {
name: string;
group?: string;
params: Record<string, string[]>;
value: string;
line: number;
}
interface Component {
name: string;
line: number;
properties: ContentLine[];
children: Component[];
}
export function inspectIcs(
sourceInput: string,
occurrenceLimitInput = 100,
): IcsInspection {
const source = assertBoundedText(
sourceInput,
MAX_ICS_TEXT,
"iCalendar input",
);
if (source.includes("\0"))
throw new SyntaxError("iCalendar input contains NUL characters.");
const occurrenceLimit = Math.trunc(occurrenceLimitInput);
if (
!Number.isSafeInteger(occurrenceLimit) ||
occurrenceLimit < 1 ||
occurrenceLimit > 1_000
)
throw new RangeError("Occurrence limit must be between 1 and 1,000.");
const unfolded = unfoldLines(source);
const parsed = parseComponents(unfolded);
const diagnostics: IcsDiagnostic[] = [];
const root = parsed.root;
if (root.name !== "VCALENDAR")
throw new SyntaxError("The root component must be VCALENDAR.");
const counts: Record<string, number> = Object.create(null) as Record<
string,
number
>;
countComponents(root, counts);
const version = singleValue(root, "VERSION", diagnostics);
const productId = singleValue(root, "PRODID", diagnostics);
const method = singleValue(root, "METHOD", diagnostics);
if (version !== "2.0")
diagnostics.push({
severity: "error",
code: "calendar-version",
message: version
? `Unsupported iCalendar VERSION ${version}.`
: "VCALENDAR is missing VERSION:2.0.",
line: root.line,
});
if (!productId)
diagnostics.push({
severity: "warning",
code: "missing-prodid",
message: "VCALENDAR is missing PRODID.",
line: root.line,
});
const timeZones = root.children
.filter((component) => component.name === "VTIMEZONE")
.map((component) => inspectTimeZone(component, diagnostics));
const declaredZones = new Set(timeZones.map((zone) => zone.tzid));
const eventComponents = root.children.filter(
(component) => component.name === "VEVENT",
);
if (eventComponents.length > MAX_EVENTS)
throw new RangeError(
`Calendar exceeds the ${MAX_EVENTS.toLocaleString()} event limit.`,
);
const events = eventComponents.map((component, index) =>
inspectEvent(component, index, diagnostics, declaredZones),
);
validateEventGroups(events, diagnostics);
const occurrences = expandOccurrences(events, occurrenceLimit, diagnostics);
const references = events.flatMap((event) => event.references);
return {
schemaVersion: 1,
version,
productId,
method,
componentCounts: counts,
timeZones,
events,
occurrences: occurrences.slice(0, occurrenceLimit),
references,
diagnostics,
limits: {
inputCharacters: source.length,
unfoldedLines: unfolded.length,
components: parsed.components,
properties: parsed.properties,
occurrenceLimit,
occurrenceLimitReached: occurrences.length >= occurrenceLimit,
},
};
}
function unfoldLines(source: string): Array<{ value: string; line: number }> {
const physical = source
.replaceAll("\r\n", "\n")
.replaceAll("\r", "\n")
.split("\n");
if (physical.length > MAX_LINES)
throw new RangeError(
`Calendar exceeds the ${MAX_LINES.toLocaleString()} physical-line limit.`,
);
const output: Array<{ value: string; line: number }> = [];
physical.forEach((value, index) => {
if (/^[ \t]/u.test(value)) {
const previous = output.at(-1);
if (!previous)
throw new SyntaxError(
`Folded continuation has no previous line at line ${index + 1}.`,
);
previous.value += value.slice(1);
if (previous.value.length > MAX_VALUE)
throw new RangeError(
`Unfolded content line at line ${previous.line} exceeds the value limit.`,
);
} else if (value || index < physical.length - 1)
output.push({ value, line: index + 1 });
});
return output;
}
function parseComponents(lines: Array<{ value: string; line: number }>): {
root: Component;
components: number;
properties: number;
} {
const stack: Component[] = [];
let root: Component | undefined;
let components = 0;
let properties = 0;
for (const input of lines) {
if (!input.value) continue;
const property = parseContentLine(input.value, input.line);
if (property.name === "BEGIN") {
const name = property.value.trim().toUpperCase();
if (!/^[A-Z0-9-]+$/u.test(name))
throw new SyntaxError(`Invalid component name at line ${input.line}.`);
components += 1;
if (components > MAX_COMPONENTS)
throw new RangeError(
`Calendar exceeds the ${MAX_COMPONENTS.toLocaleString()} component limit.`,
);
if (stack.length >= MAX_COMPONENT_DEPTH)
throw new RangeError(
`Calendar exceeds the ${MAX_COMPONENT_DEPTH}-level component nesting limit.`,
);
const component: Component = {
name,
line: input.line,
properties: [],
children: [],
};
if (stack.length) stack.at(-1)!.children.push(component);
else if (root)
throw new SyntaxError(
`Multiple root components begin at line ${input.line}.`,
);
else root = component;
stack.push(component);
} else if (property.name === "END") {
const current = stack.pop();
if (!current || current.name !== property.value.trim().toUpperCase())
throw new SyntaxError(
`Mismatched END:${property.value} at line ${input.line}.`,
);
} else {
const current = stack.at(-1);
if (!current)
throw new SyntaxError(
`Property outside a component at line ${input.line}.`,
);
properties += 1;
if (properties > MAX_PROPERTIES)
throw new RangeError(
`Calendar exceeds the ${MAX_PROPERTIES.toLocaleString()} property limit.`,
);
current.properties.push(property);
}
}
if (stack.length)
throw new SyntaxError(
`Component ${stack.at(-1)!.name} beginning at line ${stack.at(-1)!.line} is not closed.`,
);
if (!root) throw new SyntaxError("Calendar contains no component.");
return { root, components, properties };
}
function parseContentLine(value: string, line: number): ContentLine {
let quoted = false;
let separator = -1;
for (let index = 0; index < value.length; index += 1) {
if (value[index] === '"') quoted = !quoted;
else if (value[index] === ":" && !quoted) {
separator = index;
break;
}
}
if (separator < 1) throw new SyntaxError(`Malformed content line ${line}.`);
const head = splitOutsideQuotes(value.slice(0, separator), ";");
const rawName = head.shift() ?? "";
const dot = rawName.lastIndexOf(".");
const group = dot >= 0 ? rawName.slice(0, dot) : undefined;
const name = (dot >= 0 ? rawName.slice(dot + 1) : rawName).toUpperCase();
if (!/^[A-Z0-9-]+$/u.test(name))
throw new SyntaxError(`Invalid property name at line ${line}.`);
const params: Record<string, string[]> = Object.create(null) as Record<
string,
string[]
>;
for (const token of head) {
const equals = token.indexOf("=");
if (equals < 1)
throw new SyntaxError(`Malformed parameter at line ${line}.`);
const key = token.slice(0, equals).toUpperCase();
if (!/^[A-Z0-9-]+$/u.test(key))
throw new SyntaxError(`Invalid parameter name at line ${line}.`);
params[key] = splitOutsideQuotes(token.slice(equals + 1), ",").map(
(entry) => decodeParameter(entry.replace(/^"|"$/gu, "")),
);
}
return { name, group, params, value: value.slice(separator + 1), line };
}
function splitOutsideQuotes(value: string, separator: string): string[] {
const output: string[] = [];
let start = 0;
let quoted = false;
for (let index = 0; index < value.length; index += 1) {
if (value[index] === '"') quoted = !quoted;
else if (value[index] === separator && !quoted) {
output.push(value.slice(start, index));
start = index + 1;
}
}
if (quoted)
throw new SyntaxError(
"Content line contains an unterminated quoted parameter.",
);
output.push(value.slice(start));
return output;
}
function decodeParameter(value: string): string {
return value
.replaceAll("^^", "\0")
.replaceAll("^n", "\n")
.replaceAll("^N", "\n")
.replaceAll("^'", '"')
.replaceAll("\0", "^");
}
function props(component: Component, name: string): ContentLine[] {
return component.properties.filter((property) => property.name === name);
}
function singleValue(
component: Component,
name: string,
diagnostics: IcsDiagnostic[],
): string | undefined {
const values = props(component, name);
if (values.length > 1)
diagnostics.push({
severity: "error",
code: "duplicate-property",
message: `${component.name} has ${values.length} ${name} properties; only one is allowed.`,
line: values[1]?.line,
});
return values[0]?.value;
}
function countComponents(
component: Component,
counts: Record<string, number>,
): void {
counts[component.name] = (counts[component.name] ?? 0) + 1;
component.children.forEach((child) => countComponents(child, counts));
}
function inspectTimeZone(
component: Component,
diagnostics: IcsDiagnostic[],
): IcsTimeZoneInspection {
const tzid = singleValue(component, "TZID", diagnostics)?.trim() ?? "";
if (!tzid)
diagnostics.push({
severity: "error",
code: "missing-tzid",
message: "VTIMEZONE is missing TZID.",
line: component.line,
});
let usableByBrowser = false;
if (tzid)
try {
Temporal.Now.instant().toZonedDateTimeISO(tzid);
usableByBrowser = true;
} catch {
diagnostics.push({
severity: "warning",
code: "custom-vtimezone",
message: `VTIMEZONE ${tzid} is structurally inspected, but its custom transition rules cannot be executed by this browser; recurrence expansion using it is withheld.`,
line: component.line,
});
}
const observances = component.children
.filter(
(child): child is Component & { name: "STANDARD" | "DAYLIGHT" } =>
child.name === "STANDARD" || child.name === "DAYLIGHT",
)
.map((child) => ({
kind: child.name,
start: props(child, "DTSTART")[0]?.value,
offsetFrom: props(child, "TZOFFSETFROM")[0]?.value,
offsetTo: props(child, "TZOFFSETTO")[0]?.value,
rrule: props(child, "RRULE")[0]?.value,
rdates: props(child, "RDATE").flatMap((property) =>
property.value.split(","),
),
}));
if (!observances.length)
diagnostics.push({
severity: "warning",
code: "empty-vtimezone",
message: `VTIMEZONE ${tzid || "(missing TZID)"} has no STANDARD or DAYLIGHT observance.`,
line: component.line,
});
return { tzid, observances, usableByBrowser };
}
function inspectEvent(
component: Component,
componentIndex: number,
diagnostics: IcsDiagnostic[],
declaredZones: Set<string>,
): IcsEventInspection {
const uid = unescapeText(singleValue(component, "UID", diagnostics) ?? "");
const recurrence = props(component, "RECURRENCE-ID")[0];
const start = props(component, "DTSTART")[0];
const end = props(component, "DTEND")[0];
const duration = props(component, "DURATION")[0]?.value;
if (!uid)
diagnostics.push({
severity: "error",
code: "missing-uid",
message: "VEVENT is missing UID.",
line: component.line,
});
if (!start && !recurrence)
diagnostics.push({
severity: "error",
code: "missing-dtstart",
message: `VEVENT ${uid || componentIndex + 1} is missing DTSTART.`,
line: component.line,
uid,
});
if (end && duration)
diagnostics.push({
severity: "error",
code: "end-and-duration",
message: `VEVENT ${uid || componentIndex + 1} has both DTEND and DURATION.`,
line: end.line,
uid,
});
const sequenceText = props(component, "SEQUENCE")[0]?.value;
const sequence =
sequenceText === undefined ? undefined : Number(sequenceText);
if (
sequence !== undefined &&
(!Number.isSafeInteger(sequence) || sequence < 0)
)
diagnostics.push({
severity: "error",
code: "invalid-sequence",
message: `VEVENT ${uid || componentIndex + 1} has an invalid SEQUENCE.`,
uid,
});
const references = [
...props(component, "URL").map((property) =>
externalReference(property, "URL"),
),
...props(component, "ATTACH").map((property) =>
externalReference(property, "ATTACH"),
),
];
return {
componentIndex,
uid: uid || `(missing UID ${componentIndex + 1})`,
summary: unescapeText(
props(component, "SUMMARY")[0]?.value ?? "(untitled event)",
),
status: props(component, "STATUS")[0]?.value.toUpperCase(),
sequence:
Number.isSafeInteger(sequence) && (sequence ?? -1) >= 0
? sequence
: undefined,
start: start
? parseDateProperty(start, diagnostics, declaredZones, uid)
: undefined,
end: end
? parseDateProperty(end, diagnostics, declaredZones, uid)
: undefined,
duration,
recurrenceId: recurrence
? parseDateProperty(recurrence, diagnostics, declaredZones, uid)
: undefined,
recurrenceRange: recurrence?.params.RANGE?.[0]?.toUpperCase(),
rrule: props(component, "RRULE")[0]?.value,
rdates: props(component, "RDATE").flatMap((property) =>
parseDateList(property, diagnostics, declaredZones, uid),
),
exdates: props(component, "EXDATE").flatMap((property) =>
parseDateList(property, diagnostics, declaredZones, uid),
),
references,
alarms: component.children.filter((child) => child.name === "VALARM")
.length,
propertyNames: [
...new Set(component.properties.map((property) => property.name)),
].sort(),
};
}
function parseDateList(
property: ContentLine,
diagnostics: IcsDiagnostic[],
declaredZones: Set<string>,
uid: string,
): IcsDateValue[] {
return property.value
.split(",")
.map((value) =>
parseDateProperty(
{ ...property, value },
diagnostics,
declaredZones,
uid,
),
);
}
function parseDateProperty(
property: ContentLine,
diagnostics: IcsDiagnostic[],
declaredZones: Set<string>,
uid: string,
): IcsDateValue {
const raw = property.value.trim();
const tzid = property.params.TZID?.[0];
const dateOnly =
property.params.VALUE?.[0]?.toUpperCase() === "DATE" ||
/^\d{8}$/u.test(raw);
try {
if (dateOnly) {
if (!/^\d{8}$/u.test(raw)) throw new RangeError("invalid DATE");
const local = `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`;
Temporal.PlainDate.from(local);
return { raw, valueType: "date", timeZone: "floating", local };
}
const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/u.exec(
raw,
);
if (!match) throw new RangeError("invalid DATE-TIME");
const local = `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}`;
const plain = Temporal.PlainDateTime.from(local);
if (match[7]) {
const instant = Temporal.Instant.from(`${local}Z`).toString();
return { raw, valueType: "date-time", timeZone: "UTC", local, instant };
}
if (tzid) {
try {
const zoned = plain.toZonedDateTime(tzid, {
disambiguation: "compatible",
});
return {
raw,
valueType: "date-time",
timeZone: tzid,
local,
instant: zoned.toInstant().toString(),
};
} catch {
if (!declaredZones.has(tzid))
diagnostics.push({
severity: "error",
code: "unknown-tzid",
message: `${property.name} references unavailable TZID ${tzid}.`,
line: property.line,
uid,
});
return { raw, valueType: "date-time", timeZone: tzid, local };
}
}
return { raw, valueType: "date-time", timeZone: "floating", local };
} catch {
diagnostics.push({
severity: "error",
code: "invalid-date",
message: `${property.name} has invalid iCalendar date value ${raw}.`,
line: property.line,
uid,
});
return {
raw,
valueType: dateOnly ? "date" : "date-time",
timeZone: tzid ?? "floating",
local: raw,
};
}
}
function externalReference(
property: ContentLine,
name: "URL" | "ATTACH",
): IcsExternalReference {
const binary =
name === "ATTACH" &&
(property.params.VALUE?.some((value) => value.toUpperCase() === "BINARY") ||
property.params.ENCODING?.some(
(value) => value.toUpperCase() === "BASE64",
));
if (binary)
return {
property: name,
inert: true,
kind: "embedded-binary",
mediaType: property.params.FMTTYPE?.[0],
encodedCharacters: property.value.length,
};
return {
property: name,
inert: true,
kind: "uri",
mediaType: property.params.FMTTYPE?.[0],
scheme: /^([a-z][a-z0-9+.-]*):/iu.exec(property.value)?.[1]?.toLowerCase(),
value: property.value,
};
}
function validateEventGroups(
events: IcsEventInspection[],
diagnostics: IcsDiagnostic[],
): void {
const groups = new Map<string, IcsEventInspection[]>();
for (const event of events) {
const group = groups.get(event.uid) ?? [];
group.push(event);
groups.set(event.uid, group);
}
for (const [uid, group] of groups) {
const masters = group.filter((event) => !event.recurrenceId);
if (masters.length > 1)
diagnostics.push({
severity: "error",
code: "duplicate-master",
message: `UID ${uid} has ${masters.length} master VEVENT components.`,
uid,
});
if (!masters.length && group.some((event) => event.recurrenceId))
diagnostics.push({
severity: "warning",
code: "orphan-override",
message: `UID ${uid} contains recurrence overrides without a master event.`,
uid,
});
}
}
function expandOccurrences(
events: IcsEventInspection[],
limit: number,
diagnostics: IcsDiagnostic[],
): IcsOccurrence[] {
const result: IcsOccurrence[] = [];
const groups = new Map<string, IcsEventInspection[]>();
for (const event of events) {
const group = groups.get(event.uid) ?? [];
group.push(event);
groups.set(event.uid, group);
}
for (const [uid, group] of groups) {
if (result.length >= limit) break;
const master = group.find((event) => !event.recurrenceId);
if (!master?.start) continue;
if (group.some((event) => event.recurrenceRange === "THISANDFUTURE")) {
diagnostics.push({
severity: "warning",
code: "this-and-future-withheld",
message: `UID ${uid} uses RECURRENCE-ID;RANGE=THISANDFUTURE. The components are retained, but occurrence expansion is withheld rather than applying incomplete range semantics.`,
uid,
});
continue;
}
const overrides = new Map(
group
.filter((event) => event.recurrenceId)
.map((event) => [dateKey(event.recurrenceId!), event]),
);
const excluded = new Set(master.exdates.map(dateKey));
let candidates: Array<{ key: string; start: string }> = [];
if (master.rrule) {
const executableNamedZone =
master.start.timeZone !== "floating" && master.start.instant;
const executableFloating = master.start.timeZone === "floating";
if (executableNamedZone || executableFloating) {
try {
const previewZone = executableNamedZone
? master.start.timeZone
: "UTC";
const previewLocal =
master.start.valueType === "date"
? `${master.start.local}T00:00:00`
: master.start.local;
candidates = previewRRule(
master.rrule,
previewLocal,
previewZone,
Math.min(
limit - result.length + excluded.size + overrides.size,
1_000,
),
).map((occurrence) => {
if (executableNamedZone)
return { key: occurrence.instant, start: occurrence.zoned };
const local = occurrence.zoned.slice(
0,
master.start?.valueType === "date" ? 10 : 19,
);
return {
key: `floating:${local}`,
start: `${local}${master.start?.valueType === "date" ? " (all-day)" : " (floating)"}`,
};
});
} catch (reason) {
diagnostics.push({
severity: "error",
code: "rrule-expansion",
message: `UID ${uid} RRULE could not be expanded: ${reason instanceof Error ? reason.message : "unknown error"}`,
uid,
});
}
} else
diagnostics.push({
severity: "warning",
code: "rrule-timezone",
message: `UID ${uid} recurrence is retained but not expanded because DTSTART has no executable time zone.`,
uid,
});
} else
candidates.push({
key: dateKey(master.start),
start: displayDate(master.start),
});
for (const rdate of master.rdates)
candidates.push({ key: dateKey(rdate), start: displayDate(rdate) });
const seen = new Set<string>();
candidates.sort((left, right) => left.key.localeCompare(right.key));
for (const candidate of candidates) {
if (
result.length >= limit ||
seen.has(candidate.key) ||
excluded.has(candidate.key)
)
continue;
seen.add(candidate.key);
const override = overrides.get(candidate.key);
if (override?.status === "CANCELLED") continue;
result.push({
uid,
summary: override?.summary ?? master.summary,
scheduled: candidate.start,
start: override?.start ? displayDate(override.start) : candidate.start,
source: override ? "override" : "master",
status: override?.status ?? master.status,
});
}
}
return result.sort((left, right) => left.start.localeCompare(right.start));
}
function dateKey(value: IcsDateValue): string {
return value.instant ?? `${value.timeZone}:${value.local}`;
}
function displayDate(value: IcsDateValue): string {
return (
value.instant ??
`${value.local}${value.timeZone === "floating" ? " (floating)" : `[${value.timeZone}]`}`
);
}
function unescapeText(value: string): string {
let output = "";
for (let index = 0; index < value.length; index += 1) {
if (value[index] !== "\\") {
output += value[index];
continue;
}
const next = value[index + 1];
if (next === "n" || next === "N") output += "\n";
else if (next === "\\" || next === ";" || next === ",") output += next;
else output += next ?? "\\";
index += next === undefined ? 0 : 1;
}
return output;
}
+1 -1
View File
@@ -87,7 +87,7 @@ export function createUtcEvent(input: EventInput): {
const lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//add ideas//Time Tools 0.1.0//EN",
"PRODID:-//add ideas//Time Tools 0.2.0//EN",
"CALSCALE:GREGORIAN",
"BEGIN:VEVENT",
`UID:${escapeText(uid)}`,
+175
View File
@@ -0,0 +1,175 @@
import { assertBoundedText } from "@add-ideas/toolbox-helpers";
import { Temporal } from "temporal-polyfill";
import { parseInstant } from "./epoch";
export interface MeetingParticipant {
readonly name: string;
readonly timeZone: string;
readonly workStart: string;
readonly workEnd: string;
readonly includeWeekends?: boolean;
}
export interface MeetingCandidate {
readonly index: number;
readonly start: string;
readonly end: string;
readonly participants: ReadonlyArray<{
name: string;
timeZone: string;
startLocal: string;
endLocal: string;
offset: string;
}>;
}
export interface MeetingPlan {
readonly searchedFrom: string;
readonly searchedUntil: string;
readonly durationMinutes: number;
readonly stepMinutes: number;
readonly candidates: readonly MeetingCandidate[];
readonly exhausted: boolean;
readonly note: string;
}
function clockMinutes(valueInput: string, label: string): number {
const value = assertBoundedText(valueInput, 16, label).trim();
const match = /^(\d{2}):(\d{2})$/u.exec(value);
if (!match) throw new SyntaxError(`${label} must use HH:MM.`);
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (hours > 23 || minutes > 59)
throw new RangeError(`${label} is outside the 00:0023:59 range.`);
return hours * 60 + minutes;
}
function positiveInteger(
value: number,
minimum: number,
maximum: number,
label: string,
): number {
if (!Number.isSafeInteger(value) || value < minimum || value > maximum)
throw new RangeError(`${label} must be between ${minimum} and ${maximum}.`);
return value;
}
export function planMeetings(input: {
start: string;
days: number;
durationMinutes: number;
stepMinutes: number;
participants: readonly MeetingParticipant[];
maxResults?: number;
}): MeetingPlan {
const days = positiveInteger(input.days, 1, 31, "Search days");
const durationMinutes = positiveInteger(
input.durationMinutes,
5,
1_440,
"Meeting duration",
);
const stepMinutes = positiveInteger(input.stepMinutes, 5, 240, "Search step");
const maxResults = positiveInteger(
input.maxResults ?? 24,
1,
100,
"Result count",
);
if (input.participants.length === 0 || input.participants.length > 24)
throw new RangeError("Meeting planning requires 124 participants.");
const participants = input.participants.map((participant, index) => {
const name = assertBoundedText(
participant.name || `Participant ${index + 1}`,
128,
"Participant name",
).trim();
const timeZone = assertBoundedText(
participant.timeZone,
128,
"Participant time zone",
).trim();
Temporal.Now.instant().toZonedDateTimeISO(timeZone);
const workStart = clockMinutes(participant.workStart, "Work start");
const workEnd = clockMinutes(participant.workEnd, "Work end");
if (workEnd <= workStart)
throw new RangeError(
`${name}'s work window must end after it starts on the same local day.`,
);
return { ...participant, name, timeZone, workStart, workEnd };
});
const start = parseInstant(input.start);
const stepNanoseconds = BigInt(stepMinutes) * 60_000_000_000n;
const durationNanoseconds = BigInt(durationMinutes) * 60_000_000_000n;
const end = start.add({ hours: days * 24 });
const remainder = start.epochNanoseconds % stepNanoseconds;
let cursor =
remainder === 0n
? start
: Temporal.Instant.fromEpochNanoseconds(
start.epochNanoseconds + stepNanoseconds - remainder,
);
const candidates: MeetingCandidate[] = [];
let inspected = 0;
const maximumCandidates = Math.ceil((days * 24 * 60) / stepMinutes) + 1;
while (
Temporal.Instant.compare(cursor, end) < 0 &&
inspected < maximumCandidates &&
candidates.length < maxResults
) {
inspected += 1;
const candidateEnd = Temporal.Instant.fromEpochNanoseconds(
cursor.epochNanoseconds + durationNanoseconds,
);
const local = participants.map((participant) => {
const localStart = cursor.toZonedDateTimeISO(participant.timeZone);
const localEnd = candidateEnd.toZonedDateTimeISO(participant.timeZone);
const startMinutes = localStart.hour * 60 + localStart.minute;
const endMinutes = localEnd.hour * 60 + localEnd.minute;
const sameDay = localStart.toPlainDate().equals(localEnd.toPlainDate());
const weekdayAllowed =
participant.includeWeekends === true || localStart.dayOfWeek <= 5;
return {
allowed:
sameDay &&
weekdayAllowed &&
startMinutes >= participant.workStart &&
endMinutes <= participant.workEnd,
name: participant.name,
timeZone: participant.timeZone,
startLocal: localStart.toString(),
endLocal: localEnd.toString(),
offset: localStart.offset,
};
});
if (local.every((participant) => participant.allowed))
candidates.push({
index: candidates.length + 1,
start: cursor.toString(),
end: candidateEnd.toString(),
participants: local.map((participant) =>
Object.freeze({
name: participant.name,
timeZone: participant.timeZone,
startLocal: participant.startLocal,
endLocal: participant.endLocal,
offset: participant.offset,
}),
),
});
cursor = Temporal.Instant.fromEpochNanoseconds(
cursor.epochNanoseconds + stepNanoseconds,
);
}
return Object.freeze({
searchedFrom: start.toString(),
searchedUntil: end.toString(),
durationMinutes,
stepMinutes,
candidates: Object.freeze(candidates),
exhausted: candidates.length < maxResults,
note: "Availability is inferred only from the entered recurring local work windows. Calendars, holidays, travel, and personal availability are not consulted.",
});
}
+138 -3
View File
@@ -1,5 +1,5 @@
import { assertBoundedText } from "@add-ideas/toolbox-helpers";
import { Cron, type CronMode } from "croner";
import { Cron, type CronMode, type CronOptions } from "croner";
import { RRuleTemporal } from "rrule-temporal";
import { Temporal } from "temporal-polyfill";
@@ -10,6 +10,20 @@ export interface RecurrenceOccurrence {
offset: string;
}
export type CronDialect =
"unix-vixie" | "github-actions" | "croner" | "quartz" | "aws-eventbridge";
export interface CronDialectPreview {
dialect: CronDialect;
inputPattern: string;
normalizedPattern: string;
fields: readonly string[];
dayCombination: "or" | "exclusive-question-mark";
timeZone: string;
warnings: readonly string[];
occurrences: RecurrenceOccurrence[];
}
function boundedCount(value: number): number {
const count = Math.trunc(value);
if (!Number.isSafeInteger(count) || count < 1 || count > 1_000)
@@ -32,11 +46,23 @@ export function previewCron(
const timeZone = assertBoundedText(timeZoneInput, 128, "Time zone").trim();
const count = boundedCount(countInput);
const start = Temporal.Instant.from(startInput);
return runCron(pattern, start, timeZone, count, {
mode,
domAndDow: false,
});
}
function runCron(
pattern: string,
start: Temporal.Instant,
timeZone: string,
count: number,
options: Pick<CronOptions, "mode" | "domAndDow" | "alternativeWeekdays">,
): RecurrenceOccurrence[] {
const cron = new Cron(pattern, {
paused: true,
timezone: timeZone,
mode,
legacyMode: true,
...options,
});
return cron
.nextRuns(count, new Date(start.epochMilliseconds))
@@ -52,6 +78,115 @@ export function previewCron(
});
}
function cronFields(pattern: string): string[] {
const fields = pattern.split(/\s+/u).filter(Boolean);
if (fields.length > 7)
throw new SyntaxError("Cron has more than seven fields.");
return fields;
}
function rejectPortableExtensions(pattern: string, dialect: string): void {
if (/[?LW#+]/iu.test(pattern))
throw new SyntaxError(
`${dialect} preview rejects ?, L, W, # and + because they are not portable in this dialect.`,
);
}
export function previewCronDialect(
patternInput: string,
startInput: string,
timeZoneInput: string,
countInput: number,
dialect: CronDialect,
): CronDialectPreview {
const inputPattern = assertBoundedText(
patternInput,
512,
"Cron expression",
).trim();
if (!inputPattern) throw new SyntaxError("Cron expression is required.");
let normalizedPattern = inputPattern;
let fields = cronFields(inputPattern);
let mode: CronMode;
let alternativeWeekdays = false;
const warnings: string[] = [];
const timeZone = assertBoundedText(timeZoneInput, 128, "Time zone").trim();
let dayCombination: CronDialectPreview["dayCombination"] = "or";
if (dialect === "unix-vixie" || dialect === "github-actions") {
if (fields.length !== 5)
throw new SyntaxError(`${dialect} requires exactly five fields.`);
rejectPortableExtensions(inputPattern, dialect);
mode = "5-part";
if (dialect === "github-actions") {
if (timeZone !== "UTC")
throw new RangeError("GitHub Actions schedule preview uses UTC only.");
warnings.push(
"GitHub Actions has a five-minute minimum schedule interval and can delay or drop scheduled jobs under service load. This shows theoretical matching minutes and does not normalize or promise delivery.",
);
} else
warnings.push(
"Vixie-derived implementations differ on environment time-zone directives and DST behavior; this preview applies the explicitly selected IANA zone.",
);
} else if (dialect === "croner") {
if (![5, 6, 7].includes(fields.length))
throw new SyntaxError(
"Croner syntax requires five, six, or seven fields.",
);
mode = `${fields.length}-part` as CronMode;
warnings.push(
"Croner extensions (?, L, W, # and +) and its documented DST behavior are applied; other schedulers may differ.",
);
} else {
const expected = dialect === "quartz" ? [6, 7] : [6];
if (!expected.includes(fields.length))
throw new SyntaxError(
dialect === "quartz"
? "Quartz requires six fields plus an optional year."
: "AWS EventBridge requires six fields: minute hour day-of-month month day-of-week year.",
);
const dayOfMonth = fields[dialect === "quartz" ? 3 : 2];
const dayOfWeek = fields[dialect === "quartz" ? 5 : 4];
if ((dayOfMonth === "?") === (dayOfWeek === "?"))
throw new SyntaxError(
`${dialect} requires ? in exactly one of day-of-month or day-of-week.`,
);
dayCombination = "exclusive-question-mark";
alternativeWeekdays = true;
if (dialect === "aws-eventbridge") {
normalizedPattern = `0 ${inputPattern}`;
fields = ["0", ...fields];
mode = "7-part";
warnings.push(
"AWS EventBridge schedules use UTC and service-specific rate/delivery behavior. This preview evaluates only the compatible cron subset locally.",
);
if (timeZone !== "UTC")
throw new RangeError("AWS EventBridge cron preview uses UTC only.");
} else {
mode = `${cronFields(normalizedPattern).length}-part` as CronMode;
warnings.push(
"Quartz calendars, misfire policies and scheduler-specific extensions are outside this preview.",
);
}
}
const count = boundedCount(countInput);
const start = Temporal.Instant.from(startInput);
return {
dialect,
inputPattern,
normalizedPattern,
fields,
dayCombination,
timeZone,
warnings,
occurrences: runCron(normalizedPattern, start, timeZone, count, {
mode,
domAndDow: false,
alternativeWeekdays,
}),
};
}
export function previewRRule(
ruleInput: string,
startLocalInput: string,