diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..c3b2ecf --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,38 @@ +name: Build and Upload Artifacts + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - uses: oven-sh/setup-bun@v1 + + - name: Build for Linux x64 + run: bun build --compile --target=bun-linux-x64 ./index.ts --outfile mycli-linux-x64 + + - name: Build for Linux ARM64 + run: bun build --compile --target=bun-linux-arm64 ./index.ts --outfile mycli-linux-arm64 + + - name: Build for Windows x64 + run: bun build --compile --target=bun-windows-x64 ./index.ts --outfile mycli-windows-x64.exe + + - name: Build for macOS x64 + run: bun build --compile --target=bun-darwin-x64 ./index.ts --outfile mycli-macos-x64 + + - name: Build for macOS arm64 + run: bun build --compile --target=bun-darwin-arm64 ./index.ts --outfile mycli-macos-arm64 + + - name: Upload Artifacts + uses: actions/upload-artifact@v2 + with: + name: mycli + path: | + mycli-linux-x64 + mycli-linux-arm64 + mycli-windows-x64.exe + mycli-macos-x64 + mycli-macos-arm64 \ No newline at end of file diff --git a/.gitignore b/.gitignore index ed0d3c8..91cfaf8 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,6 @@ typings/ # DynamoDB Local files .dynamodb/ + +converted/ +calendar.ics \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..973949f Binary files /dev/null and b/bun.lockb differ diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..6e757ed --- /dev/null +++ b/index.ts @@ -0,0 +1,60 @@ +const initialPerf = performance.now() +import { getEventsFromICS } from './utils/ics' +import jsonToFrontmatter from './utils/jsonToFrontmatter' +import { parseArgs } from 'util' +import { argsSchema } from "./utils/types/schema"; +import removeLastSlash from "./utils/removeLastSlash"; +import getAverage from './utils/getAverage'; + +const { values } = parseArgs({ + args: Bun.argv, + options: { + out: { + type: 'string' + }, + ics: { + type: 'string' + } + }, + strict: true, + allowPositionals: true, +}) + +const parsedValues = argsSchema.safeParse(values) +if (!parsedValues.success) { + console.error(parsedValues.error) + process.exit(1) +} + +const file = Bun.file(parsedValues.data.ics) + +const parsed = getEventsFromICS(await file.text()) + +const events = parsed.map(event => { + const [ics, uid, date, type] = event.id!.split('::') + return { + fileName: `${date} ${event.title.trim()}.md`, + frontmatterContent: jsonToFrontmatter(event) + } +}) + +const writePerfArray: number[] = [] +for (const event of events) { + if (writePerfArray.length > 0) { + process.stdout.moveCursor(0, -1) // up one line + process.stdout.clearLine(1) + } + console.log(`[#${writePerfArray.length + 1}] Writing ${event.fileName}`) + + const initialWritePerf = performance.now() + + await Bun.write(`${removeLastSlash(parsedValues.data.out)}/${event.fileName}`, event.frontmatterContent) + + const finalWritePerf = performance.now() + writePerfArray.push(finalWritePerf - initialWritePerf) +} + +const finalPerf = performance.now() + +console.log(`[DONE] Finished in ${(finalPerf - initialPerf).toFixed(2)}ms`) +console.log(`[DONE] Average write time: ${(getAverage(writePerfArray) * 1000).toFixed(2)}µs`) \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..a904d2a --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "full-calendar-ics-converter", + "version": "1.0.0", + "module": "index.ts", + "type": "module", + "devDependencies": { + "@types/bun": "latest", + "@types/ical": "^0.8.3", + "@types/luxon": "^3.4.2" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "ical.js": "^2.0.1", + "luxon": "^3.4.4", + "node-ical": "^0.18.0", + "rrule": "^2.8.1", + "zod": "^3.23.8" + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/utils/getAverage.ts b/utils/getAverage.ts new file mode 100644 index 0000000..30e9dd8 --- /dev/null +++ b/utils/getAverage.ts @@ -0,0 +1,3 @@ +export default function getAverage(arr: number[]): number { + return arr.reduce((a, b) => a + b, 0) / arr.length; +} \ No newline at end of file diff --git a/utils/ics.ts b/utils/ics.ts new file mode 100644 index 0000000..74f052e --- /dev/null +++ b/utils/ics.ts @@ -0,0 +1,153 @@ +// @ts-nocheck +import ical from "ical.js"; +import { type OFCEvent, validateEvent } from "./types"; +import { DateTime } from "luxon"; +import { rrulestr } from "rrule"; + +function getDate(t: ical.Time): string { + return DateTime.fromSeconds(t.toUnixTime(), { zone: "UTC" }).toISODate(); +} + +function getTime(t: ical.Time): string { + if (t.isDate) { + return "00:00"; + } + return DateTime.fromSeconds(t.toUnixTime(), { zone: "UTC" }).toISOTime({ + includeOffset: false, + includePrefix: false, + suppressMilliseconds: true, + suppressSeconds: true, + }); +} + +function extractEventUrl(iCalEvent: ical.Event): string { + let urlProp = iCalEvent.component.getFirstProperty("url"); + return urlProp ? urlProp.getFirstValue() : ""; +} + +function specifiesEnd(iCalEvent: ical.Event) { + return ( + Boolean(iCalEvent.component.getFirstProperty("dtend")) || + Boolean(iCalEvent.component.getFirstProperty("duration")) + ); +} + +function icsToOFC(input: ical.Event): OFCEvent { + if (input.isRecurring()) { + const rrule = rrulestr( + input.component.getFirstProperty("rrule").getFirstValue().toString() + ); + const allDay = input.startDate.isDate; + const exdates = input.component + .getAllProperties("exdate") + .map((exdateProp) => { + const exdate = exdateProp.getFirstValue(); + // NOTE: We only store the date from an exdate and recreate the full datetime exdate later, + // so recurring events with exclusions that happen more than once per day are not supported. + return getDate(exdate); + }); + + return { + type: "rrule", + title: input.summary, + id: `ics::${input.uid}::${getDate(input.startDate)}::recurring`, + rrule: rrule.toString(), + skipDates: exdates, + startDate: getDate( + input.startDate.convertToZone(ical.Timezone.utcTimezone) + ), + ...(allDay + ? { allDay: true } + : { + allDay: false, + startTime: getTime( + input.startDate.convertToZone( + ical.Timezone.utcTimezone + ) + ), + endTime: getTime( + input.endDate.convertToZone(ical.Timezone.utcTimezone) + ), + }), + }; + } else { + const date = getDate(input.startDate); + const endDate = + specifiesEnd(input) && input.endDate + ? getDate(input.endDate) + : undefined; + const allDay = input.startDate.isDate; + return { + type: "single", + id: `ics::${input.uid}::${date}::single`, + title: input.summary, + date, + endDate: date !== endDate ? endDate || null : null, + ...(allDay + ? { allDay: true } + : { + allDay: false, + startTime: getTime(input.startDate), + endTime: getTime(input.endDate), + }), + }; + } +} + +export function getEventsFromICS(text: string): OFCEvent[] { + const jCalData = ical.parse(text); + const component = new ical.Component(jCalData); + + // TODO: Timezone support + // const tzc = component.getAllSubcomponents("vtimezone"); + // const tz = new ical.Timezone(tzc[0]); + + const events: ical.Event[] = component + .getAllSubcomponents("vevent") + .map((vevent) => new ical.Event(vevent)) + .filter((evt) => { + evt.iterator; + try { + evt.startDate.toJSDate(); + evt.endDate.toJSDate(); + return true; + } catch (err) { + // skipping events with invalid time + return false; + } + }); + + // Events with RECURRENCE-ID will have duplicated UIDs. + // We need to modify the base event to exclude those recurrence exceptions. + const baseEvents = Object.fromEntries( + events + .filter((e) => e.recurrenceId === null) + .map((e) => [e.uid, icsToOFC(e)]) + ); + + const recurrenceExceptions = events + .filter((e) => e.recurrenceId !== null) + .map((e): [string, OFCEvent] => [e.uid, icsToOFC(e)]); + + for (const [uid, event] of recurrenceExceptions) { + const baseEvent = baseEvents[uid]; + if (!baseEvent) { + continue; + } + + if (baseEvent.type !== "rrule" || event.type !== "single") { + console.warn( + "Recurrence exception was recurring or base event was not recurring", + { baseEvent, recurrenceException: event } + ); + continue; + } + baseEvent.skipDates.push(event.date); + } + + const allEvents = Object.values(baseEvents).concat( + recurrenceExceptions.map((e) => e[1]) + ); + + return allEvents.map(validateEvent).flatMap((e) => (e ? [e] : [])); +} diff --git a/utils/jsonToFrontmatter.ts b/utils/jsonToFrontmatter.ts new file mode 100644 index 0000000..792d8d2 --- /dev/null +++ b/utils/jsonToFrontmatter.ts @@ -0,0 +1,7 @@ +export default function jsonToFrontmatter(json: any): string { + return `--- +${Object.entries(json) + .map(([key, value]) => `${key}: ${JSON.stringify(value)}`) + .join("\n")} +---`; +} \ No newline at end of file diff --git a/utils/removeLastSlash.ts b/utils/removeLastSlash.ts new file mode 100644 index 0000000..286e99c --- /dev/null +++ b/utils/removeLastSlash.ts @@ -0,0 +1,3 @@ +export default function removeLastSlash(str: string): string { + return str.replace(/\/$/, ""); +} \ No newline at end of file diff --git a/utils/types/calendar_settings.ts b/utils/types/calendar_settings.ts new file mode 100644 index 0000000..c3837cd --- /dev/null +++ b/utils/types/calendar_settings.ts @@ -0,0 +1,75 @@ +import { ZodError, z } from "zod"; +import type { OFCEvent } from "./schema"; + +const calendarOptionsSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("local"), directory: z.string() }), + z.object({ type: z.literal("dailynote"), heading: z.string() }), + z.object({ type: z.literal("ical"), url: z.string().url() }), + z.object({ + type: z.literal("caldav"), + name: z.string(), + url: z.string().url(), + homeUrl: z.string().url(), + username: z.string(), + password: z.string(), + }), +]); + +const colorValidator = z.object({ color: z.string() }); + +export type TestSource = { + type: "FOR_TEST_ONLY"; + id: string; + events?: OFCEvent[]; +}; + +export type CalendarInfo = ( + | z.infer + | TestSource +) & + z.infer; + +export function parseCalendarInfo(obj: unknown): CalendarInfo { + const options = calendarOptionsSchema.parse(obj); + const color = colorValidator.parse(obj); + + return { ...options, ...color }; +} + +export function safeParseCalendarInfo(obj: unknown): CalendarInfo | null { + try { + return parseCalendarInfo(obj); + } catch (e) { + if (e instanceof ZodError) { + console.debug("Parsing calendar info failed with errors", { + obj, + error: e.message, + }); + } + return null; + } +} + +/** + * Construct a partial calendar source of the specified type + */ +export function makeDefaultPartialCalendarSource( + type: CalendarInfo["type"] | "icloud" +): Partial { + if (type === "icloud") { + return { + type: "caldav", + color: getComputedStyle(document.body) + .getPropertyValue("--interactive-accent") + .trim(), + url: "https://caldav.icloud.com", + }; + } + + return { + type: type, + color: getComputedStyle(document.body) + .getPropertyValue("--interactive-accent") + .trim(), + }; +} diff --git a/utils/types/index.ts b/utils/types/index.ts new file mode 100644 index 0000000..460bb43 --- /dev/null +++ b/utils/types/index.ts @@ -0,0 +1,29 @@ +import type { CalendarInfo } from "./calendar_settings"; + +export type { OFCEvent } from "./schema"; +export { validateEvent } from "./schema"; + +export { makeDefaultPartialCalendarSource } from "./calendar_settings"; +export type { CalendarInfo } from "./calendar_settings"; + +export const PLUGIN_SLUG = "full-calendar-plugin"; + +export class FCError { + message: string; + constructor(message: string) { + this.message = message; + } +} + +export type EventLocation = { + file: { path: string }; + lineNumber: number | undefined; +}; + +export type Authentication = { + type: "basic"; + username: string; + password: string; +}; + +export type CalDAVSource = Extract; diff --git a/utils/types/schema.ts b/utils/types/schema.ts new file mode 100644 index 0000000..c89e787 --- /dev/null +++ b/utils/types/schema.ts @@ -0,0 +1,136 @@ +import { z, ZodError } from "zod"; +import { DateTime, Duration } from "luxon"; + +const stripTime = (date: DateTime) => { + // Strip time from luxon dateTime. + return DateTime.fromObject( + { + year: date.year, + month: date.month, + day: date.day, + }, + { zone: "utc" } + ); +}; + +export const ParsedDate = z.string(); +// z.string().transform((val, ctx) => { +// const parsed = DateTime.fromISO(val, { zone: "utc" }); +// if (parsed.invalidReason) { +// ctx.addIssue({ +// code: z.ZodIssueCode.custom, +// message: parsed.invalidReason, +// }); +// return z.NEVER; +// } +// return stripTime(parsed); +// }); + +export const ParsedTime = z.string(); +// z.string().transform((val, ctx) => { +// let parsed = DateTime.fromFormat(val, "h:mm a"); +// if (parsed.invalidReason) { +// parsed = DateTime.fromFormat(val, "HH:mm"); +// } + +// if (parsed.invalidReason) { +// ctx.addIssue({ +// code: z.ZodIssueCode.custom, +// message: parsed.invalidReason, +// }); +// return z.NEVER; +// } + +// return Duration.fromISOTime( +// parsed.toISOTime({ +// includeOffset: false, +// includePrefix: false, +// }) +// ); +// }); + +export const TimeSchema = z.discriminatedUnion("allDay", [ + z.object({ allDay: z.literal(true) }), + z.object({ + allDay: z.literal(false), + startTime: ParsedTime, + endTime: ParsedTime.nullable().default(null), + }), +]); + +export const CommonSchema = z.object({ + title: z.string(), + id: z.string().optional(), +}); + +export const EventSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("single"), + date: ParsedDate, + endDate: ParsedDate.nullable().default(null), + completed: ParsedDate.or(z.literal(false)) + .or(z.literal(null)) + .optional(), + }), + z.object({ + type: z.literal("recurring"), + daysOfWeek: z.array(z.enum(["U", "M", "T", "W", "R", "F", "S"])), + startRecur: ParsedDate.optional(), + endRecur: ParsedDate.optional(), + }), + z.object({ + type: z.literal("rrule"), + startDate: ParsedDate, + rrule: z.string(), + skipDates: z.array(ParsedDate), + }), +]); + +type EventType = z.infer; +type TimeType = z.infer; +type CommonType = z.infer; + +export type OFCEvent = CommonType & TimeType & EventType; + +export function parseEvent(obj: unknown): OFCEvent { + if (typeof obj !== "object") { + throw new Error("value for parsing was not an object."); + } + const objectWithDefaults = { type: "single", allDay: false, ...obj }; + return { + ...CommonSchema.parse(objectWithDefaults), + ...TimeSchema.parse(objectWithDefaults), + ...EventSchema.parse(objectWithDefaults), + }; +} + +export function validateEvent(obj: unknown): OFCEvent | null { + try { + return parseEvent(obj); + } catch (e) { + if (e instanceof ZodError) { + console.debug("Parsing failed with errors", { + obj, + message: e.message, + }); + } + return null; + } +} +type Json = + | { [key: string]: Json } + | Json[] + | string + | number + | true + | false + | null; + +export function serializeEvent(obj: OFCEvent): Json { + return { ...obj }; +} + +export const argsSchema = z.object({ + out: z.string(), + ics: z.string(), +}); \ No newline at end of file