diff --git a/bun.lock b/bun.lock index dc44810f..214b7f4d 100644 --- a/bun.lock +++ b/bun.lock @@ -138,6 +138,7 @@ "name": "@repo/api", "version": "0.1.0", "dependencies": { + "@analog/ical": "workspace:*", "@analog/meeting-links": "workspace:*", "@googlemaps/places": "^2.1.0", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -248,6 +249,23 @@ "typescript": "^5.9.2", }, }, + "packages/ical": { + "name": "@analog/ical", + "version": "0.1.0", + "dependencies": { + "ts-ics": "^2.2.0", + }, + "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@types/node": "^22.9.0", + "eslint": "^9.30.1", + "typescript": "^5.8.3", + }, + "peerDependencies": { + "temporal-polyfill": "^0.3.0", + }, + }, "packages/meeting-links": { "name": "@analog/meeting-links", "version": "0.1.0", @@ -308,6 +326,8 @@ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@analog/ical": ["@analog/ical@workspace:packages/ical"], + "@analog/meeting-links": ["@analog/meeting-links@workspace:packages/meeting-links"], "@ariakit/core": ["@ariakit/core@0.4.15", "", {}, "sha512-vvxmZvkNhiisKM+Y1TbGMUfVVchV/sWu9F0xw0RYADXcimWPK31dd9JnIZs/OQ5pwAryAHmERHwuGQVESkSjwQ=="], @@ -2938,6 +2958,8 @@ "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + "ts-ics": ["ts-ics@2.2.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0" } }, "sha512-m8oA9vOpQW3lG85YJiARtStjT/ef/XT7FQ6l1gkX+65mFj1P9Ws8cwdn+FJ9P89K1P5xbrHIFtu7lOFRUgzd/g=="], + "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], diff --git a/packages/api/package.json b/packages/api/package.json index 7bb3e491..7611c565 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -34,6 +34,7 @@ "@repo/auth": "workspace:*", "@repo/db": "workspace:*", "@repo/env": "workspace:*", + "@analog/ical": "workspace:*", "@repo/google-calendar": "workspace:*", "@repo/google-tasks": "workspace:*", "@repo/temporal": "workspace:*", diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 439fd1d5..cc241150 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -7,6 +7,7 @@ import { conferencingRouter } from "./routers/conferencing"; import { earlyAccessRouter } from "./routers/early-access"; import { eventsRouter } from "./routers/events"; import { freeBusyRouter } from "./routers/free-busy"; +import { icsRouter } from "./routers/ics"; import { placesRouter } from "./routers/places"; import { tasksRouter } from "./routers/tasks"; import { userRouter } from "./routers/user"; @@ -27,6 +28,7 @@ export const appRouter = createTRPCRouter({ conferencing: conferencingRouter, earlyAccess: earlyAccessRouter, places: placesRouter, + ics: icsRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/api/src/routers/ics.ts b/packages/api/src/routers/ics.ts new file mode 100644 index 00000000..6126f1b3 --- /dev/null +++ b/packages/api/src/routers/ics.ts @@ -0,0 +1,70 @@ +import { importCalendar, importEvent, isCalendar, isEvent } from "@analog/ical"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +import { createTRPCRouter, publicProcedure } from "../trpc"; + +const FETCH_TIMEOUT = 10000; + +export const icsRouter = createTRPCRouter({ + parseFromUrl: publicProcedure + .input( + z.object({ + url: z.url(), + }), + ) + .query(async ({ input }) => { + const response = await fetch(input.url, { + signal: AbortSignal.timeout(FETCH_TIMEOUT), + }); + + if (!response.ok) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Failed to fetch ICS file: ${response.status} ${response.statusText}`, + }); + } + + const content = await response.text(); + + if (!content.trim()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "ICS file is empty", + }); + } + + try { + if (isCalendar(content)) { + const calendar = importCalendar(content); + return { + type: "calendar", + events: calendar.events, + }; + } + + if (isEvent(content)) { + const event = importEvent(content); + return { + type: "event", + events: [event], + }; + } + + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid ICS content: not a VCALENDAR or VEVENT", + }); + } catch (error) { + if (error instanceof TRPCError) { + throw error; + } + + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to process ICS file", + cause: error instanceof Error ? error.message : "Unknown error", + }); + } + }), +}); diff --git a/packages/ical/eslint.config.mjs b/packages/ical/eslint.config.mjs new file mode 100644 index 00000000..9b56f932 --- /dev/null +++ b/packages/ical/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@repo/eslint-config/base"; + +/** @type {import("eslint").Linter.Config} */ +export default config; diff --git a/packages/ical/package.json b/packages/ical/package.json new file mode 100644 index 00000000..a0fca2b8 --- /dev/null +++ b/packages/ical/package.json @@ -0,0 +1,25 @@ +{ + "name": "@analog/ical", + "version": "0.1.0", + "license": "Apache-2.0", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "lint": "eslint .", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "ts-ics": "^2.2.0" + }, + "peerDependencies": { + "temporal-polyfill": "^0.3.0" + }, + "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@types/node": "^22.9.0", + "eslint": "^9.30.1", + "typescript": "^5.8.3" + } +} diff --git a/packages/ical/src/export.ts b/packages/ical/src/export.ts new file mode 100644 index 00000000..4bb28a70 --- /dev/null +++ b/packages/ical/src/export.ts @@ -0,0 +1,127 @@ +import { Temporal } from "temporal-polyfill"; +import { + generateIcsCalendar, + generateIcsEvent, + generateIcsTimezone, + type IcsAttendee, + type IcsCalendar, + type IcsDateObject, + type IcsEvent, + type IcsTimezone, +} from "ts-ics"; + +import type { Attendee } from "@repo/api/interfaces"; + +import type { iCalendarEvent } from "./interfaces"; + +function formatOffset(offset: string): string { + return offset.replace(":", ""); +} + +function toDate(value: iCalendarEvent["start"]): Date { + if (value instanceof Temporal.PlainDate) { + return new Date(value.toString()); + } + + if (value instanceof Temporal.Instant) { + return new Date(value.epochMilliseconds); + } + + return new Date(value.toInstant().epochMilliseconds); +} + +function toAttendee(attendee: Attendee): IcsAttendee { + const result: IcsAttendee = { + email: attendee.email ?? "", + }; + + if (attendee.name) { + result.name = attendee.name; + } + + if (attendee.status) { + if (attendee.status === "accepted") { + result.partstat = "ACCEPTED"; + } else if (attendee.status === "declined") { + result.partstat = "DECLINED"; + } else if (attendee.status === "tentative") { + result.partstat = "TENTATIVE"; + } else { + result.partstat = "NEEDS-ACTION"; + } + } + + if (attendee.type) { + if (attendee.type === "optional") { + result.role = "OPT-PARTICIPANT"; + } else if (attendee.type === "resource") { + result.role = "NON-PARTICIPANT"; + } else { + result.role = "REQ-PARTICIPANT"; + } + } + + return result; +} + +function toIcsEvent(event: iCalendarEvent): IcsEvent { + const start: IcsDateObject = { + date: toDate(event.start), + type: event.allDay ? "DATE" : "DATE-TIME", + }; + + const end: IcsDateObject = { + date: toDate(event.end), + type: event.allDay ? "DATE" : "DATE-TIME", + }; + + if (!event.allDay) { + if (event.start instanceof Temporal.ZonedDateTime) { + start.local = { + date: start.date, + timezone: event.start.timeZoneId, + tzoffset: formatOffset(event.start.offset), + }; + } + + if (event.end instanceof Temporal.ZonedDateTime) { + end.local = { + date: end.date, + timezone: event.end.timeZoneId, + tzoffset: formatOffset(event.end.offset), + }; + } + } + + return { + uid: event.id, + summary: event.title ?? "", + stamp: { date: new Date() }, + start, + end, + ...(event.description && { description: event.description }), + ...(event.location && { location: event.location }), + ...(event.url && { url: event.url }), + ...(event.attendees && + event.attendees.length > 0 && { + attendees: event.attendees.map(toAttendee), + }), + }; +} + +export function exportEvent(event: iCalendarEvent): string { + return generateIcsEvent(toIcsEvent(event)); +} + +export function exportEvents(events: iCalendarEvent[]): string { + const calendar: IcsCalendar = { + prodId: "@analog/ical", + version: "2.0", + events: events.map(toIcsEvent), + }; + return generateIcsCalendar(calendar); +} + +export function exportTimezone(timezone: IcsTimezone): string { + return generateIcsTimezone(timezone); +} diff --git a/packages/ical/src/import.ts b/packages/ical/src/import.ts new file mode 100644 index 00000000..69be93a1 --- /dev/null +++ b/packages/ical/src/import.ts @@ -0,0 +1,204 @@ +import { Temporal } from "temporal-polyfill"; +import { + convertIcsCalendar, + convertIcsEvent, + getEventEnd, + type IcsAttendee, + type IcsDateObject, + type IcsEvent, +} from "ts-ics"; + +import type { Attendee } from "@repo/api/interfaces"; + +import type { iCalendar, iCalendarEvent } from "./interfaces"; + +function toTemporal(dateObj: IcsDateObject): iCalendarEvent["start"] { + if (dateObj.type === "DATE") { + return Temporal.PlainDate.from(dateObj.date.toISOString().slice(0, 10)); + } + + if (dateObj.local) { + const instant = Temporal.Instant.from(dateObj.date.toISOString()); + return instant.toZonedDateTimeISO(dateObj.local.timezone); + } + + return Temporal.Instant.from(dateObj.date.toISOString()); +} + +function attendeeStatus(partstat: IcsAttendee["partstat"]): Attendee["status"] { + if (partstat === "ACCEPTED") { + return "accepted"; + } else if (partstat === "DECLINED") { + return "declined"; + } else if (partstat === "TENTATIVE") { + return "tentative"; + } + + return "unknown"; +} + +function attendeeType(role: IcsAttendee["role"]): Attendee["type"] { + if (role === "OPT-PARTICIPANT") { + return "optional"; + } else if (role === "NON-PARTICIPANT") { + return "resource"; + } + + return "required"; +} + +function fromAttendee(attendee: IcsAttendee): Attendee { + const status = attendeeStatus(attendee.partstat); + + const type = attendeeType(attendee.role); + + return { + email: attendee.email, + name: attendee.name, + status, + type, + }; +} + +function computeEndDateObject(event: IcsEvent): IcsDateObject | undefined { + if (event.end) { + return event.end; + } + + if (!event.duration) { + return undefined; + } + + return { + date: getEventEnd(event), + type: event.start.type, + ...(event.start.local && { + local: { + date: getEventEnd(event), + timezone: event.start.local.timezone, + // tzoffset is not used by our converter; reuse start offset + tzoffset: event.start.local.tzoffset, + }, + }), + }; +} + +function mapAttendees(event: IcsEvent): Attendee[] { + const attendees = (event.attendees?.map(fromAttendee) ?? []).slice(); + if (event.organizer?.email) { + const idx = attendees.findIndex( + (a) => a.email.toLowerCase() === event.organizer!.email.toLowerCase(), + ); + if (idx >= 0) { + attendees[idx] = { ...attendees[idx]!, organizer: true }; + } else { + attendees.unshift({ + email: event.organizer.email, + name: event.organizer.name, + status: "unknown", + type: "required", + organizer: true, + }); + } + } + return attendees; +} + +function mapAvailability( + event: IcsEvent, +): iCalendarEvent["availability"] | undefined { + if (event.timeTransparent === "TRANSPARENT") { + return "free"; + } + + if (event.timeTransparent === "OPAQUE") { + return "busy"; + } + + return undefined; +} + +function mapVisibility( + event: IcsEvent, +): iCalendarEvent["visibility"] | undefined { + if (event.class) { + return event.class.toLowerCase() as iCalendarEvent["visibility"]; + } + + return undefined; +} + +function mapRecurrence(event: IcsEvent): iCalendarEvent["recurrence"] { + if (!event.recurrenceRule) { + return undefined; + } + + return { + freq: event.recurrenceRule.frequency, + interval: event.recurrenceRule.interval, + count: event.recurrenceRule.count, + until: event.recurrenceRule.until + ? toTemporal(event.recurrenceRule.until) + : undefined, + bySecond: event.recurrenceRule.bySecond, + byMinute: event.recurrenceRule.byMinute, + byHour: event.recurrenceRule.byHour, + byDay: event.recurrenceRule.byDay?.map((wd) => wd.day), + byMonthDay: event.recurrenceRule.byMonthday, + byYearDay: event.recurrenceRule.byYearday, + byWeekNo: event.recurrenceRule.byWeekNo, + byMonth: event.recurrenceRule.byMonth, + bySetPos: event.recurrenceRule.bySetPos, + wkst: event.recurrenceRule.workweekStart, + exDate: event.exceptionDates?.map((d) => toTemporal(d)), + // rDate not provided by ts-ics VEVENT parser + }; +} + +function fromIcsEvent(event: IcsEvent): iCalendarEvent { + const endDateObject = computeEndDateObject(event); + const attendees = mapAttendees(event); + const availability = mapAvailability(event); + const visibility = mapVisibility(event); + const recurrence = mapRecurrence(event); + + return { + id: event.uid, + title: event.summary, + description: event.description, + start: toTemporal(event.start), + end: endDateObject ? toTemporal(endDateObject) : toTemporal(event.start), + allDay: event.start.type === "DATE", + location: event.location, + status: event.status?.toLowerCase(), + attendees, + url: event.url, + availability, + visibility, + ...(recurrence ? { recurrence } : {}), + color: undefined, + readOnly: false, + }; +} + +export function importEvent(ics: string): iCalendarEvent { + const event = convertIcsEvent(undefined, ics); + return fromIcsEvent(event); +} + +export function importCalendar(ics: string): iCalendar { + const calendar = convertIcsCalendar(undefined, ics); + + return { + name: calendar.name, + events: calendar.events?.map(fromIcsEvent) ?? [], + }; +} + +export function isCalendar(ics: string): boolean { + return ics.trim().toUpperCase().includes("BEGIN:VCALENDAR"); +} + +export function isEvent(ics: string): boolean { + return ics.trim().toUpperCase().includes("BEGIN:VEVENT"); +} diff --git a/packages/ical/src/index.ts b/packages/ical/src/index.ts new file mode 100644 index 00000000..f2a9f8d6 --- /dev/null +++ b/packages/ical/src/index.ts @@ -0,0 +1,4 @@ +// Re-export all functions and types +export * from "./import"; +export * from "./export"; +export type { iCalendarEvent } from "./interfaces"; diff --git a/packages/ical/src/interfaces.ts b/packages/ical/src/interfaces.ts new file mode 100644 index 00000000..a0cc5f4a --- /dev/null +++ b/packages/ical/src/interfaces.ts @@ -0,0 +1,11 @@ +import type { CalendarEvent } from "@repo/api/interfaces"; + +export type iCalendarEvent = Omit< + CalendarEvent, + "providerId" | "accountId" | "calendarId" +>; + +export interface iCalendar { + name?: string; + events: iCalendarEvent[]; +} diff --git a/packages/ical/tsconfig.json b/packages/ical/tsconfig.json new file mode 100644 index 00000000..d57de3de --- /dev/null +++ b/packages/ical/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@repo/typescript-config/base.json", + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +}