+188
-30
@@ -1,18 +1,30 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
|
||||
import type { CronMode } from "croner";
|
||||
import { Temporal } from "temporal-polyfill";
|
||||
import { addBusinessDays, compareArithmetic } from "../time/arithmetic";
|
||||
import { inspectEpoch, type EpochUnit } from "../time/epoch";
|
||||
import { createUtcEvent } from "../time/ics";
|
||||
import { previewCron, previewRRule } from "../time/recurrence";
|
||||
import { inspectIcs } from "../time/ics-inspect";
|
||||
import { planMeetings } from "../time/meeting";
|
||||
import {
|
||||
previewCronDialect,
|
||||
previewRRule,
|
||||
type CronDialect,
|
||||
} from "../time/recurrence";
|
||||
import {
|
||||
compareTimeZones,
|
||||
nextTransitions,
|
||||
resolveLocalDateTime,
|
||||
} from "../time/zones";
|
||||
|
||||
type Tab = "epoch" | "zones" | "arithmetic" | "recurrence" | "business" | "ics";
|
||||
type Tab =
|
||||
| "epoch"
|
||||
| "zones"
|
||||
| "meeting"
|
||||
| "arithmetic"
|
||||
| "recurrence"
|
||||
| "business"
|
||||
| "ics";
|
||||
interface Output {
|
||||
title: string;
|
||||
text: string;
|
||||
@@ -23,10 +35,11 @@ interface Output {
|
||||
const tabs: ReadonlyArray<readonly [Tab, string]> = [
|
||||
["epoch", "Epoch & ISO"],
|
||||
["zones", "Time zones & DST"],
|
||||
["meeting", "Meeting planner"],
|
||||
["arithmetic", "Arithmetic"],
|
||||
["recurrence", "Cron & RRULE"],
|
||||
["business", "Business days"],
|
||||
["ics", "ICS event"],
|
||||
["ics", "iCalendar"],
|
||||
];
|
||||
|
||||
function initialInstant(): string {
|
||||
@@ -101,7 +114,7 @@ export function Workbench() {
|
||||
"cron",
|
||||
);
|
||||
const [cron, setCron] = useState("0 9 * * MON-FRI");
|
||||
const [cronMode, setCronMode] = useState<CronMode>("5-part");
|
||||
const [cronDialect, setCronDialect] = useState<CronDialect>("unix-vixie");
|
||||
const [rrule, setRrule] = useState("FREQ=WEEKLY;COUNT=12;BYDAY=MO,WE,FR");
|
||||
const [occurrenceCount, setOccurrenceCount] = useState(12);
|
||||
const [businessDate, setBusinessDate] = useState(() =>
|
||||
@@ -113,6 +126,15 @@ export function Workbench() {
|
||||
const [description, setDescription] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
const [eventDuration, setEventDuration] = useState("PT1H");
|
||||
const [meetingPeople, setMeetingPeople] = useState(
|
||||
"Berlin|Europe/Berlin|09:00|17:00\nNew York|America/New_York|09:00|17:00",
|
||||
);
|
||||
const [meetingDays, setMeetingDays] = useState(7);
|
||||
const [meetingDuration, setMeetingDuration] = useState(60);
|
||||
const [meetingStep, setMeetingStep] = useState(30);
|
||||
const [icsSource, setIcsSource] = useState(
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Example//EN\r\nBEGIN:VEVENT\r\nUID:example@local\r\nDTSTART;TZID=Europe/Berlin:20260901T090000\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:Example recurrence\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
|
||||
);
|
||||
|
||||
const zoneList = useMemo(
|
||||
() =>
|
||||
@@ -326,6 +348,91 @@ export function Workbench() {
|
||||
</>
|
||||
);
|
||||
|
||||
const meetingWorkspace = (
|
||||
<>
|
||||
<label className="field">
|
||||
<span>Participants: name|IANA zone|work start|work end</span>
|
||||
<textarea
|
||||
className="short"
|
||||
value={meetingPeople}
|
||||
onChange={(event) => setMeetingPeople(event.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span>Search from instant</span>
|
||||
<input
|
||||
value={instant}
|
||||
onChange={(event) => setInstant(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Days (max 31)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
value={meetingDays}
|
||||
onChange={(event) => setMeetingDays(event.target.valueAsNumber)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Duration minutes</span>
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
max="1440"
|
||||
value={meetingDuration}
|
||||
onChange={(event) => setMeetingDuration(event.target.valueAsNumber)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Search step minutes</span>
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
max="240"
|
||||
value={meetingStep}
|
||||
onChange={(event) => setMeetingStep(event.target.valueAsNumber)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
className="primary"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
run("Meeting candidates", () => {
|
||||
if (meetingPeople.length > 32_768)
|
||||
throw new Error("Participant input exceeds 32,768 UTF-16 units.");
|
||||
const participants = meetingPeople
|
||||
.split(/\r?\n/u)
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => {
|
||||
const [name = "", timeZone = "", workStart = "", workEnd = ""] =
|
||||
line.split("|");
|
||||
return { name, timeZone, workStart, workEnd };
|
||||
});
|
||||
const value = planMeetings({
|
||||
start: instant,
|
||||
days: meetingDays,
|
||||
durationMinutes: meetingDuration,
|
||||
stepMinutes: meetingStep,
|
||||
participants,
|
||||
});
|
||||
return { value, note: value.note };
|
||||
})
|
||||
}
|
||||
>
|
||||
Find shared work-hour slots
|
||||
</button>
|
||||
<p className="muted">
|
||||
Work windows recur Monday–Friday in each participant's local zone. This
|
||||
planner does not read calendars or infer holidays.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
const recurrenceWorkspace = (
|
||||
<>
|
||||
<div className="segmented" role="group" aria-label="Recurrence syntax">
|
||||
@@ -355,17 +462,20 @@ export function Workbench() {
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Dialect / fields</span>
|
||||
<span>Cron dialect</span>
|
||||
<select
|
||||
value={cronMode}
|
||||
value={cronDialect}
|
||||
onChange={(event) =>
|
||||
setCronMode(event.target.value as CronMode)
|
||||
setCronDialect(event.target.value as CronDialect)
|
||||
}
|
||||
>
|
||||
<option value="5-part">5 fields</option>
|
||||
<option value="6-part">6 fields</option>
|
||||
<option value="7-part">7 fields</option>
|
||||
<option value="auto">Auto-detect</option>
|
||||
<option value="unix-vixie">Unix / Vixie (5 fields)</option>
|
||||
<option value="github-actions">GitHub Actions (UTC)</option>
|
||||
<option value="croner">Croner extended (5/6/7)</option>
|
||||
<option value="quartz">Quartz (6/7)</option>
|
||||
<option value="aws-eventbridge">
|
||||
AWS EventBridge (UTC, 6)
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
@@ -388,14 +498,14 @@ export function Workbench() {
|
||||
type="button"
|
||||
onClick={() =>
|
||||
run("Cron occurrence preview", () => ({
|
||||
value: previewCron(
|
||||
value: previewCronDialect(
|
||||
cron,
|
||||
instant,
|
||||
zone,
|
||||
occurrenceCount,
|
||||
cronMode,
|
||||
cronDialect,
|
||||
),
|
||||
note: "Day-of-month and day-of-week use common Unix OR semantics. Choose the field count explicitly when portability matters.",
|
||||
note: "The selected dialect controls field positions, weekday numbering, day-of-month/day-of-week rules and time-zone restrictions. Service delivery behavior is outside this local preview.",
|
||||
}))
|
||||
}
|
||||
>
|
||||
@@ -513,6 +623,57 @@ export function Workbench() {
|
||||
|
||||
const icsWorkspace = (
|
||||
<>
|
||||
<div>
|
||||
<p className="eyebrow">Import and inspect</p>
|
||||
<h3>Existing .ics calendar</h3>
|
||||
</div>
|
||||
<label className="button file-button">
|
||||
Open .ics file
|
||||
<input
|
||||
type="file"
|
||||
accept=".ics,text/calendar"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 8 * 1024 * 1024) {
|
||||
setError("iCalendar file exceeds the 8 MiB import limit.");
|
||||
return;
|
||||
}
|
||||
void file.text().then((value) => {
|
||||
setIcsSource(value);
|
||||
run("iCalendar inspection", () => ({
|
||||
value: inspectIcs(value, occurrenceCount),
|
||||
note: "URLs and attachments are reported as inert data and are never fetched, opened, decoded, or rendered.",
|
||||
}));
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Paste iCalendar source</span>
|
||||
<textarea
|
||||
value={icsSource}
|
||||
onChange={(event) => setIcsSource(event.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="primary"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
run("iCalendar inspection", () => ({
|
||||
value: inspectIcs(icsSource, occurrenceCount),
|
||||
note: "Multiple VEVENTs, declared VTIMEZONEs, recurrence exclusions and moved/cancelled overrides are inspected. URLs and attachments remain inert and are never fetched or rendered.",
|
||||
}))
|
||||
}
|
||||
>
|
||||
Validate and inspect calendar
|
||||
</button>
|
||||
<hr />
|
||||
<div>
|
||||
<p className="eyebrow">Create</p>
|
||||
<h3>Portable UTC event</h3>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span>Start local wall time</span>
|
||||
@@ -595,13 +756,15 @@ export function Workbench() {
|
||||
? epochWorkspace
|
||||
: tab === "zones"
|
||||
? zonesWorkspace
|
||||
: tab === "arithmetic"
|
||||
? arithmeticWorkspace
|
||||
: tab === "recurrence"
|
||||
? recurrenceWorkspace
|
||||
: tab === "business"
|
||||
? businessWorkspace
|
||||
: icsWorkspace;
|
||||
: tab === "meeting"
|
||||
? meetingWorkspace
|
||||
: tab === "arithmetic"
|
||||
? arithmeticWorkspace
|
||||
: tab === "recurrence"
|
||||
? recurrenceWorkspace
|
||||
: tab === "business"
|
||||
? businessWorkspace
|
||||
: icsWorkspace;
|
||||
return (
|
||||
<main className="workbench">
|
||||
<header className="hero">
|
||||
@@ -611,21 +774,16 @@ export function Workbench() {
|
||||
<p>
|
||||
Convert exact timestamps, inspect time-zone edge cases, compare
|
||||
arithmetic, preview recurrences, and create portable calendar
|
||||
events.
|
||||
events, and safely inspect existing iCalendar files.
|
||||
</p>
|
||||
</div>
|
||||
<span className="privacy-pill">No network requests</span>
|
||||
</header>
|
||||
<nav
|
||||
className="panel workspace-tabs"
|
||||
role="tablist"
|
||||
aria-label="Time workspaces"
|
||||
>
|
||||
<nav className="panel workspace-tabs" aria-label="Time workspaces">
|
||||
{tabs.map(([value, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === value}
|
||||
aria-pressed={tab === value}
|
||||
key={value}
|
||||
onClick={() => setTab(value)}
|
||||
>
|
||||
|
||||
+1
-1
@@ -149,7 +149,7 @@ textarea.short {
|
||||
overflow-x: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.workspace-tabs button[aria-selected="true"],
|
||||
.workspace-tabs button[aria-pressed="true"],
|
||||
.segmented button[aria-pressed="true"] {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent);
|
||||
|
||||
@@ -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
@@ -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)}`,
|
||||
|
||||
@@ -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:00–23: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 1–24 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
@@ -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,
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.time-tools",
|
||||
"name": "Time Tools",
|
||||
"version": "0.1.0",
|
||||
"description": "Work with dates, time zones and recurrences locally in the browser.",
|
||||
"version": "0.2.0",
|
||||
"description": "Plan across time zones and preview explicit recurrence dialects locally.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["time", "developer", "productivity"],
|
||||
"tags": ["timestamp", "timezone", "cron", "rrule", "ics"],
|
||||
"tags": ["timestamp", "timezone", "meeting", "cron", "rrule", "ics"],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
@@ -21,9 +21,26 @@
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"io": {
|
||||
"accepts": [
|
||||
{
|
||||
"mediaType": "text/calendar",
|
||||
"extensions": [".ics"],
|
||||
"label": "iCalendar"
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
{
|
||||
"mediaType": "text/calendar",
|
||||
"extensions": [".ics"],
|
||||
"label": "iCalendar event"
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": { "required": [], "optional": ["clipboard-write"] },
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": false,
|
||||
"fileUploads": true,
|
||||
"telemetry": false,
|
||||
"label": "Inputs stay in this browser; nothing is uploaded."
|
||||
},
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.2.0";
|
||||
|
||||
Reference in New Issue
Block a user