feat: 1.0.0 commit

This commit is contained in:
2024-06-15 23:55:23 +02:00
parent b5e9b0124a
commit cffce45d0c
13 changed files with 555 additions and 0 deletions

38
.github/workflows/build.yml vendored Normal file
View File

@@ -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

3
.gitignore vendored
View File

@@ -86,3 +86,6 @@ typings/
# DynamoDB Local files
.dynamodb/
converted/
calendar.ics

BIN
bun.lockb Executable file

Binary file not shown.

60
index.ts Normal file
View File

@@ -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`)

21
package.json Normal file
View File

@@ -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"
}
}

27
tsconfig.json Normal file
View File

@@ -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
}
}

3
utils/getAverage.ts Normal file
View File

@@ -0,0 +1,3 @@
export default function getAverage(arr: number[]): number {
return arr.reduce((a, b) => a + b, 0) / arr.length;
}

153
utils/ics.ts Normal file
View File

@@ -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] : []));
}

View File

@@ -0,0 +1,7 @@
export default function jsonToFrontmatter(json: any): string {
return `---
${Object.entries(json)
.map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
.join("\n")}
---`;
}

3
utils/removeLastSlash.ts Normal file
View File

@@ -0,0 +1,3 @@
export default function removeLastSlash(str: string): string {
return str.replace(/\/$/, "");
}

View File

@@ -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<typeof calendarOptionsSchema>
| TestSource
) &
z.infer<typeof colorValidator>;
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<CalendarInfo> {
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(),
};
}

29
utils/types/index.ts Normal file
View File

@@ -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<CalendarInfo, { type: "caldav" }>;

136
utils/types/schema.ts Normal file
View File

@@ -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<typeof EventSchema>;
type TimeType = z.infer<typeof TimeSchema>;
type CommonType = z.infer<typeof CommonSchema>;
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(),
});