import type { CalendarComponentType } from "@/worker/db/types"; import type { DateValue, ParsedComponent, ParsedProperty } from "@/worker/caldav/ical/types"; export const supportedComponentTypes = new Set(["VEVENT", "VTODO", "VJOURNAL"]); export const dayMs = 24 * 60 * 60 * 1000; export const weekdayByName: Record = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 }; export function byteLength(value: string): number { return new TextEncoder().encode(value).byteLength; } export function decodeUtf8(bytes: ArrayBuffer): string { return new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes); } export function normalizeLineEndings(text: string): string { return `${text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n+$/g, "").split("\n").join("\r\n")}\r\n`; } export function unfoldLines(body: string): string[] { const lines = body.replace(/\r\n/g, "\n").split("\n"); const unfolded: string[] = []; for (const line of lines) { if ((line.startsWith(" ") || line.startsWith("\t")) && unfolded.length > 0) { unfolded[unfolded.length - 1] += line.slice(1); } else if (line !== "") { unfolded.push(line); } } return unfolded; } export function parseProperty(line: string): ParsedProperty | null { const colon = line.indexOf(":"); if (colon === -1) return null; const head = line.slice(0, colon); const value = line.slice(colon + 1); const [rawName, ...rawParams] = head.split(";"); if (!rawName) return null; const params: Record = {}; for (const rawParam of rawParams) { const equals = rawParam.indexOf("="); if (equals === -1) continue; params[rawParam.slice(0, equals).toUpperCase()] = rawParam.slice(equals + 1); } return { name: rawName.toUpperCase(), params, value, rawLine: line }; } export function firstProperty(properties: ParsedProperty[], name: string): ParsedProperty | null { return properties.find((prop) => prop.name === name) ?? null; } export function propertiesByName(properties: ParsedProperty[], name: string): ParsedProperty[] { return properties.filter((prop) => prop.name === name); } export interface DateParseOptions { floatingTimeZone?: string | null; } export function parseDateValue(prop: ParsedProperty | null, options: DateParseOptions = {}): DateValue | null { if (!prop) return null; const value = prop.value; const date = /^(\d{4})(\d{2})(\d{2})$/.exec(value); if (date) { const local = { year: Number(date[1]), month: Number(date[2]), day: Number(date[3]), hour: 0, minute: 0, second: 0, }; return { raw: value, valueType: "date", ms: options.floatingTimeZone ? zonedDateTimeToUtcMs(options.floatingTimeZone, local) : Date.UTC(local.year, local.month - 1, local.day), floating: true, }; } const dateTime = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/.exec(value); if (!dateTime) throw new Error(`Invalid iCalendar date value for ${prop.name}`); const utcMs = Date.UTC( Number(dateTime[1]), Number(dateTime[2]) - 1, Number(dateTime[3]), Number(dateTime[4]), Number(dateTime[5]), Number(dateTime[6]), ); const floating = dateTime[7] !== "Z" && !prop.params.TZID; return { raw: value, valueType: "date-time", ms: dateTime[7] === "Z" ? utcMs : zonedDateTimeToUtcMs(prop.params.TZID ?? options.floatingTimeZone ?? "UTC", { year: Number(dateTime[1]), month: Number(dateTime[2]), day: Number(dateTime[3]), hour: Number(dateTime[4]), minute: Number(dateTime[5]), second: Number(dateTime[6]), }), floating, }; } function zonedDateTimeToUtcMs( timeZone: string, local: { year: number; month: number; day: number; hour: number; minute: number; second: number }, ): number { let guess = Date.UTC(local.year, local.month - 1, local.day, local.hour, local.minute, local.second); let formatter: Intl.DateTimeFormat; try { formatter = new Intl.DateTimeFormat("en-US", { timeZone, hour12: false, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", }); } catch { throw new Error(`Unsupported iCalendar TZID ${timeZone}`); } const desired = Date.UTC(local.year, local.month - 1, local.day, local.hour, local.minute, local.second); for (let index = 0; index < 4; index += 1) { const parts = Object.fromEntries(formatter.formatToParts(new Date(guess)).map((part) => [part.type, part.value])); const actual = Date.UTC( Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) % 24, Number(parts.minute), Number(parts.second), ); const diff = actual - desired; if (diff === 0) return guess; guess -= diff; } return guess; } export function parseDateList(prop: ParsedProperty, options: DateParseOptions = {}): DateValue[] { return prop.value .split(",") .map((value) => parseDateValue({ ...prop, value: value.includes("/") ? value.slice(0, value.indexOf("/")) : value }, options), ) .filter((value): value is DateValue => value !== null); } export function parseDuration(value: string): number | null { const match = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/i.exec(value); if (!match) return null; const weeks = Number(match[1] ?? 0); const days = Number(match[2] ?? 0); const hours = Number(match[3] ?? 0); const minutes = Number(match[4] ?? 0); const seconds = Number(match[5] ?? 0); if (weeks + days + hours + minutes + seconds === 0) return 0; return (((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 * 1000 + seconds * 1000; } export function recurrenceBase(component: ParsedComponent): DateValue | null { return component.dtstart ?? component.due ?? component.recurrenceId; } export function componentDurationMs(component: ParsedComponent): number { if (component.componentType === "VEVENT") { if (component.dtstart && component.dtend) return Math.max(0, component.dtend.ms - component.dtstart.ms); if (component.durationMs !== null) return Math.max(0, component.durationMs); return component.dtstart?.valueType === "date" ? dayMs : 0; } if (component.componentType === "VJOURNAL") { return component.dtstart?.valueType === "date" ? dayMs : 0; } if (component.dtstart && component.due) return Math.max(0, component.due.ms - component.dtstart.ms); if (component.durationMs !== null) return Math.max(0, component.durationMs); return 0; } export function propertyValues(component: ParsedComponent, name: string): string[] { return propertiesByName(component.properties, name.toUpperCase()).map((prop) => prop.value); } export function isCancelled(component: ParsedComponent): boolean { return propertyValues(component, "STATUS").some((value) => value.toUpperCase() === "CANCELLED"); } export function documentHasRecurrence(components: ParsedComponent[]): boolean { return ( components.length > 1 || components.some((component) => component.properties.some((prop) => prop.name === "RRULE" || prop.name === "RDATE" || prop.name === "EXDATE"), ) ); }