mirror of
https://github.com/SrIzan10/full-calendar-ics-converter.git
synced 2026-06-06 00:47:04 +00:00
feat: 1.0.0 commit
This commit is contained in:
38
.github/workflows/build.yml
vendored
Normal file
38
.github/workflows/build.yml
vendored
Normal 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
3
.gitignore
vendored
@@ -86,3 +86,6 @@ typings/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
converted/
|
||||
calendar.ics
|
||||
60
index.ts
Normal file
60
index.ts
Normal 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
21
package.json
Normal 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
27
tsconfig.json
Normal 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
3
utils/getAverage.ts
Normal 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
153
utils/ics.ts
Normal 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] : []));
|
||||
}
|
||||
7
utils/jsonToFrontmatter.ts
Normal file
7
utils/jsonToFrontmatter.ts
Normal 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
3
utils/removeLastSlash.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function removeLastSlash(str: string): string {
|
||||
return str.replace(/\/$/, "");
|
||||
}
|
||||
75
utils/types/calendar_settings.ts
Normal file
75
utils/types/calendar_settings.ts
Normal 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
29
utils/types/index.ts
Normal 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
136
utils/types/schema.ts
Normal 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(),
|
||||
});
|
||||
Reference in New Issue
Block a user