From 9d8f866b78f2a353ae5ff6db4bb28bb036cfd436 Mon Sep 17 00:00:00 2001 From: Davis Haupt Date: Fri, 14 Apr 2023 15:30:31 -0400 Subject: [PATCH] Add Zod for parsing event data (OFC-1) (#331) --- .github/workflows/check.yml | 4 +- package-lock.json | 90 +++- package.json | 7 +- src/README.md | 4 +- src/calendars/DailyNoteCalendar.ts | 21 +- src/calendars/FullNoteCalendar.test.ts | 158 ++++--- .../parsing/__snapshots__/ics.test.ts.snap | 11 +- src/calendars/parsing/ics.ts | 2 +- src/core/EventCache.test.ts | 4 +- src/main.ts | 2 - src/types/calendar_settings.ts | 75 ++++ src/types/index.ts | 155 +------ src/types/schema.test.ts | 422 ++++++++++++++++++ src/types/schema.ts | 131 ++++++ src/types/validation.test.ts | 27 -- src/types/validation.ts | 87 ---- src/ui/components/AddCalendarSource.tsx | 6 +- src/ui/components/CalendarSetting.tsx | 2 +- src/ui/components/EditEvent.tsx | 45 +- src/ui/interop.ts | 7 +- src/ui/settings.ts | 5 +- src/ui/view.ts | 2 +- 22 files changed, 872 insertions(+), 395 deletions(-) create mode 100644 src/types/calendar_settings.ts create mode 100644 src/types/schema.test.ts create mode 100644 src/types/schema.ts delete mode 100644 src/types/validation.test.ts delete mode 100644 src/types/validation.ts diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 31fbf55b..f3832263 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -11,7 +11,7 @@ jobs: - uses: actions/setup-node@v2 with: node-version: "16.x" - - run: npm ci + - run: npm ci --legacy-peer-deps - run: npm run lint - run: npm run compile tests: @@ -23,5 +23,5 @@ jobs: - uses: actions/setup-node@v2 with: node-version: "16.x" - - run: npm ci + - run: npm ci --legacy-peer-deps - run: npm run test diff --git a/package-lock.json b/package-lock.json index 0cc13651..3b98744b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,8 @@ "obsidian-daily-notes-interface": "^0.9.4", "react": "^17.0.2", "react-dom": "^17.0.2", - "rrule": "^2.7.2" + "rrule": "^2.7.2", + "zod": "^3.21.4" }, "devDependencies": { "@types/co": "^4.6.3", @@ -45,13 +46,15 @@ "@typescript-eslint/parser": "^5.2.0", "builtin-modules": "^3.2.0", "esbuild": "0.13.12", + "fast-check": "^3.8.0", "husky": "^7.0.4", "jest": "^29.3.1", "obsidian": "^0.16.3", "prettier": "^2.5.1", "ts-jest": "^29.0.3", "tslib": "2.3.1", - "typescript": "4.4.4" + "typescript": "4.4.4", + "zod-fast-check": "^0.9.0" } }, "node_modules/@ampproject/remapping": { @@ -4245,6 +4248,28 @@ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, + "node_modules/fast-check": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.8.0.tgz", + "integrity": "sha512-Mz6k/+bton0iMXdIaqG/ow81oUCYxIDtmGUvf1Q/9O5QhiLI6T9JaCNEr4Y6LfMOpY/jBUfo8hK+3qd5sGfTfw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "dependencies": { + "pure-rand": "^6.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8013,6 +8038,22 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.1.tgz", + "integrity": "sha512-t+x1zEHDjBwkDGY5v5ApnZ/utcd4XYDiJsaQQoptTXgUXX95sDg1elCdJghzicm7n2mbCBJ3uYWr6M22SO19rg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, "node_modules/query-string": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/query-string/-/query-string-8.1.0.tgz", @@ -9557,6 +9598,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz", + "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-fast-check": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/zod-fast-check/-/zod-fast-check-0.9.0.tgz", + "integrity": "sha512-7N56zNAO7HabbIETlCofd8e94ZRBulo9gx8qAC5AC+yTVo++WUKqi21fNyIWHtM46Xl0iTT7ucz0blMoCTz5jg==", + "dev": true, + "peerDependencies": { + "fast-check": "^2.23.0", + "zod": "^3.18.0" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", @@ -12499,6 +12558,15 @@ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, + "fast-check": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.8.0.tgz", + "integrity": "sha512-Mz6k/+bton0iMXdIaqG/ow81oUCYxIDtmGUvf1Q/9O5QhiLI6T9JaCNEr4Y6LfMOpY/jBUfo8hK+3qd5sGfTfw==", + "dev": true, + "requires": { + "pure-rand": "^6.0.0" + } + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -15145,6 +15213,12 @@ "dev": true, "peer": true }, + "pure-rand": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.1.tgz", + "integrity": "sha512-t+x1zEHDjBwkDGY5v5ApnZ/utcd4XYDiJsaQQoptTXgUXX95sDg1elCdJghzicm7n2mbCBJ3uYWr6M22SO19rg==", + "dev": true + }, "query-string": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/query-string/-/query-string-8.1.0.tgz", @@ -16183,6 +16257,18 @@ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true }, + "zod": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz", + "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==" + }, + "zod-fast-check": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/zod-fast-check/-/zod-fast-check-0.9.0.tgz", + "integrity": "sha512-7N56zNAO7HabbIETlCofd8e94ZRBulo9gx8qAC5AC+yTVo++WUKqi21fNyIWHtM46Xl0iTT7ucz0blMoCTz5jg==", + "dev": true, + "requires": {} + }, "zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index dd692b91..e466f11d 100644 --- a/package.json +++ b/package.json @@ -35,13 +35,15 @@ "@typescript-eslint/parser": "^5.2.0", "builtin-modules": "^3.2.0", "esbuild": "0.13.12", + "fast-check": "^3.8.0", "husky": "^7.0.4", "jest": "^29.3.1", "obsidian": "^0.16.3", "prettier": "^2.5.1", "ts-jest": "^29.0.3", "tslib": "2.3.1", - "typescript": "4.4.4" + "typescript": "4.4.4", + "zod-fast-check": "^0.9.0" }, "dependencies": { "@fullcalendar/core": "^5.10.1", @@ -65,6 +67,7 @@ "obsidian-daily-notes-interface": "^0.9.4", "react": "^17.0.2", "react-dom": "^17.0.2", - "rrule": "^2.7.2" + "rrule": "^2.7.2", + "zod": "^3.21.4" } } diff --git a/src/README.md b/src/README.md index a144b421..af1507f3 100644 --- a/src/README.md +++ b/src/README.md @@ -49,9 +49,9 @@ Following the advice in [this blog post on architecture docs](https://matklad.gi This module defines some common types used throughout the code. The most prevalent is `OFCEvent`, short for Obsidian Full Calendar Event, that specifies the intermediate representation for all events in the plugin. Note that FullCalendar.io uses a different event format called `EventInput`, which you can read about [in their documentation](https://fullcalendar.io/docs/event-parsing). -Translation between `OFCEvent` and `EventInput` is handled in `interop.ts`. Each `Calendar` subclass (see below) handles its own translation between its source format and `OFCEvent`. +`OFCEvent` is derived from a [Zod parser](https://github.com/colinhacks/zod) that handles parsing/validating JavaScript objects into the expected shape of an event. You can check out the parser in `types/schema.ts`. -Objects can be validated as OFCEvents using `validateEvent()` . This function is used throughout the code to ensure that only valid events are present. +Translation between `OFCEvent` and `EventInput` is handled in `interop.ts`. Each `Calendar` subclass (see below) handles its own translation from its source format into `OFCEvent`. ### `core` diff --git a/src/calendars/DailyNoteCalendar.ts b/src/calendars/DailyNoteCalendar.ts index 2b05556e..944e0616 100644 --- a/src/calendars/DailyNoteCalendar.ts +++ b/src/calendars/DailyNoteCalendar.ts @@ -17,13 +17,7 @@ import { } from "obsidian-daily-notes-interface"; import { EventPathLocation } from "../core/EventStore"; import { ObsidianInterface } from "../ObsidianAdapter"; -import { - OFCEvent, - EventLocation, - CalendarInfo, - validateEvent, - SingleEventData, -} from "../types"; +import { OFCEvent, EventLocation, CalendarInfo, validateEvent } from "../types"; import { EventResponse } from "./Calendar"; import { EditableCalendar, EditableEventResponse } from "./EditableCalendar"; @@ -161,9 +155,12 @@ const generateInlineAttributes = (attrs: Record): string => { }; const makeListItem = ( - data: SingleEventData, + data: OFCEvent, whitespacePrefix: string = "" ): string => { + if (data.type !== "single") { + throw new Error("Can only pass in single event."); + } const { completed, title } = data; const checkbox = (() => { if (completed !== null && completed !== undefined) { @@ -172,13 +169,13 @@ const makeListItem = ( return null; })(); - const attrs: Partial = { ...data }; + const attrs: Partial = { ...data }; delete attrs["completed"]; delete attrs["title"]; delete attrs["type"]; delete attrs["date"]; - for (const key of <(keyof SingleEventData)[]>Object.keys(attrs)) { + for (const key of <(keyof OFCEvent)[]>Object.keys(attrs)) { if (attrs[key] === undefined || attrs[key] === null) { delete attrs[key]; } @@ -193,7 +190,7 @@ const makeListItem = ( } ${title} ${generateInlineAttributes(attrs)}`; }; -const modifyListItem = (line: string, data: SingleEventData): string | null => { +const modifyListItem = (line: string, data: OFCEvent): string | null => { const listMatch = line.match(listRegex); if (!listMatch) { console.warn( @@ -213,7 +210,7 @@ const modifyListItem = (line: string, data: SingleEventData): string | null => { // TODO: refactor this to not do the weird props thing type AddToHeadingProps = { heading: HeadingCache | undefined; - item: SingleEventData; + item: OFCEvent; headingText: string; }; const addToHeading = ( diff --git a/src/calendars/FullNoteCalendar.test.ts b/src/calendars/FullNoteCalendar.test.ts index 04fdb517..85cbef44 100644 --- a/src/calendars/FullNoteCalendar.test.ts +++ b/src/calendars/FullNoteCalendar.test.ts @@ -6,6 +6,7 @@ import { MockApp, MockAppBuilder } from "../../test_helpers/AppBuilder"; import { FileBuilder } from "../../test_helpers/FileBuilder"; import { OFCEvent } from "src/types"; import FullNoteCalendar from "./FullNoteCalendar"; +import { parseEvent } from "../types/schema"; async function assertFailed(func: () => Promise, message: RegExp) { try { @@ -104,63 +105,69 @@ describe("Note Calendar Tests", () => { }, ], ], - ])("%p", async (_, inputs: { title: string; event: OFCEvent }[]) => { - const obsidian = makeApp( - MockAppBuilder.make() - .folder( - inputs.reduce( - (builder, { title, event }) => - builder.file( - title, - new FileBuilder().frontmatter(event) - ), - new MockAppBuilder(dirName) + ])( + "%p", + async (_, inputs: { title: string; event: Partial }[]) => { + const obsidian = makeApp( + MockAppBuilder.make() + .folder( + inputs.reduce( + (builder, { title, event }) => + builder.file( + title, + new FileBuilder().frontmatter(event) + ), + new MockAppBuilder(dirName) + ) ) - ) - .done() - ); - const calendar = new FullNoteCalendar(obsidian, color, dirName); - const res = await calendar.getEvents(); - expect(res.length).toBe(inputs.length); - const events = res.map((e) => e[0]); - const paths = res.map((e) => e[1].file.path); + .done() + ); + const calendar = new FullNoteCalendar(obsidian, color, dirName); + const res = await calendar.getEvents(); + expect(res.length).toBe(inputs.length); + const events = res.map((e) => e[0]); + const paths = res.map((e) => e[1].file.path); - expect( - res.every((elt) => elt[1].lineNumber === undefined) - ).toBeTruthy(); + expect( + res.every((elt) => elt[1].lineNumber === undefined) + ).toBeTruthy(); - for (const { event, title } of inputs.map((i) => ({ - title: i.title, - event: { - ...i.event, - completed: undefined, - type: "single", - }, - }))) { - expect(events).toContainEqual(event); - expect(paths).toContainEqual(`${dirName}/${title}`); - } + for (const { event, title } of inputs.map((i) => ({ + title: i.title, + event: { + endDate: null, + allDay: false, + type: "single", + ...i.event, + }, + }))) { + expect(events).toContainEqual(event); + expect(paths).toContainEqual(`${dirName}/${title}`); + } - for (const [ - event, - { - file: { path }, - }, - ] of res) { - const file = obsidian.getFileByPath(path)!; - const eventsFromFile = await calendar.getEventsInFile(file); - expect(eventsFromFile.length).toBe(1); - expect(eventsFromFile[0][0]).toEqual(event); + for (const [ + event, + { + file: { path }, + }, + ] of res) { + const file = obsidian.getFileByPath(path)!; + const eventsFromFile = await calendar.getEventsInFile(file); + expect(eventsFromFile.length).toBe(1); + expect(eventsFromFile[0][0]).toEqual(event); + } } - }); + ); it.todo("Recursive folder settings"); it("creates an event", async () => { const obsidian = makeApp(MockAppBuilder.make().done()); const calendar = new FullNoteCalendar(obsidian, color, dirName); - const event: OFCEvent = { + const event = { title: "Test Event", date: "2022-01-01", + endDate: null, + allDay: false, startTime: "11:00", endTime: "12:30", }; @@ -168,29 +175,33 @@ describe("Note Calendar Tests", () => { (obsidian.create as jest.Mock).mockReturnValue({ path: join(dirName, "2022-01-01 Test Event.md"), }); - const { lineNumber } = await calendar.createEvent(event); + const { lineNumber } = await calendar.createEvent(parseEvent(event)); expect(lineNumber).toBeUndefined(); expect(obsidian.create).toHaveBeenCalledTimes(1); const returns = (obsidian.create as jest.Mock).mock.calls[0]; expect(returns).toMatchInlineSnapshot(` - [ - "events/2022-01-01 Test Event.md", - "--- - title: Test Event - date: 2022-01-01 - startTime: 11:00 - endTime: 12:30 - --- - ", - ] - `); + [ + "events/2022-01-01 Test Event.md", + "--- + title: Test Event + allDay: false + startTime: 11:00 + endTime: 12:30 + type: single + date: 2022-01-01 + endDate: null + --- + ", + ] + `); }); it("cannot overwrite event", async () => { - const event: OFCEvent = { + const event = { title: "Test Event", allDay: true, date: "2022-01-01", + endDate: null, }; const obsidian = makeApp( MockAppBuilder.make() @@ -203,16 +214,21 @@ describe("Note Calendar Tests", () => { .done() ); const calendar = new FullNoteCalendar(obsidian, color, dirName); - await assertFailed(() => calendar.createEvent(event), /already exists/); + await assertFailed( + () => calendar.createEvent(parseEvent(event)), + /already exists/ + ); }); it("modify an existing event and keeping the same day and title", async () => { - const event: OFCEvent = { + const event = parseEvent({ title: "Test Event", + allDay: false, date: "2022-01-01", + endDate: null, startTime: "11:00", endTime: "12:30", - }; + }); const filename = "2022-01-01 Test Event.md"; const obsidian = makeApp( MockAppBuilder.make() @@ -235,6 +251,7 @@ describe("Note Calendar Tests", () => { const mockFn = jest.fn(); await calendar.modifyEvent( { path: join("events", filename), lineNumber: undefined }, + // @ts-ignore { ...event, endTime: "13:30" }, mockFn ); @@ -249,14 +266,17 @@ describe("Note Calendar Tests", () => { expect(file.path).toBe(join("events", filename)); expect(rewriteCallback(contents)).toMatchInlineSnapshot(` - "--- - title: Test Event - date: 2022-01-01 - startTime: 11:00 - endTime: 13:30 - --- - " - `); + "--- + title: Test Event + allDay: false + startTime: 11:00 + endTime: 13:30 + type: single + date: 2022-01-01 + endDate: null + --- + " + `); }); // it("modify an existing event with a new date", async () => { // const event: OFCEvent = { diff --git a/src/calendars/parsing/__snapshots__/ics.test.ts.snap b/src/calendars/parsing/__snapshots__/ics.test.ts.snap index 98618dfe..4b2d55fb 100644 --- a/src/calendars/parsing/__snapshots__/ics.test.ts.snap +++ b/src/calendars/parsing/__snapshots__/ics.test.ts.snap @@ -27,8 +27,8 @@ END:VCALENDAR 1`] = ` [ { "allDay": true, - "completed": undefined, "date": "2023-02-26", + "endDate": null, "id": "ics::7389432083-0-40713-74006::2023-02-26::single", "title": "EVENT TITLE", "type": "single", @@ -148,7 +148,6 @@ END:VCALENDAR [ { "allDay": true, - "completed": undefined, "date": "2022-03-02", "endDate": "2022-03-03", "id": "ics::5r09pnnlktaqivstai5vlbqb1h@google.com::2022-03-02::single", @@ -156,6 +155,7 @@ END:VCALENDAR "type": "single", }, { + "allDay": false, "endTime": "12:30", "id": "ics::5tt2avr2th0h65homv3b6jeqof@google.com::2022-03-01::recurring", "rrule": "RRULE:FREQ=WEEKLY;BYDAY=TH,TU;WKST=SU", @@ -166,8 +166,9 @@ END:VCALENDAR "type": "rrule", }, { - "completed": undefined, + "allDay": false, "date": "2022-02-28", + "endDate": null, "endTime": "19:45", "id": "ics::40mdbe6fvc1rmd60n6r0c3go7e@google.com::2022-02-28::single", "startTime": "16:45", @@ -175,8 +176,9 @@ END:VCALENDAR "type": "single", }, { - "completed": undefined, + "allDay": false, "date": "2022-02-19", + "endDate": null, "endTime": "23:00", "id": "ics::44hekcaaf0or7547vhqa772mqj@google.com::2022-02-19::single", "startTime": "19:00", @@ -185,7 +187,6 @@ END:VCALENDAR }, { "allDay": true, - "completed": undefined, "date": "2022-02-16", "endDate": "2022-02-17", "id": "ics::7ooluqb717vabebvc9gkc38c9l@google.com::2022-02-16::single", diff --git a/src/calendars/parsing/ics.ts b/src/calendars/parsing/ics.ts index eb191e12..d095e696 100644 --- a/src/calendars/parsing/ics.ts +++ b/src/calendars/parsing/ics.ts @@ -81,7 +81,7 @@ function icsToOFC(input: ical.Event): OFCEvent { id: `ics::${input.uid}::${date}::single`, title: input.summary, date, - endDate: date !== endDate ? endDate : undefined, + endDate: date !== endDate ? endDate || null : null, ...(allDay ? { allDay: true } : { diff --git a/src/core/EventCache.test.ts b/src/core/EventCache.test.ts index 3a52ff4d..56551f46 100644 --- a/src/core/EventCache.test.ts +++ b/src/core/EventCache.test.ts @@ -13,7 +13,7 @@ import EventCache, { } from "./EventCache"; import { EventPathLocation } from "./EventStore"; -jest.mock("../types/validation", () => ({ +jest.mock("../types/schema", () => ({ validateEvent: (e: any) => e, })); @@ -62,9 +62,7 @@ const initializerMap = ( FOR_TEST_ONLY: cb, local: () => null, dailynote: () => null, - gcal: () => null, ical: () => null, - icloud: () => null, caldav: () => null, }); diff --git a/src/main.ts b/src/main.ts index 1a3caf73..db16e9c4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -41,7 +41,6 @@ export default class FullCalendarPlugin extends Plugin { : null, ical: (info) => info.type === "ical" ? new ICSCalendar(info.color, info.url) : null, - gcal: () => null, caldav: (info) => info.type === "caldav" ? new CalDAVCalendar( @@ -56,7 +55,6 @@ export default class FullCalendarPlugin extends Plugin { info.homeUrl ) : null, - icloud: () => null, FOR_TEST_ONLY: () => null, }); diff --git a/src/types/calendar_settings.ts b/src/types/calendar_settings.ts new file mode 100644 index 00000000..8a4f6b3c --- /dev/null +++ b/src/types/calendar_settings.ts @@ -0,0 +1,75 @@ +import { ZodError, z } from "zod"; +import { 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/src/types/index.ts b/src/types/index.ts index 879d5438..0a122d4b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,151 +1,12 @@ -import { validateEvent as val } from "./validation"; -import { rrulestr } from "rrule"; -export const PLUGIN_SLUG = "full-calendar-plugin"; - -// Frontmatter -export type AllDayData = { - allDay: true; -}; - -export type RangeTimeData = { - allDay?: false; - startTime: string; - endTime: string | null; -}; - -export type CommonEventData = { - title: string; - id?: string; // Only set for remote calendars. -} & (RangeTimeData | AllDayData); - -export type SingleEventData = { - type?: "single"; - date: string; - endDate?: string; - completed?: string | false | null; -} & CommonEventData; - -export type RecurringEventData = { - type: "recurring"; - daysOfWeek: string[]; - startRecur?: string; - endRecur?: string; -} & CommonEventData; - -export type RRuleEventData = { - type: "rrule"; - startDate: string; - rrule: string; - skipDates: string[]; -} & CommonEventData; - -export type OFCEvent = SingleEventData | RecurringEventData | RRuleEventData; - -// Settings - -type CalendarSourceCommon = { - color: string; -}; - -/** - * Local calendar with events stored as files in a directory. - */ -export type LocalCalendarSource = { - type: "local"; - directory: string; -} & CalendarSourceCommon; - -/** - * Local calendar with events stored inline in daily notes. Under a certain heading. - */ -export type DailyNoteCalendarSource = { - type: "dailynote"; - heading: string; -} & CalendarSourceCommon; +import { CalendarInfo } from "./calendar_settings"; -/** - * Public google calendars using the FullCalendar integration. - */ -export type GoogleCalendarSource = { - type: "gcal"; - url: string; -} & CalendarSourceCommon; +export type { OFCEvent } from "./schema"; +export { validateEvent } from "./schema"; -/** - * Readonly mirror of a remote calendar located at the given URL. - */ -export type ICalSource = { - type: "ical"; - url: string; -} & CalendarSourceCommon; +export { makeDefaultPartialCalendarSource } from "./calendar_settings"; +export type { CalendarInfo } from "./calendar_settings"; -/** - * Auth types. Currently only support Basic, but will probably support OAuth in the future. - */ -type BasicAuth = { - username: string; - password: string; -} & CalendarSourceCommon; - -type AuthType = BasicAuth; - -/** - * Read/write mirror of a remote CalDAV backed calendar at the given URL. - */ -export type CalDAVSource = { - type: "caldav"; - name: string; - url: string; - homeUrl: string; -} & CalendarSourceCommon & - AuthType; - -/** - * An read/write mirror of an iCloud backed calendar. - */ -export type ICloudSource = Omit & { - type: "icloud"; - url: "https://caldav.icloud.com"; -}; - -export type TestSource = { - type: "FOR_TEST_ONLY"; - id: string; - events?: OFCEvent[]; -} & CalendarSourceCommon; - -export type CalendarInfo = - | LocalCalendarSource - | DailyNoteCalendarSource - | GoogleCalendarSource - | ICalSource - | CalDAVSource - | ICloudSource - | TestSource; - -/** - * Construct a partial calendar source of the specified type - */ -export function makeDefaultPartialCalendarSource( - type: CalendarInfo["type"] -): Partial { - if (type === "icloud") { - return { - type: type, - color: getComputedStyle(document.body) - .getPropertyValue("--interactive-accent") - .trim(), - url: "https://caldav.icloud.com", - } as Partial; - } - - return { - type: type, - color: getComputedStyle(document.body) - .getPropertyValue("--interactive-accent") - .trim(), - }; -} +export const PLUGIN_SLUG = "full-calendar-plugin"; export class FCError { message: string; @@ -159,10 +20,10 @@ export type EventLocation = { lineNumber: number | undefined; }; -export const validateEvent = val; - export type Authentication = { type: "basic"; username: string; password: string; }; + +export type CalDAVSource = Extract; diff --git a/src/types/schema.test.ts b/src/types/schema.test.ts new file mode 100644 index 00000000..e9ca066b --- /dev/null +++ b/src/types/schema.test.ts @@ -0,0 +1,422 @@ +import { + CommonSchema, + EventSchema, + OFCEvent, + ParsedDate, + ParsedTime, + TimeSchema, + parseEvent, + serializeEvent, +} from "./schema"; +import fc from "fast-check"; +import { ZodFastCheck } from "zod-fast-check"; + +describe("schema parsing tests", () => { + describe("single events", () => { + it("simplest", () => { + expect( + parseEvent({ + title: "Test", + date: "2021-01-01", + allDay: true, + }) + ).toMatchInlineSnapshot(` + { + "allDay": true, + "date": "2021-01-01", + "endDate": null, + "title": "Test", + "type": "single", + } + `); + }); + it("explicit type", () => { + expect( + parseEvent({ + title: "Test", + type: "single", + date: "2021-01-01", + allDay: true, + }) + ).toMatchInlineSnapshot(` + { + "allDay": true, + "date": "2021-01-01", + "endDate": null, + "title": "Test", + "type": "single", + } + `); + }); + it("truncates time from date", () => { + expect( + parseEvent({ + title: "Test", + type: "single", + date: "2021-01-01", + allDay: true, + }) + ).toMatchInlineSnapshot(` + { + "allDay": true, + "date": "2021-01-01", + "endDate": null, + "title": "Test", + "type": "single", + } + `); + }); + it("start time", () => { + expect( + parseEvent({ + title: "Test", + type: "single", + date: "2021-01-01T10:30:00.000Z", + allDay: false, + startTime: "10:30", + endTime: null, + }) + ).toMatchInlineSnapshot(` + { + "allDay": false, + "date": "2021-01-01T10:30:00.000Z", + "endDate": null, + "endTime": null, + "startTime": "10:30", + "title": "Test", + "type": "single", + } + `); + }); + it("am/pm start time", () => { + expect( + parseEvent({ + title: "Test", + type: "single", + date: "2021-01-01", + allDay: false, + startTime: "10:30 pm", + endTime: null, + }) + ).toMatchInlineSnapshot(` + { + "allDay": false, + "date": "2021-01-01", + "endDate": null, + "endTime": null, + "startTime": "10:30 pm", + "title": "Test", + "type": "single", + } + `); + }); + it("end time", () => { + expect( + parseEvent({ + title: "Test", + type: "single", + date: "2021-01-01", + allDay: false, + startTime: "10:30", + endTime: "11:45", + }) + ).toMatchInlineSnapshot(` + { + "allDay": false, + "date": "2021-01-01", + "endDate": null, + "endTime": "11:45", + "startTime": "10:30", + "title": "Test", + "type": "single", + } + `); + }); + it("multi-day events", () => { + expect( + parseEvent({ + title: "Test", + type: "single", + date: "2021-01-01", + endDate: "2021-01-03", + allDay: true, + }) + ).toMatchInlineSnapshot(` + { + "allDay": true, + "date": "2021-01-01", + "endDate": "2021-01-03", + "title": "Test", + "type": "single", + } + `); + }); + it("to-do", () => { + expect( + parseEvent({ + title: "Test", + type: "single", + date: "2021-01-01", + allDay: true, + completed: null, + }) + ).toMatchInlineSnapshot(` + { + "allDay": true, + "completed": null, + "date": "2021-01-01", + "endDate": null, + "title": "Test", + "type": "single", + } + `); + }); + it("to-do unchecked", () => { + expect( + parseEvent({ + title: "Test", + type: "single", + date: "2021-01-01", + allDay: true, + completed: false, + }) + ).toMatchInlineSnapshot(` + { + "allDay": true, + "completed": false, + "date": "2021-01-01", + "endDate": null, + "title": "Test", + "type": "single", + } + `); + }); + it("to-do completed", () => { + expect( + parseEvent({ + title: "Test", + type: "single", + date: "2021-01-01", + allDay: true, + completed: "2021-01-01T10:30:00.000Z", + }) + ).toMatchInlineSnapshot(` + { + "allDay": true, + "completed": "2021-01-01T10:30:00.000Z", + "date": "2021-01-01", + "endDate": null, + "title": "Test", + "type": "single", + } + `); + }); + }); + describe("simple recurring events", () => { + it("recurs once per week", () => { + expect( + parseEvent({ + title: "Test", + allDay: true, + type: "recurring", + daysOfWeek: ["M"], + }) + ).toMatchInlineSnapshot(` + { + "allDay": true, + "daysOfWeek": [ + "M", + ], + "title": "Test", + "type": "recurring", + } + `); + }); + it("recurs twice per week", () => { + expect( + parseEvent({ + title: "Test", + allDay: true, + type: "recurring", + daysOfWeek: ["M", "W"], + }) + ).toMatchInlineSnapshot(` + { + "allDay": true, + "daysOfWeek": [ + "M", + "W", + ], + "title": "Test", + "type": "recurring", + } + `); + }); + it("recurs with start date", () => { + expect( + parseEvent({ + title: "Test", + allDay: true, + type: "recurring", + daysOfWeek: ["M"], + startRecur: "2023-01-05", + }) + ).toMatchInlineSnapshot(` + { + "allDay": true, + "daysOfWeek": [ + "M", + ], + "startRecur": "2023-01-05", + "title": "Test", + "type": "recurring", + } + `); + }); + it("recurs with end date", () => { + expect( + parseEvent({ + title: "Test", + allDay: true, + type: "recurring", + daysOfWeek: ["M"], + endRecur: "2023-01-05", + }) + ).toMatchInlineSnapshot(` + { + "allDay": true, + "daysOfWeek": [ + "M", + ], + "endRecur": "2023-01-05", + "title": "Test", + "type": "recurring", + } + `); + }); + it("recurs with both start and end dates", () => { + expect( + parseEvent({ + title: "Test", + allDay: true, + type: "recurring", + daysOfWeek: ["M"], + startRecur: "2023-01-05", + endRecur: "2023-05-12", + }) + ).toMatchInlineSnapshot(` + { + "allDay": true, + "daysOfWeek": [ + "M", + ], + "endRecur": "2023-05-12", + "startRecur": "2023-01-05", + "title": "Test", + "type": "recurring", + } + `); + }); + }); + describe("rrule events", () => { + it("basic rrule", () => { + expect( + parseEvent({ + title: "Test", + allDay: true, + type: "rrule", + id: "hi", + rrule: "RRULE", + skipDates: [], + startDate: "2023-01-05", + }) + ).toMatchInlineSnapshot(` + { + "allDay": true, + "id": "hi", + "rrule": "RRULE", + "skipDates": [], + "startDate": "2023-01-05", + "title": "Test", + "type": "rrule", + } + `); + }); + }); + + describe("property-based tests", () => { + const zfc = ZodFastCheck() + .override( + ParsedDate, + fc + .date({ + min: new Date(2000, 0, 0), + max: new Date(2150, 0, 0), + }) + .map( + (date) => + `${date.getFullYear()}-${(date.getMonth() + 1) + .toString() + .padStart(2, "0")}-${date + .getDate() + .toString() + .padStart(2, "0")}` + ) + ) + .override( + ParsedTime, + fc + .date() + .map( + (date) => + `${date + .getHours() + .toString() + .padStart(2, "0")}:${date + .getMinutes() + .toString() + .padStart(2, "0")}` + ) + ); + + it("parses", () => { + const CommonArb = zfc.inputOf(CommonSchema); + const TimeArb = zfc.inputOf(TimeSchema); + const EventArb = zfc.inputOf(EventSchema); + const EventInputArbitrary = fc + .tuple(CommonArb, TimeArb, EventArb) + .map(([common, time, event]) => ({ + ...common, + ...time, + ...event, + })); + + fc.assert( + fc.property(EventInputArbitrary, (obj) => { + expect(() => parseEvent(obj)).not.toThrow(); + }) + ); + }); + + it("roundtrips", () => { + const CommonArb = zfc.outputOf(CommonSchema); + const TimeArb = zfc.outputOf(TimeSchema); + const EventArb = zfc.outputOf(EventSchema); + const OFCEventArbitrary: fc.Arbitrary = fc + .tuple(CommonArb, TimeArb, EventArb) + .map(([common, time, event]) => ({ + ...common, + ...time, + ...event, + })); + + fc.assert( + fc.property(OFCEventArbitrary, (event) => { + const obj = serializeEvent(event); + const newParsedEvent = parseEvent(obj); + expect(newParsedEvent).toEqual(event); + }) + ); + }); + }); +}); diff --git a/src/types/schema.ts b/src/types/schema.ts new file mode 100644 index 00000000..4ce81dca --- /dev/null +++ b/src/types/schema.ts @@ -0,0 +1,131 @@ +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 }; +} diff --git a/src/types/validation.test.ts b/src/types/validation.test.ts deleted file mode 100644 index 564ab1a1..00000000 --- a/src/types/validation.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { validateEvent } from "./validation"; - -describe("validation tests", () => { - it.each([ - [ - "basic allDay", - { - title: "Test Event", - allDay: true, - date: "2022-01-01", - type: "single", - }, - ], - [ - "basic allDay toDo", - { - title: "Test Event", - allDay: true, - date: "2022-01-01", - completed: "x", - type: "single", - }, - ], - ])("%p roundtrips", (_, event) => { - expect(validateEvent(event)).toEqual(event); - }); -}); diff --git a/src/types/validation.ts b/src/types/validation.ts deleted file mode 100644 index d538c1aa..00000000 --- a/src/types/validation.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { AllDayData, OFCEvent, RangeTimeData } from "."; - -// TODO: Replace with Zod validator (https://github.com/colinhacks/zod) -/* - * Validates that an incoming object from a JS object (presumably parsed from a note's frontmatter) - * is a valid event, and returns that event if so. If any required fields are missing, then returns null. - */ -export function validateEvent(obj?: Record): OFCEvent | null { - if (obj === undefined) { - return null; - } - - if (!obj.title) { - return null; - } - - if (!obj.allDay && !obj.startTime) { - return null; - } - - const timeInfo: RangeTimeData | AllDayData = obj.allDay - ? { allDay: true } - : { - startTime: obj.startTime, - endTime: obj.endTime, - }; - - if (obj.type === undefined || obj.type === "single") { - if (!obj.date) { - return null; - } - const event: OFCEvent = { - title: obj.title, - type: "single", - date: obj.date, - ...timeInfo, - }; - if (obj.completed !== undefined || obj.completed !== null) { - event.completed = obj.completed; - } - if (obj.endDate) { - event.endDate = obj.endDate; - } - if (obj.id) { - event.id = obj.id; - } - return event; - } else if (obj.type === "recurring") { - if (obj.daysOfWeek === undefined) { - return null; - } - const event: OFCEvent = { - title: obj.title, - type: "recurring", - daysOfWeek: obj.daysOfWeek, - startRecur: obj.startRecur, - endRecur: obj.endRecur, - ...timeInfo, - }; - if (obj.id) { - event.id = obj.id; - } - return event; - } else if (obj.type === "rrule") { - if (!obj.rrule) { - return null; - } - if (!obj.startDate) { - return null; - } - - const event: OFCEvent = { - type: "rrule", - title: obj.title, - rrule: obj.rrule, - skipDates: obj.skipDates, - startDate: obj.startDate, - ...timeInfo, - }; - if (obj.id) { - event.id = obj.id; - } - return event; - } - - return null; -} diff --git a/src/ui/components/AddCalendarSource.tsx b/src/ui/components/AddCalendarSource.tsx index 2073f441..2a1b4c81 100644 --- a/src/ui/components/AddCalendarSource.tsx +++ b/src/ui/components/AddCalendarSource.tsx @@ -229,7 +229,7 @@ export const AddCalendarSource = ({ headings, submit, }: AddCalendarProps) => { - const isCalDAV = source.type === "caldav" || source.type === "icloud"; + const isCalDAV = source.type === "caldav"; const [setting, setSettingState] = useState(source); const [submitting, setSubmitingState] = useState(false); @@ -279,9 +279,7 @@ export const AddCalendarSource = ({ headings={headings} /> )} - {source.type === "gcal" || - source.type === "ical" || - source.type === "caldav" ? ( + {source.type === "ical" || source.type === "caldav" ? ( { - const isCalDAV = setting.type === "caldav" || setting.type === "icloud"; + const isCalDAV = setting.type === "caldav"; return (