├── LICENSE ├── README.md ├── packages ├── schema-tests │ ├── README.md │ ├── src │ │ ├── index.ts │ │ └── schemas.ts │ ├── biome.json │ ├── jest.config.ts │ ├── tsconfig.json │ ├── package.json │ └── tests │ │ ├── parse │ │ ├── alarm.test.ts │ │ ├── freebusy.test.ts │ │ ├── journal.test.ts │ │ ├── todo.test.ts │ │ ├── event.test.ts │ │ └── timezone.test.ts │ │ └── exports.test.ts ├── schema-zod │ ├── README.md │ ├── .gitignore │ ├── src │ │ ├── index.ts │ │ ├── values │ │ │ ├── timeStamp.ts │ │ │ ├── weekDayNumber.ts │ │ │ ├── class.ts │ │ │ ├── timeTransparent.ts │ │ │ ├── organizer.ts │ │ │ ├── weekDay.ts │ │ │ ├── index.ts │ │ │ ├── recurrenceId.ts │ │ │ ├── duration.ts │ │ │ ├── exceptionDate.ts │ │ │ ├── attendee.ts │ │ │ ├── date.ts │ │ │ ├── attachment.ts │ │ │ ├── status.ts │ │ │ ├── trigger.ts │ │ │ └── recurrenceRule.ts │ │ └── components │ │ │ ├── index.ts │ │ │ ├── timezone.ts │ │ │ ├── alarm.ts │ │ │ ├── timezoneProp.ts │ │ │ ├── calendar.ts │ │ │ ├── journal.ts │ │ │ └── freebusy.ts │ ├── .npmignore │ ├── tsup.config.ts │ ├── biome.json │ ├── package.json │ ├── tsconfig.json │ └── LICENSE └── ts-ics │ ├── .gitignore │ ├── src │ ├── types │ │ ├── nonStandard │ │ │ ├── index.ts │ │ │ └── nonStandardValues.ts │ │ ├── line.ts │ │ ├── values │ │ │ ├── text.ts │ │ │ ├── integer.ts │ │ │ ├── organizer.ts │ │ │ ├── duration.ts │ │ │ ├── class.ts │ │ │ ├── timeTransparent.ts │ │ │ ├── index.ts │ │ │ ├── recurrenceId.ts │ │ │ ├── exceptionDate.ts │ │ │ ├── weekday.ts │ │ │ ├── attendee.ts │ │ │ ├── date.ts │ │ │ ├── trigger.ts │ │ │ ├── attachment.ts │ │ │ ├── status.ts │ │ │ └── recurrenceRule.ts │ │ ├── index.ts │ │ ├── components │ │ │ ├── index.ts │ │ │ ├── timezone.ts │ │ │ ├── alarm.ts │ │ │ ├── timezoneProp.ts │ │ │ ├── calendar.ts │ │ │ └── journal.ts │ │ └── parse.ts │ ├── lib │ │ ├── index.ts │ │ ├── parse │ │ │ ├── nonStandard │ │ │ │ ├── index.ts │ │ │ │ └── nonStandardValues.ts │ │ │ ├── index.ts │ │ │ ├── utils │ │ │ │ ├── replaceMailTo.ts │ │ │ │ ├── unescapeText.ts │ │ │ │ ├── standardValidate.ts │ │ │ │ ├── options.ts │ │ │ │ ├── line.ts │ │ │ │ ├── splitLines.ts │ │ │ │ └── unescapeText.test.ts │ │ │ ├── components │ │ │ │ ├── index.ts │ │ │ │ ├── timezone.ts │ │ │ │ ├── alarm.ts │ │ │ │ ├── freebusy.ts │ │ │ │ └── timezoneProp.ts │ │ │ └── values │ │ │ │ ├── class.ts │ │ │ │ ├── integer.ts │ │ │ │ ├── weekDay.ts │ │ │ │ ├── text.ts │ │ │ │ ├── timeTransparent.ts │ │ │ │ ├── organizer.ts │ │ │ │ ├── recurrenceId.ts │ │ │ │ ├── index.ts │ │ │ │ ├── exceptionDate.ts │ │ │ │ ├── status.ts │ │ │ │ ├── weekDayNumber.ts │ │ │ │ ├── attachment.ts │ │ │ │ ├── trigger.ts │ │ │ │ ├── freebusyValue.ts │ │ │ │ ├── attendee.ts │ │ │ │ ├── duration.ts │ │ │ │ ├── timeStamp.ts │ │ │ │ └── date.ts │ │ └── generate │ │ │ ├── nonStandard │ │ │ ├── index.ts │ │ │ └── nonStandardValues.ts │ │ │ ├── index.ts │ │ │ ├── utils │ │ │ ├── getKeys.ts │ │ │ ├── generateOptions.ts │ │ │ ├── escapeText.ts │ │ │ ├── addLine.ts │ │ │ ├── formatLines.test.ts │ │ │ └── formatLines.ts │ │ │ ├── values │ │ │ ├── mail.ts │ │ │ ├── integer.ts │ │ │ ├── weekdayNumber.ts │ │ │ ├── index.ts │ │ │ ├── exceptionDate.ts │ │ │ ├── text.ts │ │ │ ├── recurrenceId.ts │ │ │ ├── organizer.ts │ │ │ ├── trigger.ts │ │ │ ├── freebusyValue.ts │ │ │ ├── duration.ts │ │ │ ├── attachment.ts │ │ │ ├── attendee.ts │ │ │ ├── timeStamp.ts │ │ │ ├── date.ts │ │ │ └── recurrenceRule.ts │ │ │ └── components │ │ │ ├── index.ts │ │ │ ├── timezone.ts │ │ │ ├── timezoneProp.ts │ │ │ ├── alarm.ts │ │ │ ├── calendar.ts │ │ │ └── freebusy.ts │ ├── constants │ │ ├── index.ts │ │ ├── regex │ │ │ └── index.ts │ │ ├── symbols.ts │ │ └── keys │ │ │ ├── utils.ts │ │ │ ├── timezone.ts │ │ │ ├── alarm.ts │ │ │ ├── calendar.ts │ │ │ ├── freebusy.ts │ │ │ ├── timezoneProp.ts │ │ │ ├── recurrenceRule.ts │ │ │ ├── journal.ts │ │ │ ├── index.ts │ │ │ ├── todo.ts │ │ │ └── event.ts │ ├── utils │ │ ├── duration │ │ │ ├── index.ts │ │ │ ├── eventEndFromDuration.ts │ │ │ ├── durationFromInterval.ts │ │ │ └── durationFromInterval.test.ts │ │ ├── nonStandardValue │ │ │ └── index.ts │ │ ├── timezone │ │ │ ├── index.ts │ │ │ ├── offsetToMilliseconds.ts │ │ │ ├── getOffsetFromTimezoneId.test.ts │ │ │ ├── getOffsetFromTimezoneId.ts │ │ │ ├── extendProps.ts │ │ │ └── getTimezone.ts │ │ ├── index.ts │ │ ├── end.ts │ │ └── recurrence │ │ │ ├── iterateBy │ │ │ ├── month.ts │ │ │ ├── weekNo.ts │ │ │ ├── hour.ts │ │ │ ├── minute.ts │ │ │ ├── second.ts │ │ │ ├── yearDay.ts │ │ │ ├── monthDay.ts │ │ │ └── setPos.ts │ │ │ └── index.ts │ └── index.ts │ ├── tests │ ├── utils │ │ └── index.ts │ ├── parse │ │ ├── duration.test.ts │ │ ├── recurrenceId.test.ts │ │ ├── attachment.test.ts │ │ ├── trigger.test.ts │ │ ├── date.test.ts │ │ ├── weekdayNumber.test.ts │ │ ├── fixtures │ │ │ ├── apple.ics │ │ │ └── longDescriptionEvent.ics │ │ ├── organizer.test.ts │ │ ├── exceptionDate.test.ts │ │ ├── attendee.test.ts │ │ ├── timezoneProp.test.ts │ │ └── alarm.test.ts │ └── generate │ │ ├── integer.test.ts │ │ ├── fixtures │ │ └── timezones.ts │ │ ├── alarm.test.ts │ │ ├── timezone.test.ts │ │ ├── exceptionDate.test.ts │ │ ├── timezoneProp.test.ts │ │ ├── attendee.test.ts │ │ ├── duration.test.ts │ │ └── recurrenceRule.test.ts │ ├── .npmignore │ ├── tsup.config.ts │ ├── biome.json │ ├── jest.config.ts │ ├── tsconfig.json │ ├── LICENSE │ └── package.json ├── .gitignore ├── public └── Header.png ├── turbo.json ├── .changeset ├── config.json └── README.md ├── biome.json ├── package.json ├── .github └── workflows │ ├── main.yml │ ├── publish.yml │ └── benchmark.yml ├── benchmark └── index.js └── CHANGELOG.md /LICENSE: -------------------------------------------------------------------------------- 1 | packages/ts-ics/LICENSE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | packages/ts-ics/README.md -------------------------------------------------------------------------------- /packages/schema-tests/README.md: -------------------------------------------------------------------------------- 1 | # @ts-ics/schema-tests 2 | -------------------------------------------------------------------------------- /packages/schema-zod/README.md: -------------------------------------------------------------------------------- 1 | # @ts-ics/schema-zod 2 | -------------------------------------------------------------------------------- /packages/ts-ics/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | dist -------------------------------------------------------------------------------- /packages/schema-zod/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | dist -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | .turbo 4 | bench_result.json -------------------------------------------------------------------------------- /packages/schema-tests/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./schemas"; 2 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/nonStandard/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./nonStandardValues"; 2 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./generate"; 2 | export * from "./parse"; 3 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/nonStandard/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./nonStandardValues"; 2 | -------------------------------------------------------------------------------- /public/Header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Neuvernetzung/ts-ics/HEAD/public/Header.png -------------------------------------------------------------------------------- /packages/schema-zod/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./components"; 2 | export * from "./values"; 3 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/nonStandard/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./nonStandardValues"; 2 | -------------------------------------------------------------------------------- /packages/schema-tests/src/schemas.ts: -------------------------------------------------------------------------------- 1 | export const schemaPackageNames: string[] = ["@ts-ics/schema-zod"]; 2 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/line.ts: -------------------------------------------------------------------------------- 1 | export type Line = { value: string; options?: Record }; 2 | -------------------------------------------------------------------------------- /packages/ts-ics/src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./regex"; 2 | export * from "./symbols"; 3 | export * from "./keys"; 4 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/duration/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./durationFromInterval"; 2 | export * from "./eventEndFromDuration"; 3 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./components"; 2 | export * from "./nonStandard"; 3 | export * from "./values"; 4 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./components"; 2 | export * from "./nonStandard"; 3 | export * from "./values"; 4 | -------------------------------------------------------------------------------- /packages/ts-ics/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./lib"; 2 | export * from "./types"; 3 | export * from "./utils"; 4 | export * from "./constants"; 5 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/nonStandardValue/index.ts: -------------------------------------------------------------------------------- 1 | export const valueIsNonStandard = (property: string) => 2 | property.startsWith("X-"); 3 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/utils/replaceMailTo.ts: -------------------------------------------------------------------------------- 1 | export const replaceMailTo = (mailString: string) => 2 | mailString.replace(/mailto:/gi, ""); 3 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/values/text.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertLineType } from "../parse"; 2 | 3 | export type ConvertText = ConvertLineType; 4 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/timezone/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./extendProps"; 2 | export * from "./getTimezone"; 3 | export * from "./offsetToMilliseconds"; 4 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/values/integer.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertLineType } from "../parse"; 2 | 3 | export type ConvertInteger = ConvertLineType; 4 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./duration"; 2 | export * from "./end"; 3 | export * from "./recurrence"; 4 | export * from "./timezone"; 5 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/utils/getKeys.ts: -------------------------------------------------------------------------------- 1 | export const getKeys = (object: TObject) => 2 | Object.keys(object) as (keyof TObject)[]; 3 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/values/mail.ts: -------------------------------------------------------------------------------- 1 | export const generateIcsMail = (email: string, isOption?: boolean) => 2 | isOption ? `"MAILTO:${email}"` : `MAILTO:${email}`; 3 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./components"; 2 | export * from "./line"; 3 | export * from "./nonStandard"; 4 | export * from "./parse"; 5 | export * from "./values"; 6 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/values/integer.ts: -------------------------------------------------------------------------------- 1 | import { generateIcsLine } from "../utils/addLine"; 2 | 3 | export const generateIcsInteger = (icsKey: string, value: number) => 4 | generateIcsLine(icsKey, Math.trunc(value).toString()); 5 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { CRLF_BREAK } from "../../src/constants/symbols"; 2 | 3 | export const icsTestData = (icsDataRaw: string[]): string => 4 | icsDataRaw.join(CRLF_BREAK); // Line breaks need to be CRLF, when creating a string inside Javascript its LF 5 | -------------------------------------------------------------------------------- /packages/schema-zod/src/values/timeStamp.ts: -------------------------------------------------------------------------------- 1 | import { convertIcsTimeStamp, type ParseTimeStamp } from "ts-ics"; 2 | import { zIcsDateObject } from "./date"; 3 | 4 | export const parseicsTimeStamp: ParseTimeStamp = (...props) => 5 | convertIcsTimeStamp(zIcsDateObject, ...props); 6 | -------------------------------------------------------------------------------- /packages/schema-zod/.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | .github 3 | .changeset 4 | package-lock.json 5 | node_modules 6 | CHANGELOG.md 7 | **/**.tsbuildinfo 8 | .env 9 | tsup.config.ts 10 | src 11 | jest.config.ts 12 | .gitignore 13 | tsconfig.json 14 | tsup.config.ts 15 | public 16 | tests 17 | biome.json -------------------------------------------------------------------------------- /packages/ts-ics/.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | .github 3 | .changeset 4 | package-lock.json 5 | node_modules 6 | CHANGELOG.md 7 | **/**.tsbuildinfo 8 | .env 9 | tsup.config.ts 10 | src 11 | jest.config.ts 12 | .gitignore 13 | tsconfig.json 14 | tsup.config.ts 15 | public 16 | tests 17 | biome.json -------------------------------------------------------------------------------- /packages/schema-zod/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./calendar"; 2 | export * from "./timezone"; 3 | export * from "./event"; 4 | export * from "./alarm"; 5 | export * from "./timezoneProp"; 6 | export * from "./todo"; 7 | export * from "./journal"; 8 | export * from "./freebusy"; 9 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./alarm"; 2 | export * from "./calendar"; 3 | export * from "./event"; 4 | export * from "./timezone"; 5 | export * from "./timezoneProp"; 6 | export * from "./todo"; 7 | export * from "./journal"; 8 | export * from "./freebusy"; 9 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/end.ts: -------------------------------------------------------------------------------- 1 | import type { IcsEvent } from "../types"; 2 | import { getEventEndFromDuration } from "./duration"; 3 | 4 | export const getEventEnd = (event: IcsEvent) => 5 | event.end 6 | ? event.end.date 7 | : getEventEndFromDuration(event.start.date, event.duration); 8 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./alarm"; 2 | export * from "./calendar"; 3 | export * from "./event"; 4 | export * from "./timezone"; 5 | export * from "./timezoneProp"; 6 | export * from "./todo"; 7 | export * from "./journal"; 8 | export * from "./freebusy"; 9 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./alarm"; 2 | export * from "./calendar"; 3 | export * from "./event"; 4 | export * from "./freebusy"; 5 | export * from "./journal"; 6 | export * from "./timezone"; 7 | export * from "./timezoneProp"; 8 | export * from "./todo"; 9 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/values/class.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertClass, IcsClassType } from "@/types"; 2 | import { standardValidate } from "../utils/standardValidate"; 3 | 4 | export const convertIcsClass: ConvertClass = (schema, line) => 5 | standardValidate(schema, line.value as IcsClassType); 6 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/values/integer.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertInteger } from "@/types"; 2 | import { standardValidate } from "../utils/standardValidate"; 3 | 4 | export const convertIcsInteger: ConvertInteger = (schema, line) => 5 | standardValidate(schema, Number.parseInt(line.value, 10)); 6 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/values/weekDay.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertWeekDay, IcsWeekDay } from "@/types"; 2 | import { standardValidate } from "../utils/standardValidate"; 3 | 4 | export const convertIcsWeekDay: ConvertWeekDay = (schema, line) => 5 | standardValidate(schema, line.value as IcsWeekDay); 6 | -------------------------------------------------------------------------------- /packages/schema-zod/src/values/weekDayNumber.ts: -------------------------------------------------------------------------------- 1 | import { convertIcsWeekDayNumber, type ParseWeekDayNumber } from "ts-ics"; 2 | import { zIcsWeekdayNumber } from "./weekDay"; 3 | 4 | export const parseIcsWeekdayNumber: ParseWeekDayNumber = (...props) => 5 | convertIcsWeekDayNumber(zIcsWeekdayNumber, ...props); 6 | -------------------------------------------------------------------------------- /packages/schema-zod/src/values/class.ts: -------------------------------------------------------------------------------- 1 | import { classTypes, convertIcsClass, type ParseClassType } from "ts-ics"; 2 | import { z } from "zod"; 3 | 4 | export const zIcsClassType = z.enum(classTypes); 5 | 6 | export const parseIcsClassType: ParseClassType = (...props) => 7 | convertIcsClass(zIcsClassType, ...props); 8 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/values/weekdayNumber.ts: -------------------------------------------------------------------------------- 1 | import type { IcsWeekdayNumber } from "@/types/values/weekday"; 2 | 3 | export const generateIcsWeekdayNumber = (value: IcsWeekdayNumber) => { 4 | if (value.occurrence) { 5 | return `${value.occurrence}${value.day}`; 6 | } 7 | 8 | return value.day; 9 | }; 10 | -------------------------------------------------------------------------------- /packages/schema-zod/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | format: ["cjs", "esm"], 5 | clean: true, 6 | entry: ["./src/index.ts"], 7 | minify: true, 8 | dts: "./src/index.ts", 9 | platform: "node", 10 | target: "node16", 11 | skipNodeModulesBundle: true, 12 | }); 13 | -------------------------------------------------------------------------------- /packages/ts-ics/src/constants/regex/index.ts: -------------------------------------------------------------------------------- 1 | import { OBJECT_END, OBJECT_START } from "../keys"; 2 | 3 | export const createGetRegex = (key: string) => 4 | new RegExp(`${OBJECT_START}:${key}([\\s\\S]*?)${OBJECT_END}:${key}`, "g"); 5 | 6 | export const createReplaceRegex = (key: string) => 7 | new RegExp(`${OBJECT_START}:${key}|${OBJECT_END}:${key}`, "g"); 8 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/values/text.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertText } from "@/types/values/text"; 2 | import { standardValidate } from "../utils/standardValidate"; 3 | import { unescapeTextString } from "../utils/unescapeText"; 4 | 5 | export const convertIcsText: ConvertText = (schema, line) => 6 | standardValidate(schema, unescapeTextString(line.value)); 7 | -------------------------------------------------------------------------------- /packages/ts-ics/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | format: ["cjs", "esm"], 5 | clean: true, 6 | entry: ["./src/index.ts"], 7 | minify: true, 8 | dts: "./src/index.ts", 9 | platform: "node", 10 | target: "node16", 11 | skipNodeModulesBundle: true, 12 | noExternal: ["date-fns"], 13 | }); 14 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "tasks": { 4 | "dev": { 5 | "cache": false, 6 | "persistent": true 7 | }, 8 | "build": { 9 | "cache": false, 10 | "dependsOn": ["^build"] 11 | }, 12 | "type-check": {}, 13 | "lint": {}, 14 | "test": {}, 15 | "start": {} 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [["**"]], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "master", 9 | "updateInternalDependencies": "patch", 10 | "ignore": ["@ts-ics/schema-tests"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/values/timeTransparent.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertTimeTransparent, IcsTimeTransparentType } from "@/types"; 2 | import { standardValidate } from "../utils/standardValidate"; 3 | 4 | export const convertIcsTimeTransparent: ConvertTimeTransparent = ( 5 | schema, 6 | line 7 | ) => standardValidate(schema, line.value as IcsTimeTransparentType); 8 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/values/organizer.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertLineType, ParseLineType } from "../parse"; 2 | 3 | export type IcsOrganizer = { 4 | name?: string; 5 | email: string; 6 | dir?: string; 7 | sentBy?: string; 8 | }; 9 | 10 | export type ConvertOrganizer = ConvertLineType; 11 | 12 | export type ParseOrganizer = ParseLineType; 13 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/values/duration.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertLineType, ParseLineType } from "../parse"; 2 | 3 | export type IcsDuration = { 4 | before?: boolean; 5 | weeks?: number; 6 | days?: number; 7 | hours?: number; 8 | minutes?: number; 9 | seconds?: number; 10 | }; 11 | 12 | export type ConvertDuration = ConvertLineType; 13 | 14 | export type ParseDuration = ParseLineType; 15 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/values/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./attachment"; 2 | export * from "./attendee"; 3 | export * from "./date"; 4 | export * from "./duration"; 5 | export * from "./mail"; 6 | export * from "./organizer"; 7 | export * from "./recurrenceRule"; 8 | export * from "./timeStamp"; 9 | export * from "./trigger"; 10 | export * from "./weekdayNumber"; 11 | export * from "./integer"; 12 | export * from "./text"; 13 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", 3 | "assist": { 4 | "actions": { 5 | "source": { 6 | "organizeImports": "on" 7 | } 8 | } 9 | }, 10 | "linter": { 11 | "enabled": true, 12 | "rules": { 13 | "recommended": true, 14 | "complexity": { "noForEach": "off" }, 15 | "correctness": { "noUnusedImports": "error" } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/ts-ics/src/constants/symbols.ts: -------------------------------------------------------------------------------- 1 | export const CRLF_BREAK_REGEX = /\r\n/; 2 | export const BREAK_REGEX = /\r\n|\r|\n/; 3 | export const CRLF_BREAK = "\r\n"; 4 | export const LF_BREAK = "\n"; 5 | export const SEPARATOR = ":"; 6 | export const COMMA = ","; 7 | export const QUOTE = '"'; 8 | export const SEMICOLON = ";"; 9 | export const SPACE = " "; 10 | export const EQUAL_SIGN = "="; 11 | 12 | export const MAX_LINE_LENGTH = 75; 13 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/values/class.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertLineType, ParseLineType } from "../parse"; 2 | 3 | export const classTypes = ["PRIVATE", "PUBLIC", "CONFIDENTIAL"] as const; 4 | 5 | export type IcsClassTypes = typeof classTypes; 6 | export type IcsClassType = IcsClassTypes[number]; 7 | 8 | export type ConvertClass = ConvertLineType; 9 | 10 | export type ParseClassType = ParseLineType; 11 | -------------------------------------------------------------------------------- /packages/schema-zod/src/values/timeTransparent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | convertIcsTimeTransparent, 3 | type ParseTimeTransparent, 4 | timeTransparentTypes, 5 | } from "ts-ics"; 6 | import { z } from "zod"; 7 | 8 | export const zIcsTimeTransparentType = z.enum(timeTransparentTypes); 9 | 10 | export const parseIcsTimeTransparent: ParseTimeTransparent = (...props) => 11 | convertIcsTimeTransparent(zIcsTimeTransparentType, ...props); 12 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/utils/generateOptions.ts: -------------------------------------------------------------------------------- 1 | import { EQUAL_SIGN, SEMICOLON } from "@/constants"; 2 | 3 | export type GenerateIcsOptionsProps = { key: string; value: string }[]; 4 | 5 | export const generateIcsOptions = (options: GenerateIcsOptionsProps) => { 6 | if (options.length < 1) return; 7 | 8 | return `${options 9 | .map((option) => `${option.key}${EQUAL_SIGN}${option.value}`) 10 | .join(SEMICOLON)}`; 11 | }; 12 | -------------------------------------------------------------------------------- /packages/ts-ics/biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", 3 | "assist": { 4 | "actions": { 5 | "source": { 6 | "organizeImports": "on" 7 | } 8 | } 9 | }, 10 | "linter": { 11 | "enabled": true, 12 | "rules": { 13 | "recommended": true, 14 | "complexity": { "noForEach": "off" }, 15 | "correctness": { "noUnusedImports": "error" } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/schema-tests/biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", 3 | "assist": { 4 | "actions": { 5 | "source": { 6 | "organizeImports": "on" 7 | } 8 | } 9 | }, 10 | "linter": { 11 | "enabled": true, 12 | "rules": { 13 | "recommended": true, 14 | "complexity": { "noForEach": "off" }, 15 | "correctness": { "noUnusedImports": "error" } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/schema-zod/biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", 3 | "assist": { 4 | "actions": { 5 | "source": { 6 | "organizeImports": "on" 7 | } 8 | } 9 | }, 10 | "linter": { 11 | "enabled": true, 12 | "rules": { 13 | "recommended": true, 14 | "complexity": { "noForEach": "off" }, 15 | "correctness": { "noUnusedImports": "error" } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/parse/duration.test.ts: -------------------------------------------------------------------------------- 1 | import { convertIcsDuration } from "@/lib/parse/values/duration"; 2 | 3 | it("Test Ics IcsDuration Parse", async () => { 4 | const value = "P15DT5H0M20S"; 5 | 6 | expect(() => convertIcsDuration(undefined, { value })).not.toThrow(); 7 | }); 8 | 9 | it("Test Ics IcsDuration Parse", async () => { 10 | const value = "P7W"; 11 | 12 | expect(() => convertIcsDuration(undefined, { value })).not.toThrow(); 13 | }); 14 | -------------------------------------------------------------------------------- /packages/ts-ics/src/constants/keys/utils.ts: -------------------------------------------------------------------------------- 1 | export const invertKeys = ( 2 | keys: Record 3 | ): Record => 4 | Object.fromEntries( 5 | Object.entries(keys).map(([key, uppercaseKey]) => [uppercaseKey, key]) 6 | ); 7 | 8 | export const keysFromObject = ( 9 | keyObject: TKeyObject 10 | ) => Object.keys(keyObject) as (keyof TKeyObject)[]; 11 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/timezone/offsetToMilliseconds.ts: -------------------------------------------------------------------------------- 1 | export const timeZoneOffsetToMilliseconds = (offset: string) => { 2 | const sign = offset[0] === "+" ? 1 : -1; 3 | const hours = Number(offset.slice(1, 3)); 4 | const minutes = offset.length > 3 ? Number(offset.slice(3, 5)) : 0; 5 | const seconds = offset.length > 5 ? Number(offset.slice(5, 7)) : 0; 6 | const milliseconds = ((hours * 60 + minutes) * 60 + seconds) * 1000 * sign; 7 | 8 | return milliseconds; 9 | }; 10 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/utils/unescapeText.ts: -------------------------------------------------------------------------------- 1 | // https://icalendar.org/iCalendar-RFC-5545/3-3-11-text.html 2 | // According to RFC 5545: 3 | // ESCAPED-CHAR = ("\\" / "\;" / "\," / "\N" / "\n") 4 | // ; \\ encodes \, \N or \n encodes newline 5 | // ; \; encodes ;, \, encodes , 6 | export const unescapeTextString = (value: string) => { 7 | return value.replace(/\\(([,;\\])|([nN]))/g, (_m, _g1, g2) => { 8 | if (g2) return g2; 9 | return "\n"; 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/values/timeTransparent.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertLineType, ParseLineType } from "../parse"; 2 | 3 | export const timeTransparentTypes = ["TRANSPARENT", "OPAQUE"] as const; 4 | 5 | export type IcsTimeTransparentTypes = typeof timeTransparentTypes; 6 | export type IcsTimeTransparentType = IcsTimeTransparentTypes[number]; 7 | 8 | export type ConvertTimeTransparent = ConvertLineType; 9 | 10 | export type ParseTimeTransparent = ParseLineType; 11 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/generate/integer.test.ts: -------------------------------------------------------------------------------- 1 | import { generateIcsInteger } from "@/lib"; 2 | 3 | it("Integer is generated correctly", () => { 4 | const integerString = generateIcsInteger("PERCENT-COMPLETE", 25); 5 | 6 | expect(integerString).toContain("PERCENT-COMPLETE:25\r\n"); 7 | }); 8 | 9 | it("Integer is stripping floating point correctly", () => { 10 | const integerString = generateIcsInteger("PERCENT-COMPLETE", 13.2); 11 | 12 | expect(integerString).toContain("PERCENT-COMPLETE:13\r\n"); 13 | }); 14 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/values/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./attachment"; 2 | export * from "./attendee"; 3 | export * from "./date"; 4 | export * from "./duration"; 5 | export * from "./organizer"; 6 | export * from "./recurrenceRule"; 7 | export * from "./recurrenceId"; 8 | export * from "./status"; 9 | export * from "./trigger"; 10 | export * from "./weekday"; 11 | export * from "./class"; 12 | export * from "./timeTransparent"; 13 | export * from "./exceptionDate"; 14 | export * from "./integer"; 15 | export * from "./text"; 16 | -------------------------------------------------------------------------------- /packages/schema-zod/src/values/organizer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | convertIcsOrganizer, 3 | type IcsOrganizer, 4 | type ParseOrganizer, 5 | } from "ts-ics"; 6 | import { z } from "zod"; 7 | 8 | export const zIcsOrganizer: z.ZodType = z.object({ 9 | name: z.string().optional(), 10 | email: z.email(), 11 | dir: z.string().optional(), 12 | sentBy: z.email().optional(), 13 | }); 14 | 15 | export const parseIcsOrganizer: ParseOrganizer = (...props) => 16 | convertIcsOrganizer(zIcsOrganizer, ...props); 17 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/values/exceptionDate.ts: -------------------------------------------------------------------------------- 1 | import type { IcsExceptionDate } from "@/types/values/exceptionDate"; 2 | import { generateIcsTimeStamp } from "./timeStamp"; 3 | import type { IcsTimezone } from "@/types"; 4 | 5 | type GenerateIcsExceptionDateOptions = { 6 | timezones?: IcsTimezone[]; 7 | }; 8 | 9 | export const generateIcsExceptionDate = ( 10 | exceptionDate: IcsExceptionDate, 11 | key: string, 12 | options?: GenerateIcsExceptionDateOptions 13 | ) => generateIcsTimeStamp(key, exceptionDate, undefined, options); 14 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/values/text.ts: -------------------------------------------------------------------------------- 1 | import { generateIcsLine } from "../utils/addLine"; 2 | import { escapeTextString } from "../utils/escapeText"; 3 | import { 4 | generateIcsOptions, 5 | type GenerateIcsOptionsProps, 6 | } from "../utils/generateOptions"; 7 | 8 | export const generateIcsText = ( 9 | icsKey: string, 10 | value: string, 11 | options?: GenerateIcsOptionsProps 12 | ) => 13 | generateIcsLine( 14 | icsKey, 15 | escapeTextString(value), 16 | options ? generateIcsOptions(options) : undefined 17 | ); 18 | -------------------------------------------------------------------------------- /packages/schema-zod/src/values/weekDay.ts: -------------------------------------------------------------------------------- 1 | import { 2 | convertIcsWeekDay, 3 | type ParseWeekDay, 4 | type IcsWeekdayNumber, 5 | weekDays, 6 | } from "ts-ics"; 7 | import { z } from "zod"; 8 | 9 | export const zIcsWeekDay = z.enum(weekDays); 10 | 11 | export const zIcsWeekdayNumber: z.ZodType = 12 | z.object({ 13 | day: zIcsWeekDay, 14 | occurrence: z.number().optional(), 15 | }); 16 | 17 | export const parseIcsWeekDay: ParseWeekDay = (...props) => 18 | convertIcsWeekDay(zIcsWeekDay, ...props); 19 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/generate/fixtures/timezones.ts: -------------------------------------------------------------------------------- 1 | import type { IcsTimezone } from "@/types"; 2 | 3 | export const fictiveTimezone: IcsTimezone = { 4 | id: "Fictive/Timezone", 5 | props: [ 6 | { 7 | type: "DAYLIGHT", 8 | start: new Date(1997, 3, 30, 2), 9 | name: "(DST)", 10 | offsetFrom: "+0100", 11 | offsetTo: "+0200", 12 | }, 13 | { 14 | type: "STANDARD", 15 | start: new Date(1997, 10, 26, 3), 16 | name: "(DST)", 17 | offsetFrom: "+0200", 18 | offsetTo: "+0100", 19 | }, 20 | ], 21 | }; 22 | -------------------------------------------------------------------------------- /packages/schema-zod/src/values/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./date"; 2 | export * from "./exceptionDate"; 3 | export * from "./trigger"; 4 | export * from "./attendee"; 5 | export * from "./attendee"; 6 | export * from "./attachment"; 7 | export * from "./duration"; 8 | export * from "./class"; 9 | export * from "./organizer"; 10 | export * from "./recurrenceId"; 11 | export * from "./recurrenceRule"; 12 | export * from "./status"; 13 | export * from "./timeTransparent"; 14 | export * from "./trigger"; 15 | export * from "./weekDay"; 16 | export * from "./weekDayNumber"; 17 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/utils/escapeText.ts: -------------------------------------------------------------------------------- 1 | // https://icalendar.org/iCalendar-RFC-5545/3-3-11-text.html 2 | // According to RFC 5545: 3 | // ESCAPED-CHAR = ("\\" / "\;" / "\," / "\N" / "\n") 4 | // ; \\ encodes \, \N or \n encodes newline 5 | // ; \; encodes ;, \, encodes , 6 | // ; Note: A COLON character in a TEXT property value SHALL NOT be escaped 7 | export const escapeTextString = (inputString: string) => { 8 | return inputString.replace(/([\\;,])|(\n)/g, (_m, g1) => { 9 | if (g1) return `\\${g1}`; 10 | return "\\n"; 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /packages/schema-zod/src/values/recurrenceId.ts: -------------------------------------------------------------------------------- 1 | import { 2 | convertIcsRecurrenceId, 3 | type ParseRecurrenceId, 4 | type IcsRecurrenceId, 5 | } from "ts-ics"; 6 | import { z } from "zod"; 7 | import { zIcsDateObject } from "./date"; 8 | 9 | export const zIcsRecurrenceId: z.ZodType = 10 | z.object({ 11 | range: z.literal("THISANDFUTURE").optional(), 12 | value: zIcsDateObject, 13 | }); 14 | 15 | export const parseIcsRecurrenceId: ParseRecurrenceId = (...props) => 16 | convertIcsRecurrenceId(zIcsRecurrenceId, ...props); 17 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/values/organizer.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertOrganizer } from "@/types/values/organizer"; 2 | 3 | import { replaceMailTo } from "../utils/replaceMailTo"; 4 | import { standardValidate } from "../utils/standardValidate"; 5 | 6 | export const convertIcsOrganizer: ConvertOrganizer = (schema, line) => 7 | standardValidate(schema, { 8 | name: line.options?.CN, 9 | dir: line.options?.DIR, 10 | sentBy: line.options?.["SENT-BY"] 11 | ? replaceMailTo(line.options["SENT-BY"]) 12 | : undefined, 13 | email: replaceMailTo(line.value), 14 | }); 15 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/values/recurrenceId.ts: -------------------------------------------------------------------------------- 1 | import type { IcsRecurrenceId } from "@/types/values/recurrenceId"; 2 | 3 | import { convertIcsTimeStamp } from "./timeStamp"; 4 | import type { ConvertRecurrenceId } from "@/types"; 5 | import { standardValidate } from "../utils/standardValidate"; 6 | 7 | export const convertIcsRecurrenceId: ConvertRecurrenceId = ( 8 | schema, 9 | line, 10 | options 11 | ) => 12 | standardValidate(schema, { 13 | value: convertIcsTimeStamp(undefined, line, options), 14 | range: line.options?.RANGE as IcsRecurrenceId["range"], 15 | }); 16 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/generate/alarm.test.ts: -------------------------------------------------------------------------------- 1 | import { generateIcsAlarm } from "@/lib"; 2 | import { IcsAlarm } from "@/types"; 3 | 4 | it("Generate non standard value", () => { 5 | const alarm: IcsAlarm = { 6 | trigger: { type: "relative", value: { days: 2 } }, 7 | nonStandard: { wtf: "yeah" }, 8 | }; 9 | 10 | const alarmString = generateIcsAlarm<{ wtf: string }>(alarm, { 11 | nonStandard: { 12 | wtf: { name: "X-WTF", generate: (v) => ({ value: v.toString() }) }, 13 | }, 14 | }); 15 | 16 | expect(alarmString).toContain("X-WTF:yeah"); 17 | }); 18 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/generate/timezone.test.ts: -------------------------------------------------------------------------------- 1 | import { generateIcsTimezone } from "@/lib"; 2 | import { IcsTimezone } from "@/types"; 3 | 4 | it("Generate non standard value", () => { 5 | const timezone: IcsTimezone = { 6 | nonStandard: { wtf: "yeah" }, 7 | id: "1", 8 | props: [], 9 | }; 10 | 11 | const timezoneString = generateIcsTimezone<{ wtf: string }>(timezone, { 12 | nonStandard: { 13 | wtf: { name: "X-WTF", generate: (v) => ({ value: v.toString() }) }, 14 | }, 15 | }); 16 | 17 | expect(timezoneString).toContain("X-WTF:yeah"); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/values/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./attachment"; 2 | export * from "./attendee"; 3 | export * from "./date"; 4 | export * from "./duration"; 5 | export * from "./organizer"; 6 | export * from "./recurrenceId"; 7 | export * from "./recurrenceRule"; 8 | export * from "./timeStamp"; 9 | export * from "./trigger"; 10 | export * from "./weekDayNumber"; 11 | export * from "./class"; 12 | export * from "./timeTransparent"; 13 | export * from "./exceptionDate"; 14 | export * from "./weekDay"; 15 | export * from "./status"; 16 | export * from "./integer"; 17 | export * from "./text"; 18 | -------------------------------------------------------------------------------- /packages/schema-zod/src/values/duration.ts: -------------------------------------------------------------------------------- 1 | import { 2 | convertIcsDuration, 3 | type IcsDuration, 4 | type ParseDuration, 5 | } from "ts-ics"; 6 | import { z } from "zod"; 7 | 8 | export const zIcsDuration: z.ZodType = z.object({ 9 | before: z.boolean().optional(), 10 | weeks: z.number().optional(), 11 | days: z.number().optional(), 12 | hours: z.number().optional(), 13 | minutes: z.number().optional(), 14 | seconds: z.number().optional(), 15 | }); 16 | 17 | export const parseIcsDuration: ParseDuration = (...props) => 18 | convertIcsDuration(zIcsDuration, ...props); 19 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/values/exceptionDate.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertExceptionDates } from "@/types/values/exceptionDate"; 2 | import { convertIcsTimeStamp } from "./timeStamp"; 3 | import { standardValidate } from "../utils/standardValidate"; 4 | 5 | export const convertIcsExceptionDates: ConvertExceptionDates = ( 6 | schema, 7 | line, 8 | options 9 | ) => 10 | standardValidate( 11 | schema, 12 | line.value 13 | .split(",") 14 | .map((value) => 15 | convertIcsTimeStamp( 16 | undefined, 17 | { value, options: line.options }, 18 | options 19 | ) 20 | ) 21 | ); 22 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/values/recurrenceId.ts: -------------------------------------------------------------------------------- 1 | import type { IcsDateObject } from "./date"; 2 | import type { ConvertLineType, ParseLineType } from "../parse"; 3 | import type { IcsTimezone } from "../components/timezone"; 4 | 5 | export type IcsRecurrenceId = { 6 | range?: "THISANDFUTURE"; 7 | value: IcsDateObject; 8 | }; 9 | 10 | export type ParseRecurrenceIdOptions = { timezones?: IcsTimezone[] }; 11 | 12 | export type ConvertRecurrenceId = ConvertLineType< 13 | IcsRecurrenceId, 14 | ParseRecurrenceIdOptions 15 | >; 16 | 17 | export type ParseRecurrenceId = ParseLineType< 18 | IcsRecurrenceId, 19 | ParseRecurrenceIdOptions 20 | >; 21 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/recurrence/iterateBy/month.ts: -------------------------------------------------------------------------------- 1 | import { getMonth, setMonth } from "date-fns"; 2 | 3 | import type { IcsRecurrenceRule } from "@/types"; 4 | 5 | export const iterateByMonth = ( 6 | rule: IcsRecurrenceRule, 7 | dateGroups: Date[][], 8 | byMonth: NonNullable 9 | ): Date[][] => { 10 | if (rule.frequency === "YEARLY") { 11 | return dateGroups.map((dates) => 12 | dates.flatMap((date) => byMonth.map((month) => setMonth(date, month))) 13 | ); 14 | } 15 | 16 | return dateGroups.map((dates) => 17 | dates.filter((date) => byMonth.includes(getMonth(date))) 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "type": "module", 4 | "workspaces": [ 5 | "apps/*", 6 | "packages/*" 7 | ], 8 | "private": true, 9 | "scripts": { 10 | "type-check": "turbo run type-check", 11 | "lint": "turbo run lint", 12 | "dev": "turbo run dev", 13 | "build": "turbo run build", 14 | "test": "turbo run test", 15 | "benchmark": "node benchmark/index.js" 16 | }, 17 | "author": "Neuvernetzung Medienagentur UG", 18 | "packageManager": "npm@11.6.0", 19 | "devDependencies": { 20 | "@changesets/cli": "^2.29.7", 21 | "@types/node": "^24.5.1", 22 | "tinybench": "^5.0.1", 23 | "turbo": "^2.5.6" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/values/exceptionDate.ts: -------------------------------------------------------------------------------- 1 | import type { IcsDateObject } from "./date"; 2 | import type { ConvertLineType, ParseLineType } from "../parse"; 3 | import type { IcsTimezone } from "../components/timezone"; 4 | 5 | export type IcsExceptionDate = IcsDateObject; 6 | 7 | export type IcsExceptionDates = IcsExceptionDate[]; 8 | 9 | export type ParseExceptionDatesOptions = { timezones?: IcsTimezone[] }; 10 | 11 | export type ConvertExceptionDates = ConvertLineType< 12 | IcsExceptionDates, 13 | ParseExceptionDatesOptions 14 | >; 15 | 16 | export type ParseExceptionDates = ParseLineType< 17 | IcsExceptionDates, 18 | ParseExceptionDatesOptions 19 | >; 20 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/parse/recurrenceId.test.ts: -------------------------------------------------------------------------------- 1 | import { getLine } from "@/lib/parse/utils/line"; 2 | 3 | import { convertIcsRecurrenceId } from "@/lib/parse/values/recurrenceId"; 4 | 5 | it("Test Ics Recurrence Id Parse", async () => { 6 | const rId = `RECURRENCE-ID;VALUE=DATE:19960401`; 7 | 8 | const { line } = getLine(rId); 9 | 10 | expect(() => convertIcsRecurrenceId(undefined, line)).not.toThrow(); 11 | }); 12 | 13 | it("Test Ics Recurrence Id Parse", async () => { 14 | const rId = `RECURRENCE-ID;RANGE=THISANDFUTURE:19960120T120000Z`; 15 | 16 | const { line } = getLine(rId); 17 | 18 | expect(() => convertIcsRecurrenceId(undefined, line)).not.toThrow(); 19 | }); 20 | -------------------------------------------------------------------------------- /packages/schema-zod/src/values/exceptionDate.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { zIcsDateObject } from "./date"; 3 | import { 4 | type IcsExceptionDate, 5 | type IcsExceptionDates, 6 | convertIcsExceptionDates, 7 | type ParseExceptionDates, 8 | } from "ts-ics"; 9 | 10 | export const zIcsExceptionDate: z.ZodType = 11 | zIcsDateObject; 12 | 13 | export const zIcsExceptionDates: z.ZodType< 14 | IcsExceptionDates, 15 | IcsExceptionDates 16 | > = z.array(zIcsExceptionDate); 17 | 18 | export const parseIcsExceptionDate: ParseExceptionDates = (...props) => 19 | convertIcsExceptionDates(zIcsExceptionDates, ...props); 20 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/recurrence/iterateBy/weekNo.ts: -------------------------------------------------------------------------------- 1 | import { setWeek } from "date-fns"; 2 | 3 | import type { IcsRecurrenceRule, WeekDayNumber } from "@/types"; 4 | 5 | export const iterateByWeekNo = ( 6 | rule: IcsRecurrenceRule, 7 | dateGroups: Date[][], 8 | byWeekNo: NonNullable, 9 | weekStartsOn: WeekDayNumber 10 | ): Date[][] => { 11 | if (rule.frequency === "YEARLY") { 12 | return dateGroups.map((dates) => 13 | dates.flatMap((date) => 14 | byWeekNo.map((weekNo) => setWeek(date, weekNo, { weekStartsOn })) 15 | ) 16 | ); 17 | } 18 | 19 | return dateGroups; // Nicht verfügbar für alle anderen Frequencies 20 | }; 21 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/timezone/getOffsetFromTimezoneId.test.ts: -------------------------------------------------------------------------------- 1 | import { getOffsetFromTimezoneId } from "./getOffsetFromTimezoneId"; 2 | 3 | it("Test getOffsetFromTimezoneId", async () => { 4 | const date = new Date(2023, 6, 2, 14, 30); 5 | 6 | const offset = getOffsetFromTimezoneId("America/New_York", date); 7 | 8 | expect(offset).toBe(-14400000); 9 | }); 10 | 11 | it("Test getOffsetFromTimezoneId - dont throw when custom not provided timezone", async () => { 12 | // https://github.com/Neuvernetzung/ts-ics/issues/104 13 | const date = new Date(2023, 6, 2, 14, 30); 14 | 15 | expect(() => 16 | getOffsetFromTimezoneId("Customized unknown Time Zone", date) 17 | ).not.toThrow(); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/timezone/getOffsetFromTimezoneId.ts: -------------------------------------------------------------------------------- 1 | export const getOffsetFromTimezoneId = ( 2 | timeZone: string, 3 | date: Date 4 | ): number => { 5 | const defaultLocale = "en-US"; // Muss en-US sein, da bei anderen Timezones z.B. de-DE new Date() parsen nicht funktioniert. 6 | 7 | const utcDate = new Date( 8 | date.toLocaleString(defaultLocale, { timeZone: "UTC" }) 9 | ); 10 | 11 | try { 12 | const tzDate = new Date(date.toLocaleString(defaultLocale, { timeZone })); 13 | return tzDate.getTime() - utcDate.getTime(); 14 | } catch { 15 | // Fallback to local timezone - https://github.com/Neuvernetzung/ts-ics/issues/104 16 | 17 | return date.getTime() - utcDate.getTime(); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/values/weekday.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertLineType, ParseLineType } from "../parse"; 2 | 3 | export const weekDays = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"] as const; 4 | 5 | export type IcsWeekDays = typeof weekDays; 6 | export type IcsWeekDay = IcsWeekDays[number]; 7 | 8 | export type ConvertWeekDay = ConvertLineType; 9 | 10 | export type ParseWeekDay = ParseLineType; 11 | 12 | export type IcsWeekdayNumber = { 13 | day: IcsWeekDay; 14 | occurrence?: number; 15 | }; 16 | 17 | export type WeekDayNumber = 0 | 1 | 2 | 3 | 4 | 5 | 6; 18 | 19 | export type ConvertWeekDayNumber = ConvertLineType; 20 | 21 | export type ParseWeekDayNumber = ParseLineType; 22 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/parse/attachment.test.ts: -------------------------------------------------------------------------------- 1 | import { convertIcsAttachment } from "@/lib/parse/values/attachment"; 2 | import { getLine } from "@/lib/parse/utils/line"; 3 | 4 | it("Test Ics IcsAttachment Parse", async () => { 5 | const attachment = `ATTACH:CID:jsmith.part3.960817T083000.xyzMail@example.com`; 6 | 7 | const { line } = getLine(attachment); 8 | 9 | expect(() => convertIcsAttachment(undefined, line)).not.toThrow(); 10 | }); 11 | 12 | it("Test Ics IcsAttachment Parse", async () => { 13 | const attachment = `ATTACH;FMTTYPE=application/postscript:ftp://example.com/pub/reports/r-960812.ps`; 14 | 15 | const { line } = getLine(attachment); 16 | 17 | expect(() => convertIcsAttachment(undefined, line)).not.toThrow(); 18 | }); 19 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/utils/standardValidate.ts: -------------------------------------------------------------------------------- 1 | import type { StandardSchemaV1 } from "@standard-schema/spec"; 2 | 3 | export const standardValidate = ( 4 | schema: T | undefined, 5 | input: StandardSchemaV1.InferInput 6 | ): StandardSchemaV1.InferOutput => { 7 | if (schema === undefined) return input; 8 | 9 | const result = schema["~standard"].validate(input); 10 | if (result instanceof Promise) { 11 | throw new TypeError("Schema validation must be synchronous"); 12 | } 13 | 14 | // if the `issues` field exists, the validation failed 15 | if (result.issues) { 16 | throw new Error(JSON.stringify(result.issues, null, 2)); 17 | } 18 | 19 | return result.value; 20 | }; 21 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/values/status.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ConvertEventStatus, 3 | ConvertJournalStatus, 4 | ConvertTodoStatus, 5 | IcsEventStatusType, 6 | IcsJournalStatusType, 7 | IcsTodoStatusType, 8 | } from "@/types"; 9 | import { standardValidate } from "../utils/standardValidate"; 10 | 11 | export const convertIcsEventStatus: ConvertEventStatus = (schema, line) => 12 | standardValidate(schema, line.value as IcsEventStatusType); 13 | 14 | export const convertIcsTodoStatus: ConvertTodoStatus = (schema, line) => 15 | standardValidate(schema, line.value as IcsTodoStatusType); 16 | 17 | export const convertIcsJournalStatus: ConvertJournalStatus = (schema, line) => 18 | standardValidate(schema, line.value as IcsJournalStatusType); 19 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/recurrence/iterateBy/hour.ts: -------------------------------------------------------------------------------- 1 | import { getHours, setHours } from "date-fns"; 2 | 3 | import type { IcsRecurrenceRule } from "@/types"; 4 | 5 | export const iterateByHour = ( 6 | rule: IcsRecurrenceRule, 7 | dateGroups: Date[][], 8 | byHour: NonNullable 9 | ): Date[][] => { 10 | if ( 11 | rule.frequency === "YEARLY" || 12 | rule.frequency === "MONTHLY" || 13 | rule.frequency === "WEEKLY" || 14 | rule.frequency === "DAILY" 15 | ) { 16 | return dateGroups.map((dates) => 17 | dates.flatMap((date) => byHour.map((hour) => setHours(date, hour))) 18 | ); 19 | } 20 | 21 | return dateGroups.map((dates) => 22 | dates.filter((date) => byHour.includes(getHours(date))) 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /packages/schema-zod/src/values/attendee.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type IcsAttendee, 3 | attendeePartStatusTypes, 4 | convertIcsAttendee, 5 | type ParseAttendee, 6 | } from "ts-ics"; 7 | import { z } from "zod"; 8 | 9 | export const zIcsAttendee: z.ZodType = z.object({ 10 | email: z.email(), 11 | name: z.string().optional(), 12 | member: z.email().optional(), 13 | delegatedFrom: z.email().optional(), 14 | role: z.string().optional(), 15 | partstat: z.enum(attendeePartStatusTypes).optional(), 16 | dir: z.url().optional(), 17 | sentBy: z.email().optional(), 18 | rsvp: z.boolean().optional(), 19 | }); 20 | 21 | export const parseIcsAttendee: ParseAttendee = (...props) => 22 | convertIcsAttendee(zIcsAttendee, ...props); 23 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/values/weekDayNumber.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertWeekDayNumber, IcsWeekDay } from "@/types/values/weekday"; 2 | import { standardValidate } from "../utils/standardValidate"; 3 | import type { Line } from "@/types"; 4 | 5 | const __convertIcsWeekDayNumber = (value: Line["value"]) => { 6 | const isWeekdayOnly = value.length === 2; 7 | 8 | if (isWeekdayOnly) return { day: value as IcsWeekDay }; 9 | 10 | const occurrence = value.slice(0, -2); 11 | const day = value.replace(occurrence, ""); 12 | 13 | return { day: day as IcsWeekDay, occurrence: Number(occurrence) }; 14 | }; 15 | 16 | export const convertIcsWeekDayNumber: ConvertWeekDayNumber = (schema, line) => 17 | standardValidate(schema, __convertIcsWeekDayNumber(line.value)); 18 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/utils/addLine.ts: -------------------------------------------------------------------------------- 1 | import { CRLF_BREAK } from "@/constants"; 2 | import type { IcsComponent } from "@/constants/keys"; 3 | 4 | export const formatIcsLine = (line: string) => `${line}${CRLF_BREAK}`; 5 | 6 | export const generateIcsLine = ( 7 | key: string, 8 | value: string | number | undefined | null, 9 | options?: string 10 | ) => { 11 | if (!options) return formatIcsLine(`${key}:${value}`); 12 | 13 | if (value === undefined || value === null) return ""; 14 | 15 | return formatIcsLine(`${key};${options}:${value}`); 16 | }; 17 | 18 | export const getIcsStartLine = (component: IcsComponent) => 19 | formatIcsLine(`BEGIN:${component}`); 20 | 21 | export const getIcsEndLine = (component: IcsComponent) => 22 | formatIcsLine(`END:${component}`); 23 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/values/recurrenceId.ts: -------------------------------------------------------------------------------- 1 | import type { IcsRecurrenceId, IcsTimezone } from "@/types"; 2 | 3 | import { generateIcsTimeStamp } from "./timeStamp"; 4 | 5 | type GenerateIcsExceptionDateOptions = { 6 | timezones?: IcsTimezone[]; 7 | }; 8 | 9 | export const generateIcsRecurrenceId = ( 10 | value: IcsRecurrenceId, 11 | options?: GenerateIcsExceptionDateOptions 12 | ) => { 13 | let icsString = ""; 14 | 15 | icsString += generateIcsTimeStamp( 16 | "RECURRENCE-ID", 17 | value.value, 18 | value.range 19 | ? [ 20 | { 21 | key: "RANGE", 22 | value: value.range, 23 | }, 24 | ] 25 | : undefined, 26 | options 27 | ); 28 | 29 | return icsString; 30 | }; 31 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/parse.ts: -------------------------------------------------------------------------------- 1 | import type { StandardSchemaV1 } from "@standard-schema/spec"; 2 | import type { Line } from "./line"; 3 | 4 | export type ConvertComponentType = ( 5 | schema: StandardSchemaV1 | undefined, 6 | lines: string, 7 | options?: TOptions 8 | ) => TType; 9 | 10 | export type ParseComponentType = ( 11 | lines: string, 12 | options?: TOptions 13 | ) => TType; 14 | 15 | export type ConvertLineType = ( 16 | schema: StandardSchemaV1 | undefined, 17 | line: Line, 18 | options?: TOptions 19 | ) => TType; 20 | 21 | export type ParseLineType = ( 22 | line: Line, 23 | options?: TOptions 24 | ) => TType; 25 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/timezone/extendProps.ts: -------------------------------------------------------------------------------- 1 | import type { IcsTimezoneProp } from "@/types"; 2 | 3 | import { extendByRecurrenceRule } from "../recurrence"; 4 | 5 | export const extendTimezoneProps = ( 6 | date: Date, 7 | timezoneProps: IcsTimezoneProp[] 8 | ): IcsTimezoneProp[] => 9 | timezoneProps.flatMap((timezoneProp) => { 10 | if (!timezoneProp.recurrenceRule) return timezoneProp; 11 | if ( 12 | timezoneProp.recurrenceRule.until && 13 | timezoneProp.recurrenceRule.until.date < date 14 | ) 15 | return timezoneProp; 16 | 17 | const extended = extendByRecurrenceRule(timezoneProp.recurrenceRule, { 18 | start: timezoneProp.start, 19 | end: date, 20 | }).map((date) => ({ ...timezoneProp, start: date })); 21 | 22 | return extended; 23 | }); 24 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/values/attachment.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ConvertAttachment, 3 | IcsAttachment, 4 | } from "@/types/values/attachment"; 5 | import { standardValidate } from "../utils/standardValidate"; 6 | 7 | export const convertIcsAttachment: ConvertAttachment = (schema, line) => { 8 | const attachment: IcsAttachment = 9 | line.options?.VALUE === "BINARY" 10 | ? { 11 | type: "binary", 12 | encoding: 13 | (line.options?.ENCODING as IcsAttachment["encoding"]) || "BASE64", 14 | binary: line.value, 15 | value: line.options?.VALUE, 16 | } 17 | : { 18 | type: "uri", 19 | url: line.value, 20 | formatType: line.options?.FMTTYPE, 21 | }; 22 | 23 | return standardValidate(schema, attachment); 24 | }; 25 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/recurrence/iterateBy/minute.ts: -------------------------------------------------------------------------------- 1 | import { getMinutes, setMinutes } from "date-fns"; 2 | 3 | import type { IcsRecurrenceRule } from "@/types"; 4 | 5 | export const iterateByMinute = ( 6 | rule: IcsRecurrenceRule, 7 | dateGroups: Date[][], 8 | byMinute: NonNullable 9 | ): Date[][] => { 10 | if ( 11 | rule.frequency === "YEARLY" || 12 | rule.frequency === "MONTHLY" || 13 | rule.frequency === "WEEKLY" || 14 | rule.frequency === "DAILY" || 15 | rule.frequency === "HOURLY" 16 | ) { 17 | return dateGroups.map((dates) => 18 | dates.flatMap((date) => byMinute.map((hour) => setMinutes(date, hour))) 19 | ); 20 | } 21 | 22 | return dateGroups.map((dates) => 23 | dates.filter((date) => byMinute.includes(getMinutes(date))) 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/values/attendee.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertLineType, ParseLineType } from "../parse"; 2 | 3 | export const attendeePartStatusTypes = [ 4 | "NEEDS-ACTION", 5 | "ACCEPTED", 6 | "DECLINED", 7 | "TENTATIVE", 8 | "DELEGATED", 9 | ] as const; 10 | 11 | export type IcsAttendeePartStatusTypes = typeof attendeePartStatusTypes; 12 | export type IcsAttendeePartStatusType = IcsAttendeePartStatusTypes[number]; 13 | 14 | export type IcsAttendee = { 15 | email: string; 16 | name?: string; 17 | member?: string; 18 | delegatedFrom?: string; 19 | role?: string; 20 | partstat?: IcsAttendeePartStatusType; 21 | dir?: string; 22 | sentBy?: string; 23 | rsvp?: boolean; 24 | }; 25 | 26 | export type ConvertAttendee = ConvertLineType; 27 | 28 | export type ParseAttendee = ParseLineType; 29 | -------------------------------------------------------------------------------- /packages/schema-zod/src/values/date.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type IcsDateObject, 3 | dateObjectTypes, 4 | convertIcsDateTime, 5 | convertIcsDate, 6 | type ParseDate, 7 | } from "ts-ics"; 8 | import { z } from "zod"; 9 | 10 | export const zIcsDateObjectTzProps = z.object({ 11 | date: z.date(), 12 | timezone: z.string(), 13 | tzoffset: z.string(), 14 | }); 15 | 16 | export const zIcsDateObject: z.ZodType = z.object( 17 | { 18 | date: z.date(), 19 | type: z.enum(dateObjectTypes).optional(), 20 | local: zIcsDateObjectTzProps.optional(), 21 | } 22 | ); 23 | 24 | export const parseIcsDate: ParseDate = (...props) => 25 | convertIcsDate(z.date(), ...props); 26 | 27 | export const parseIcsDateTime: ParseDate = (...props) => 28 | convertIcsDateTime(z.date(), ...props); 29 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/values/organizer.ts: -------------------------------------------------------------------------------- 1 | import type { IcsOrganizer } from "@/types/values/organizer"; 2 | 3 | import { generateIcsMail } from "./mail"; 4 | import { generateIcsLine } from "../utils/addLine"; 5 | import { generateIcsOptions } from "../utils/generateOptions"; 6 | 7 | export const generateIcsOrganizer = (organizer: IcsOrganizer) => { 8 | const options = generateIcsOptions( 9 | [ 10 | organizer.dir && { key: "DIR", value: `"${organizer.dir}"` }, 11 | organizer.name && { key: "CN", value: organizer.name }, 12 | organizer.sentBy && { 13 | key: "SENT-BY", 14 | value: generateIcsMail(organizer.sentBy), 15 | }, 16 | ].filter((v) => !!v) 17 | ); 18 | 19 | return generateIcsLine( 20 | "ORGANIZER", 21 | generateIcsMail(organizer.email), 22 | options 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/duration/eventEndFromDuration.ts: -------------------------------------------------------------------------------- 1 | import { addDays, addHours, addMinutes, addSeconds, addWeeks } from "date-fns"; 2 | 3 | import type { IcsDuration } from "../../types"; 4 | 5 | export const getEventEndFromDuration = (start: Date, duration: IcsDuration) => { 6 | const directionMultiplier = duration.before ? -1 : 1; 7 | 8 | const seconds = (duration.seconds || 0) * directionMultiplier; 9 | const minutes = (duration.minutes || 0) * directionMultiplier; 10 | const hours = (duration.hours || 0) * directionMultiplier; 11 | const days = (duration.days || 0) * directionMultiplier; 12 | const weeks = (duration.weeks || 0) * directionMultiplier; 13 | 14 | return addWeeks( 15 | addDays( 16 | addHours(addMinutes(addSeconds(start, seconds), minutes), hours), 17 | days 18 | ), 19 | weeks 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/recurrence/iterateBy/second.ts: -------------------------------------------------------------------------------- 1 | import { getSeconds, setSeconds } from "date-fns"; 2 | 3 | import type { IcsRecurrenceRule } from "@/types"; 4 | 5 | export const iterateBySecond = ( 6 | rule: IcsRecurrenceRule, 7 | dateGroups: Date[][], 8 | bySecond: NonNullable 9 | ): Date[][] => { 10 | if ( 11 | rule.frequency === "YEARLY" || 12 | rule.frequency === "MONTHLY" || 13 | rule.frequency === "WEEKLY" || 14 | rule.frequency === "DAILY" || 15 | rule.frequency === "HOURLY" || 16 | rule.frequency === "MINUTELY" 17 | ) { 18 | return dateGroups.map((dates) => 19 | dates.flatMap((date) => bySecond.map((hour) => setSeconds(date, hour))) 20 | ); 21 | } 22 | 23 | return dateGroups.map((dates) => 24 | dates.filter((date) => bySecond.includes(getSeconds(date))) 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /packages/ts-ics/src/constants/keys/timezone.ts: -------------------------------------------------------------------------------- 1 | import type { IcsTimezone } from "@/types"; 2 | import { invertKeys, keysFromObject } from "./utils"; 3 | 4 | export type IcsTimezoneObjectKey = Exclude< 5 | keyof IcsTimezone, 6 | "props" | "nonStandard" 7 | >; 8 | export type IcsTimezoneObjectKeys = IcsTimezoneObjectKey[]; 9 | 10 | export const VTIMEZONE_TO_KEYS = { 11 | id: "TZID", 12 | lastModified: "LAST-MODIFIED", 13 | url: "TZURL", 14 | } as const satisfies Record; 15 | 16 | export const VTIMEZONE_TO_OBJECT_KEYS = invertKeys(VTIMEZONE_TO_KEYS); 17 | 18 | export type IcsTimezoneKey = keyof typeof VTIMEZONE_TO_OBJECT_KEYS; 19 | export type IcsTimezoneKeys = IcsTimezoneKey[]; 20 | 21 | export const VTIMEZONE_KEYS = keysFromObject(VTIMEZONE_TO_OBJECT_KEYS); 22 | 23 | export const VTIMEZONE_OBJECT_KEYS = keysFromObject(VTIMEZONE_TO_KEYS); 24 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/recurrence/iterateBy/yearDay.ts: -------------------------------------------------------------------------------- 1 | import { getYear, setDayOfYear } from "date-fns"; 2 | 3 | import type { IcsRecurrenceRule } from "@/types"; 4 | 5 | export const iterateByYearDay = ( 6 | rule: IcsRecurrenceRule, 7 | dateGroups: Date[][], 8 | byYearday: NonNullable 9 | ): Date[][] => { 10 | if (rule.frequency === "YEARLY") { 11 | return dateGroups.map((dates) => 12 | dates.flatMap((date) => byYearday.map((year) => setDayOfYear(date, year))) 13 | ); 14 | } 15 | 16 | if ( 17 | rule.frequency === "MONTHLY" || 18 | rule.frequency === "WEEKLY" || 19 | rule.frequency === "DAILY" 20 | ) 21 | return dateGroups; // Nicht verfügbar für Monthly, Weekly und Daily 22 | 23 | return dateGroups.map((dates) => 24 | dates.filter((date) => byYearday.includes(getYear(date))) 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/parse/trigger.test.ts: -------------------------------------------------------------------------------- 1 | import { getLine } from "@/lib/parse/utils/line"; 2 | 3 | import { convertIcsTrigger } from "@/lib/parse/values/trigger"; 4 | 5 | it("Test Ics IcsTrigger Parse", async () => { 6 | const trigger = `TRIGGER:-PT15M`; 7 | 8 | const { line } = getLine(trigger); 9 | 10 | expect(() => convertIcsTrigger(undefined, line)).not.toThrow(); 11 | }); 12 | 13 | it("Test Ics IcsTrigger Parse", async () => { 14 | const trigger = `TRIGGER;RELATED=END:PT5M`; 15 | 16 | const { line } = getLine(trigger); 17 | 18 | expect(() => convertIcsTrigger(undefined, line)).not.toThrow(); 19 | }); 20 | 21 | it("Test Ics IcsTrigger Parse", async () => { 22 | const trigger = `TRIGGER;VALUE=DATE-TIME:19980101T050000Z`; 23 | 24 | const { line } = getLine(trigger); 25 | 26 | expect(() => convertIcsTrigger(undefined, line)).not.toThrow(); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/parse/date.test.ts: -------------------------------------------------------------------------------- 1 | import { generateIcsUtcDateTime } from "@/lib"; 2 | import { convertIcsDate, convertIcsDateTime } from "@/lib/parse/values/date"; 3 | import { setMilliseconds } from "date-fns"; 4 | 5 | it("Test Ics Date Time Parse", async () => { 6 | const value = "20230118T073000Z"; 7 | 8 | expect(() => convertIcsDateTime(undefined, { value })).not.toThrow(); 9 | }); 10 | 11 | it("Test Ics Date Parse", async () => { 12 | const value = "20230118"; 13 | 14 | expect(() => convertIcsDate(undefined, { value })).not.toThrow(); 15 | }); 16 | 17 | it("Strip Milliseconds - Milliseconds are not allowed in Ics", async () => { 18 | const date = new Date("2023-01-18T07:30:00.123Z"); 19 | 20 | const icsDate = generateIcsUtcDateTime(date); 21 | 22 | expect(convertIcsDateTime(undefined, { value: icsDate })).toEqual( 23 | setMilliseconds(date, 0) 24 | ); 25 | }); 26 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Type-check and test 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | - "!master" 8 | 9 | permissions: {} 10 | 11 | jobs: 12 | publish: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v5 17 | with: 18 | fetch-depth: 0 19 | persist-credentials: false 20 | 21 | - name: NodeJs aufsetzen 22 | uses: actions/setup-node@v5 23 | with: 24 | node-version: 24 25 | cache: "npm" 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Build 31 | run: npm run build 32 | 33 | - name: Lint 34 | run: npm run lint 35 | 36 | - name: Type-check 37 | run: npm run type-check 38 | 39 | - name: Test 40 | run: npm run test 41 | -------------------------------------------------------------------------------- /packages/ts-ics/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { JestConfigWithTsJest } from "ts-jest"; 2 | 3 | const config: JestConfigWithTsJest = { 4 | // The root of your source code, typically /src 5 | // `` is a token Jest substitutes 6 | testEnvironment: "jsdom", 7 | roots: ["/src", "/tests"], 8 | 9 | // Jest transformations -- this adds support for TypeScript 10 | // using ts-jest 11 | transform: { 12 | "^.+\\.tsx?$": "ts-jest", 13 | }, 14 | // Test spec file resolution pattern 15 | // Matches parent folder `tests` and filename 16 | // should contain `test` or `spec`. 17 | testRegex: "(/tests/(\\.|/)(test|spec|)|(\\.|/)(test|spec|))\\.tsx?$", 18 | 19 | // Module file extensions for importing 20 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 21 | moduleNameMapper: { 22 | "^@/(.*)$": "/src/$1", 23 | }, 24 | }; 25 | 26 | export default config; 27 | -------------------------------------------------------------------------------- /packages/schema-tests/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { JestConfigWithTsJest } from "ts-jest"; 2 | 3 | const config: JestConfigWithTsJest = { 4 | // The root of your source code, typically /src 5 | // `` is a token Jest substitutes 6 | testEnvironment: "jsdom", 7 | roots: ["/src", "/tests"], 8 | 9 | // Jest transformations -- this adds support for TypeScript 10 | // using ts-jest 11 | transform: { 12 | "^.+\\.tsx?$": "ts-jest", 13 | }, 14 | // Test spec file resolution pattern 15 | // Matches parent folder `tests` and filename 16 | // should contain `test` or `spec`. 17 | testRegex: "(/tests/(\\.|/)(test|spec|)|(\\.|/)(test|spec|))\\.tsx?$", 18 | 19 | // Module file extensions for importing 20 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 21 | moduleNameMapper: { 22 | "^@/(.*)$": "/src/$1", 23 | }, 24 | }; 25 | 26 | export default config; 27 | -------------------------------------------------------------------------------- /packages/schema-zod/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ts-ics/schema-zod", 3 | "author": "Neuvernetzung Medienagentur UG", 4 | "version": "2.4.0", 5 | "description": "Zod schema validators for ts-ics", 6 | "type": "module", 7 | "main": "dist/index.cjs", 8 | "module": "dist/index.js", 9 | "scripts": { 10 | "lint": "npx @biomejs/biome lint ./src --write", 11 | "type-check": "tsc --noEmit", 12 | "dev": "tsup --watch", 13 | "build": "tsup" 14 | }, 15 | "license": "MIT", 16 | "dependencies": { 17 | "zod": "^4.1.9", 18 | "ts-ics": "^2.4.0" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^24.5.1", 22 | "@biomejs/biome": "^2.2.4", 23 | "tsup": "^8.5.0", 24 | "typescript": "^5.9.2" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/Neuvernetzung/ts-ics.git" 29 | }, 30 | "publishConfig": { 31 | "access": "public" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/parse/weekdayNumber.test.ts: -------------------------------------------------------------------------------- 1 | import { COMMA } from "@/constants"; 2 | 3 | import { convertIcsWeekDayNumber } from "@/lib/parse/values/weekDayNumber"; 4 | 5 | it("Test Ics Weekday Number Parse", async () => { 6 | const weekdayNumber = `MO,TU,WE,TH,FR,SA,SU`; 7 | 8 | expect(() => 9 | weekdayNumber 10 | .split(COMMA) 11 | .forEach((w) => convertIcsWeekDayNumber(undefined, { value: w })) 12 | ).not.toThrow(); 13 | }); 14 | 15 | it("Test Ics Weekday Number Parse", async () => { 16 | const weekdayNumber = `1SU`; 17 | 18 | expect(() => 19 | convertIcsWeekDayNumber(undefined, { value: weekdayNumber }) 20 | ).not.toThrow(); 21 | }); 22 | 23 | it("Test Ics Weekday Number Parse", async () => { 24 | const weekdayNumber = `-1MO`; 25 | 26 | expect(() => 27 | convertIcsWeekDayNumber(undefined, { value: weekdayNumber }) 28 | ).not.toThrow(); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/ts-ics/src/constants/keys/alarm.ts: -------------------------------------------------------------------------------- 1 | import type { IcsAlarm } from "@/types"; 2 | import { invertKeys, keysFromObject } from "./utils"; 3 | 4 | export type IcsAlarmObjectKey = Exclude; 5 | export type IcsAlarmObjectKeys = IcsAlarmObjectKey[]; 6 | 7 | export const VALARM_TO_KEYS = { 8 | action: "ACTION", 9 | description: "DESCRIPTION", 10 | duration: "DURATION", 11 | repeat: "REPEAT", 12 | summary: "SUMMARY", 13 | trigger: "TRIGGER", 14 | attachments: "ATTACH", 15 | attendees: "ATTENDEE", 16 | } as const satisfies Record; 17 | 18 | export const VALARM_TO_OBJECT_KEYS = invertKeys(VALARM_TO_KEYS); 19 | 20 | export type IcsAlarmKey = keyof typeof VALARM_TO_OBJECT_KEYS; 21 | export type IcsAlarmKeys = IcsAlarmKey[]; 22 | 23 | export const VALARM_KEYS = keysFromObject(VALARM_TO_OBJECT_KEYS); 24 | 25 | export const VALARM_OBJECT_KEYS = keysFromObject(VALARM_TO_KEYS); 26 | -------------------------------------------------------------------------------- /packages/schema-zod/src/components/timezone.ts: -------------------------------------------------------------------------------- 1 | import { 2 | convertIcsTimezone, 3 | type ParseTimezone, 4 | type IcsTimezone, 5 | type NonStandardValuesGeneric, 6 | } from "ts-ics"; 7 | import { z } from "zod"; 8 | import { zIcsTimezoneProp } from "./timezoneProp"; 9 | 10 | export const zIcsTimezone: z.ZodType< 11 | // biome-ignore lint/suspicious/noExplicitAny: 12 | IcsTimezone, 13 | // biome-ignore lint/suspicious/noExplicitAny: 14 | IcsTimezone 15 | > = z.object({ 16 | id: z.string(), 17 | lastModified: z.date().optional(), 18 | url: z.url().optional(), 19 | props: z.array(zIcsTimezoneProp), 20 | nonStandard: z.record(z.string(), z.any()).optional(), 21 | }); 22 | 23 | export const parseIcsTimezone = ( 24 | ...props: Parameters> 25 | ): ReturnType> => convertIcsTimezone(zIcsTimezone, ...props); 26 | -------------------------------------------------------------------------------- /packages/ts-ics/src/constants/keys/calendar.ts: -------------------------------------------------------------------------------- 1 | import type { IcsCalendar } from "@/types"; 2 | import { invertKeys, keysFromObject } from "./utils"; 3 | 4 | export type IcsCalendarObjectKey = Exclude< 5 | keyof IcsCalendar, 6 | "events" | "timezones" | "nonStandard" | "todos" | "journals" | "freeBusy" 7 | >; 8 | export type IcsCalendarObjectKeys = IcsCalendarObjectKey[]; 9 | 10 | export const VCALENDAR_TO_KEYS = { 11 | method: "METHOD", 12 | prodId: "PRODID", 13 | version: "VERSION", 14 | name: "X-WR-CALNAME", 15 | } as const satisfies Record; 16 | 17 | export const VCALENDAR_TO_OBJECT_KEYS = invertKeys(VCALENDAR_TO_KEYS); 18 | 19 | export type IcsCalendarKey = keyof typeof VCALENDAR_TO_OBJECT_KEYS; 20 | export type IcsCalendarKeys = IcsCalendarKey[]; 21 | 22 | export const VCALENDAR_KEYS = keysFromObject(VCALENDAR_TO_OBJECT_KEYS); 23 | 24 | export const VCALENDAR_OBJECT_KEYS = keysFromObject(VCALENDAR_TO_KEYS); 25 | -------------------------------------------------------------------------------- /packages/schema-zod/src/values/attachment.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type IcsAttachment, 3 | attachmentEncodingTypes, 4 | convertIcsAttachment, 5 | type ParseAttachment, 6 | } from "ts-ics"; 7 | import { z } from "zod"; 8 | 9 | export const zIcsAttachment: z.ZodType = z.union([ 10 | z.object({ 11 | type: z.literal("uri"), 12 | url: z.url(), 13 | formatType: z.string().optional(), 14 | encoding: z.never().optional(), 15 | value: z.never().optional(), 16 | binary: z.never().optional(), 17 | }), 18 | z.object({ 19 | type: z.literal("binary"), 20 | url: z.never().optional(), 21 | formatType: z.never().optional(), 22 | encoding: z.enum(attachmentEncodingTypes), 23 | value: z.enum(["BINARY"]), 24 | binary: z.string(), 25 | }), 26 | ]); 27 | 28 | export const parseIcsAttachment: ParseAttachment = (...props) => 29 | convertIcsAttachment(zIcsAttachment, ...props); 30 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/values/trigger.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertTrigger, IcsTriggerRelation, IcsTrigger } from "@/types"; 2 | 3 | import { convertIcsDuration } from "./duration"; 4 | import { convertIcsTimeStamp } from "./timeStamp"; 5 | import { standardValidate } from "../utils/standardValidate"; 6 | 7 | export const convertIcsTrigger: ConvertTrigger = (schema, line, options) => { 8 | const trigger: IcsTrigger = 9 | line.options?.VALUE === "DATE-TIME" || line.options?.VALUE === "DATE" 10 | ? { 11 | type: "absolute", 12 | value: convertIcsTimeStamp(undefined, line, options), 13 | options: { related: line.options?.RELATED as IcsTriggerRelation }, 14 | } 15 | : { 16 | type: "relative", 17 | value: convertIcsDuration(undefined, line), 18 | options: { related: line.options?.RELATED as IcsTriggerRelation }, 19 | }; 20 | 21 | return standardValidate(schema, trigger); 22 | }; 23 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/parse/fixtures/apple.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | CALSCALE:GREGORIAN 3 | PRODID:-//Apple Inc.//macOS 15.0.1//EN 4 | VERSION:2.0 5 | X-APPLE-CALENDAR-COLOR:#E1152E 6 | X-WR-CALNAME:Content-Kalender 7 | BEGIN:VEVENT 8 | CREATED:20241002T144449Z 9 | DTEND;VALUE=DATE:20241009 10 | DTSTAMP:20241121T122200Z 11 | DTSTART;VALUE=DATE:20241008 12 | LAST-MODIFIED:20241014T035332Z 13 | RELATED-TO;RELTYPE=X-CALENDARSERVER-RECURRENCE-SET:A6123446-992C-4D34-84 14 | 1D-7B555A4F997E 15 | SEQUENCE:1 16 | SUMMARY:Beitrag 17 | TRANSP:TRANSPARENT 18 | UID:12C529B0-5433-4AA6-A545-9162234D77A5 19 | X-APPLE-CREATOR-IDENTITY:com.apple.calendar 20 | X-APPLE-CREATOR-TEAM-IDENTITY:0000000000 21 | BEGIN:VALARM 22 | ACTION:DISPLAY 23 | DESCRIPTION:Erinnerung 24 | TRIGGER:-PT15H 25 | UID:32128BB8-0320-491F-A155-796740912369 26 | X-APPLE-DEFAULT-ALARM:TRUE 27 | X-WR-ALARMUID:32328BB8-0210-422F-A225-7961234928469 28 | END:VALARM 29 | END:VEVENT 30 | END:VCALENDAR 31 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/values/trigger.ts: -------------------------------------------------------------------------------- 1 | import type { IcsTrigger } from "@/types"; 2 | 3 | import { generateIcsDuration } from "./duration"; 4 | import { generateIcsLine } from "../utils/addLine"; 5 | import { generateIcsOptions } from "../utils/generateOptions"; 6 | import { generateIcsUtcDateTime } from "./date"; 7 | 8 | export const generateIcsTrigger = (trigger: IcsTrigger) => { 9 | const options = generateIcsOptions( 10 | [ 11 | trigger.options?.related && { 12 | key: "RELATED", 13 | value: trigger.options.related, 14 | }, 15 | ].filter((v) => !!v) 16 | ); 17 | 18 | if (trigger.type === "absolute") { 19 | return generateIcsLine( 20 | "TRIGGER", 21 | generateIcsUtcDateTime(trigger.value?.date) 22 | ); 23 | } 24 | 25 | if (trigger.type === "relative") { 26 | return generateIcsLine( 27 | "TRIGGER", 28 | generateIcsDuration(trigger.value), 29 | options 30 | ); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | publish: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v5 17 | with: 18 | fetch-depth: 0 19 | persist-credentials: false 20 | 21 | - name: NodeJs aufsetzen 22 | uses: actions/setup-node@v5 23 | with: 24 | node-version: 24 25 | cache: "npm" 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Build 31 | run: npm run build 32 | 33 | - name: Create Release Pull Request or Publish 34 | id: changesets 35 | uses: changesets/action@v1 36 | with: 37 | publish: npx changeset publish 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 40 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 41 | -------------------------------------------------------------------------------- /packages/schema-zod/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src", 4 | "paths": { 5 | "@/*": ["./*"] 6 | }, 7 | "target": "esnext", 8 | "lib": ["dom", "dom.iterable", "esnext"], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "incremental": false, 20 | "composite": false, 21 | "declaration": true, 22 | "declarationMap": true, 23 | "inlineSources": false, 24 | "moduleResolution": "node", 25 | "noUnusedLocals": false, 26 | "noUnusedParameters": false, 27 | "preserveWatchOutput": true, 28 | "erasableSyntaxOnly": true 29 | }, 30 | "include": ["./src/**/*.ts", "./src/**/*.tsx", "./tests/**/*.ts"], 31 | "exclude": ["dist", "node_modules"] 32 | } 33 | -------------------------------------------------------------------------------- /packages/ts-ics/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src", 4 | "paths": { 5 | "@/*": ["./*"] 6 | }, 7 | "target": "esnext", 8 | "lib": ["dom", "dom.iterable", "esnext"], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "incremental": false, 20 | "composite": false, 21 | "declaration": true, 22 | "declarationMap": true, 23 | "inlineSources": false, 24 | "moduleResolution": "node", 25 | "noUnusedLocals": false, 26 | "noUnusedParameters": false, 27 | "preserveWatchOutput": true, 28 | "erasableSyntaxOnly": true 29 | }, 30 | "include": ["./src/**/*.ts", "./src/**/*.tsx", "./tests/**/*.ts"], 31 | "exclude": ["dist", "node_modules"] 32 | } 33 | -------------------------------------------------------------------------------- /packages/schema-tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src", 4 | "paths": { 5 | "@/*": ["./*"] 6 | }, 7 | "target": "esnext", 8 | "lib": ["dom", "dom.iterable", "esnext"], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "incremental": false, 20 | "composite": false, 21 | "declaration": true, 22 | "declarationMap": true, 23 | "inlineSources": false, 24 | "moduleResolution": "node", 25 | "noUnusedLocals": false, 26 | "noUnusedParameters": false, 27 | "preserveWatchOutput": true, 28 | "erasableSyntaxOnly": true 29 | }, 30 | "include": ["./src/**/*.ts", "./src/**/*.tsx", "./tests/**/*.ts"], 31 | "exclude": ["dist", "node_modules"] 32 | } 33 | -------------------------------------------------------------------------------- /packages/schema-zod/src/values/status.ts: -------------------------------------------------------------------------------- 1 | import { 2 | convertIcsEventStatus, 3 | type ParseEventStatus, 4 | eventStatusTypes, 5 | todoStatusTypes, 6 | type ParseTodoStatus, 7 | convertIcsTodoStatus, 8 | journalStatusTypes, 9 | convertIcsJournalStatus, 10 | type ParseJournalStatus, 11 | } from "ts-ics"; 12 | import { z } from "zod"; 13 | 14 | export const zIcsEventStatusType = z.enum(eventStatusTypes); 15 | 16 | export const parseIcsEventStatus: ParseEventStatus = (...props) => 17 | convertIcsEventStatus(zIcsEventStatusType, ...props); 18 | 19 | export const zIcsTodoStatusType = z.enum(todoStatusTypes); 20 | 21 | export const parseIcsTodoStatus: ParseTodoStatus = (...props) => 22 | convertIcsTodoStatus(zIcsTodoStatusType, ...props); 23 | 24 | export const zIcsJournalStatusType = z.enum(journalStatusTypes); 25 | 26 | export const parseIcsJournalStatus: ParseJournalStatus = (...props) => 27 | convertIcsJournalStatus(zIcsJournalStatusType, ...props); 28 | -------------------------------------------------------------------------------- /packages/ts-ics/src/constants/keys/freebusy.ts: -------------------------------------------------------------------------------- 1 | import type { IcsFreeBusy } from "@/types"; 2 | import { invertKeys, keysFromObject } from "./utils"; 3 | 4 | export type IcsFreeBusyObjectKey = Exclude; 5 | 6 | export type IcsFreeBusyObjectKeys = IcsFreeBusyObjectKey[]; 7 | 8 | export const VFREEBUSY_TO_KEYS = { 9 | stamp: "DTSTAMP", 10 | start: "DTSTART", 11 | uid: "UID", 12 | url: "URL", 13 | organizer: "ORGANIZER", 14 | attendees: "ATTENDEE", 15 | comment: "COMMENT", 16 | end: "DTEND", 17 | freeBusy: "FREEBUSY", 18 | } as const satisfies Record; 19 | 20 | export const VFREEBUSY_TO_OBJECT_KEYS = invertKeys(VFREEBUSY_TO_KEYS); 21 | 22 | export type IcsFreeBusyKey = keyof typeof VFREEBUSY_TO_OBJECT_KEYS; 23 | export type IcsFreeBusyKeys = IcsFreeBusyKey[]; 24 | 25 | export const VFREEBUSY_KEYS = keysFromObject(VFREEBUSY_TO_OBJECT_KEYS); 26 | 27 | export const VFREEBUSY_OBJECT_KEYS = keysFromObject(VFREEBUSY_TO_KEYS); 28 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/values/date.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertLineType, ParseLineType } from "../parse"; 2 | import type { IcsTimezone } from "../components/timezone"; 3 | 4 | export const dateObjectTypes = ["DATE", "DATE-TIME"] as const; 5 | 6 | export type DateObjectTypes = typeof dateObjectTypes; 7 | export type DateObjectType = DateObjectTypes[number]; 8 | 9 | export type DateObjectTzProps = { 10 | date: Date; 11 | timezone: string; 12 | tzoffset: string; 13 | }; 14 | 15 | export type IcsDateObject = { 16 | date: Date; 17 | type?: DateObjectType; 18 | local?: DateObjectTzProps; 19 | }; 20 | 21 | export type ConvertDate = ConvertLineType; 22 | 23 | export type ParseDate = ParseLineType; 24 | 25 | export type ParseTimeStampOptions = { timezones?: IcsTimezone[] }; 26 | 27 | export type ConvertTimeStamp = ConvertLineType< 28 | IcsDateObject, 29 | ParseTimeStampOptions 30 | >; 31 | 32 | export type ParseTimeStamp = ParseLineType< 33 | IcsDateObject, 34 | ParseTimeStampOptions 35 | >; 36 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/recurrence/iterateBy/monthDay.ts: -------------------------------------------------------------------------------- 1 | import { getDaysInMonth, getMonth, setDate } from "date-fns"; 2 | 3 | import type { IcsRecurrenceRule } from "@/types"; 4 | 5 | export const iterateByMonthDay = ( 6 | rule: IcsRecurrenceRule, 7 | dateGroups: Date[][], 8 | byMonthday: NonNullable 9 | ): Date[][] => { 10 | if (rule.frequency === "YEARLY" || rule.frequency === "MONTHLY") { 11 | return dateGroups.map((dates) => 12 | dates.flatMap((date) => { 13 | const daysInMonth = getDaysInMonth(date); 14 | 15 | return byMonthday 16 | .map( 17 | (day) => (day > daysInMonth ? undefined : setDate(date, day)) // Invalide Dates entfernen z.B. 30. FEB 18 | ) 19 | .filter((v) => !!v); 20 | }) 21 | ); 22 | } 23 | 24 | if (rule.frequency === "WEEKLY") return dateGroups; // Nicht verfügbar für Weekly 25 | 26 | return dateGroups.map((dates) => 27 | dates.filter((date) => byMonthday.includes(getMonth(date))) 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/values/freebusyValue.ts: -------------------------------------------------------------------------------- 1 | import type { IcsFreeBusyTime } from "@/types/components/freebusy"; 2 | import { generateIcsLine } from "../utils/addLine"; 3 | import { generateIcsOptions } from "../utils/generateOptions"; 4 | import { generateIcsUtcDateTime } from "./date"; 5 | import { generateIcsDuration } from "./duration"; 6 | 7 | export const generateIcsFreeBusyTime = ( 8 | freeBusy: IcsFreeBusyTime, 9 | key: string 10 | ) => { 11 | const value: string = freeBusy.values 12 | .map( 13 | (freeBusy) => 14 | `${generateIcsUtcDateTime(freeBusy.start)}/${ 15 | freeBusy.end 16 | ? generateIcsUtcDateTime(freeBusy.end) 17 | : generateIcsDuration(freeBusy.duration) 18 | }` 19 | ) 20 | .join(","); 21 | 22 | const icsOptions = generateIcsOptions( 23 | [freeBusy.type && { key: "FBTYPE", value: freeBusy.type }].filter( 24 | (v) => !!v 25 | ) 26 | ); 27 | 28 | return generateIcsLine(key, value, icsOptions); 29 | }; 30 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/values/trigger.ts: -------------------------------------------------------------------------------- 1 | import type { IcsDateObject } from "./date"; 2 | import type { IcsDuration } from "./duration"; 3 | import type { ConvertLineType, ParseLineType } from "../parse"; 4 | import type { IcsTimezone } from "../components/timezone"; 5 | 6 | export const triggerRelations = ["START", "END"] as const; 7 | 8 | export type IcsTriggerRelations = typeof triggerRelations; 9 | export type IcsTriggerRelation = IcsTriggerRelations[number]; 10 | 11 | export type IcsTriggerUnion = 12 | | { type: "absolute"; value: IcsDateObject } 13 | | { type: "relative"; value: IcsDuration }; 14 | 15 | export type IcsTriggerOptions = { related?: IcsTriggerRelation }; 16 | 17 | export type IcsTriggerBase = { options?: IcsTriggerOptions }; 18 | 19 | export type IcsTrigger = IcsTriggerBase & IcsTriggerUnion; 20 | 21 | export type ParseTriggerOptions = { timezones?: IcsTimezone[] }; 22 | 23 | export type ConvertTrigger = ConvertLineType; 24 | 25 | export type ParseTrigger = ParseLineType; 26 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/parse/organizer.test.ts: -------------------------------------------------------------------------------- 1 | import { getLine } from "@/lib/parse/utils/line"; 2 | 3 | import { convertIcsOrganizer } from "@/lib/parse/values/organizer"; 4 | 5 | it("Test Ics IcsOrganizer Parse", async () => { 6 | const organizer = `ORGANIZER;CN=John Smith:mailto:jsmith@example.com`; 7 | 8 | const { line } = getLine(organizer); 9 | 10 | expect(() => convertIcsOrganizer(undefined, line)).not.toThrow(); 11 | }); 12 | 13 | it("Test Ics IcsOrganizer Parse", async () => { 14 | const organizer = `ORGANIZER;CN=JohnSmith;DIR="ldap://example.com:6666/o=DC%20Associates,c=US???(cn=John%20Smith)":mailto:jsmith@example.com`; 15 | 16 | const { line } = getLine(organizer); 17 | 18 | expect(() => convertIcsOrganizer(undefined, line)).not.toThrow(); 19 | }); 20 | 21 | it("Test Ics IcsOrganizer Parse", async () => { 22 | const organizer = `ORGANIZER;SENT-BY="mailto:jane_doe@example.com":mailto:jsmith@example.com`; 23 | 24 | const { line } = getLine(organizer); 25 | 26 | expect(() => convertIcsOrganizer(undefined, line)).not.toThrow(); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/values/freebusyValue.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertFreeBusyTime, FreeBusyType } from "@/types"; 2 | import { standardValidate } from "../utils/standardValidate"; 3 | import { convertIcsDateTime } from "./date"; 4 | import { convertIcsDuration } from "./duration"; 5 | 6 | export const convertIcsFreeBusyTime: ConvertFreeBusyTime = (schema, line) => 7 | standardValidate(schema, { 8 | type: line.options?.FBTYPE as FreeBusyType | undefined, 9 | values: line.value.split(",").map((v) => { 10 | const [startString, durationOrEndString] = v.split("/"); 11 | 12 | const start = convertIcsDateTime(undefined, { value: startString }); 13 | 14 | if (durationOrEndString.startsWith("PT")) { 15 | const duration = convertIcsDuration(undefined, { 16 | value: durationOrEndString, 17 | }); 18 | return { start, duration }; 19 | } 20 | 21 | const end = convertIcsDateTime(undefined, { value: durationOrEndString }); 22 | 23 | return { start, end }; 24 | }), 25 | }); 26 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/duration/durationFromInterval.ts: -------------------------------------------------------------------------------- 1 | import { 2 | differenceInDays, 3 | differenceInHours, 4 | differenceInMinutes, 5 | differenceInSeconds, 6 | differenceInWeeks, 7 | } from "date-fns"; 8 | 9 | import type { IcsDuration } from "../../types"; 10 | 11 | export const getDurationFromInterval = ( 12 | start: Date, 13 | end: Date 14 | ): IcsDuration => { 15 | const weeks = Math.abs(differenceInWeeks(end, start)); 16 | const rawDays = Math.abs(differenceInDays(end, start)); 17 | const days = rawDays - weeks * 7; 18 | const rawHours = Math.abs(differenceInHours(end, start)); 19 | const hours = rawHours - rawDays * 24; 20 | const rawMinutes = Math.abs(differenceInMinutes(end, start)); 21 | const minutes = rawMinutes - rawHours * 60; 22 | const rawSeconds = Math.abs(differenceInSeconds(end, start)); 23 | const seconds = rawSeconds - rawMinutes * 60; 24 | 25 | return { 26 | before: start > end, 27 | weeks, 28 | days, 29 | hours, 30 | minutes, 31 | seconds, 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/recurrence/iterateBy/setPos.ts: -------------------------------------------------------------------------------- 1 | import { compareAsc } from "date-fns"; 2 | 3 | import type { IcsRecurrenceRule } from "@/types"; 4 | 5 | export const iterateBySetPos = ( 6 | rule: IcsRecurrenceRule, 7 | dateGroups: Date[][], 8 | bySetPos: NonNullable 9 | ): Date[][] => { 10 | if ( 11 | !rule.byYearday && 12 | !rule.byWeekNo && 13 | !rule.byMonthday && 14 | !rule.byMonth && 15 | !rule.byDay && 16 | !rule.byHour && 17 | !rule.byMinute && 18 | !rule.bySecond 19 | ) 20 | return dateGroups; // setPos muss immer mit anderer by-Rule verwendet werden 21 | 22 | return dateGroups.map((dates) => 23 | dates.sort(compareAsc).filter((_, i) => 24 | bySetPos.some((pos) => { 25 | if (pos > 0) { 26 | if (i === 0) return false; 27 | return i % pos === 0; 28 | } 29 | if (i === 0) { 30 | return dates.length - 1 + pos === 0; 31 | } 32 | return i % (dates.length - 1 + pos) === 0; 33 | }) 34 | ) 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/utils/options.ts: -------------------------------------------------------------------------------- 1 | import { EQUAL_SIGN } from "@/constants"; 2 | 3 | type RawOption = { property: string; value: string }; 4 | 5 | export const removeDoubleQuotesFromString = (str: string) => { 6 | if (str.startsWith('"') && str.endsWith('"')) { 7 | const result = str.slice(1, -1); 8 | return result; 9 | } 10 | return str; 11 | }; 12 | 13 | export const getOptions = (optionsStrings: string[]) => 14 | optionsStrings.map((option) => { 15 | const [property, ...valueArray] = option.split(EQUAL_SIGN); 16 | 17 | const value = valueArray.join(EQUAL_SIGN); 18 | 19 | return { 20 | property: property as TKey, 21 | value: removeDoubleQuotesFromString(value), 22 | }; 23 | }, {}); 24 | 25 | export const reduceOptions = (options: RawOption[]) => 26 | options.reduce>((prev, next) => { 27 | prev[next.property] = next.value; 28 | 29 | return prev; 30 | }, {}); 31 | 32 | export const splitOptions = (optionsStrings: string[]) => 33 | reduceOptions(getOptions(optionsStrings)); 34 | -------------------------------------------------------------------------------- /packages/ts-ics/src/constants/keys/timezoneProp.ts: -------------------------------------------------------------------------------- 1 | import type { IcsTimezoneProp } from "@/types"; 2 | import { invertKeys, keysFromObject } from "./utils"; 3 | 4 | export type IcsTimezonePropObjectKey = Exclude< 5 | keyof IcsTimezoneProp, 6 | "type" | "nonStandard" 7 | >; 8 | export type IcsTimezonePropObjectKeys = IcsTimezonePropObjectKey[]; 9 | 10 | export const VTIMEZONE_PROP_TO_KEYS = { 11 | comment: "COMMENT", 12 | name: "TZNAME", 13 | offsetFrom: "TZOFFSETFROM", 14 | offsetTo: "TZOFFSETTO", 15 | recurrenceDate: "RDATE", 16 | recurrenceRule: "RRULE", 17 | start: "DTSTART", 18 | } as const satisfies Record; 19 | 20 | export const VTIMEZONE_PROP_TO_OBJECT_KEYS = invertKeys(VTIMEZONE_PROP_TO_KEYS); 21 | 22 | export type IcsTimezonePropKey = keyof typeof VTIMEZONE_PROP_TO_OBJECT_KEYS; 23 | export type IcsTimezonePropKeys = IcsTimezonePropKey[]; 24 | 25 | export const VTIMEZONE_PROP_KEYS = keysFromObject( 26 | VTIMEZONE_PROP_TO_OBJECT_KEYS 27 | ); 28 | 29 | export const VTIMEZONE_PROP_OBJECT_KEYS = keysFromObject( 30 | VTIMEZONE_PROP_TO_KEYS 31 | ); 32 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/values/attachment.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertLineType, ParseLineType } from "../parse"; 2 | 3 | export const attachmentEncodingTypes = ["BASE64"] as const; 4 | 5 | export type IcsAttachmentEncodingTypes = typeof attachmentEncodingTypes; 6 | export type IcsAttachmentEncodingType = IcsAttachmentEncodingTypes[number]; 7 | 8 | export const attachmentValueTypes = ["BINARY"] as const; 9 | 10 | export type IcsAttachmentValueTypes = typeof attachmentValueTypes; 11 | export type IcsAttachmentValueType = IcsAttachmentValueTypes[number]; 12 | 13 | export type IcsAttachment = 14 | | { 15 | type: "uri"; 16 | url: string; 17 | formatType?: string; 18 | encoding?: never; 19 | value?: never; 20 | binary?: never; 21 | } 22 | | { 23 | type: "binary"; 24 | url?: never; 25 | formatType?: never; 26 | encoding?: IcsAttachmentEncodingType; 27 | value?: IcsAttachmentValueType; 28 | binary: string; 29 | }; 30 | 31 | export type ConvertAttachment = ConvertLineType; 32 | 33 | export type ParseAttachment = ParseLineType; 34 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/values/duration.ts: -------------------------------------------------------------------------------- 1 | import type { IcsDuration } from "@/types"; 2 | 3 | export const generateIcsDuration = (duration: IcsDuration) => { 4 | if (Object.values(duration).filter((v) => typeof v === "number").length === 0) 5 | return; 6 | 7 | let icsString = ""; 8 | 9 | if (duration.before) { 10 | icsString += "-"; 11 | } 12 | 13 | icsString += "P"; 14 | 15 | if (duration.weeks !== undefined) { 16 | icsString += `${duration.weeks}W`; 17 | } 18 | 19 | if (duration.days !== undefined) { 20 | icsString += `${duration.days}D`; 21 | } 22 | 23 | if ( 24 | duration.hours !== undefined || 25 | duration.minutes !== undefined || 26 | duration.seconds !== undefined 27 | ) { 28 | icsString += "T"; 29 | 30 | if (duration.hours !== undefined) { 31 | icsString += `${duration.hours}H`; 32 | } 33 | 34 | if (duration.minutes !== undefined) { 35 | icsString += `${duration.minutes}M`; 36 | } 37 | 38 | if (duration.seconds !== undefined) { 39 | icsString += `${duration.seconds}S`; 40 | } 41 | } 42 | 43 | return icsString; 44 | }; 45 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/values/attachment.ts: -------------------------------------------------------------------------------- 1 | import type { IcsAttachment } from "@/types/values/attachment"; 2 | 3 | import { generateIcsLine } from "../utils/addLine"; 4 | import { generateIcsOptions } from "../utils/generateOptions"; 5 | 6 | export const generateIcsAttachment = (attachment: IcsAttachment) => { 7 | if (attachment.type === "uri") { 8 | const options = generateIcsOptions( 9 | [ 10 | attachment.formatType && { 11 | key: "FMTTYPE", 12 | value: attachment.formatType, 13 | }, 14 | ].filter((v) => !!v) 15 | ); 16 | 17 | return generateIcsLine("ATTACH", attachment.url, options); 18 | } 19 | 20 | if (attachment.type === "binary") { 21 | const options = generateIcsOptions( 22 | [ 23 | attachment.value && { key: "VALUE", value: attachment.value }, 24 | attachment.encoding && { key: "ENCODING", value: attachment.encoding }, 25 | ].filter((v) => !!v) 26 | ); 27 | 28 | return generateIcsLine("ATTACH", attachment.binary, options); 29 | } 30 | 31 | throw Error(`IcsAttachment has no type! ${JSON.stringify(attachment)}`); 32 | }; 33 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/generate/exceptionDate.test.ts: -------------------------------------------------------------------------------- 1 | import { generateIcsExceptionDate } from "@/lib/generate/values/exceptionDate"; 2 | import { convertIcsExceptionDates } from "@/lib/parse/values/exceptionDate"; 3 | import { getLine } from "@/lib/parse/utils/line"; 4 | import { splitLines } from "@/lib/parse/utils/splitLines"; 5 | import type { IcsExceptionDates } from "@/types/values/exceptionDate"; 6 | 7 | it("Test Ics Exception Date Generate", async () => { 8 | const exceptions: IcsExceptionDates = [ 9 | { date: new Date("2007-04-02T01:00:00.000Z"), type: "DATE-TIME" }, 10 | { date: new Date("2007-04-03T01:00:00.000Z"), type: "DATE-TIME" }, 11 | ]; 12 | 13 | let exceptionsString = ""; 14 | 15 | exceptions.forEach((exception) => { 16 | exceptionsString += generateIcsExceptionDate(exception, "EXDATE"); 17 | }); 18 | 19 | const lineStrings = splitLines(exceptionsString); 20 | 21 | lineStrings.forEach((lineString, i) => { 22 | const { line } = getLine(lineString); 23 | 24 | const parsed = convertIcsExceptionDates(undefined, line); 25 | 26 | expect(parsed[0].date).toEqual(exceptions[i].date); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /packages/ts-ics/src/constants/keys/recurrenceRule.ts: -------------------------------------------------------------------------------- 1 | import type { IcsRecurrenceRule } from "@/types"; 2 | import { invertKeys, keysFromObject } from "./utils"; 3 | 4 | export type IcsRecurrenceRuleObjectKey = keyof IcsRecurrenceRule; 5 | export type IcsRecurrenceRuleObjectKeys = IcsRecurrenceRuleObjectKey[]; 6 | 7 | export const RRULE_TO_KEYS = { 8 | byDay: "BYDAY", 9 | byHour: "BYHOUR", 10 | byMinute: "BYMINUTE", 11 | byMonth: "BYMONTH", 12 | byMonthday: "BYMONTHDAY", 13 | bySecond: "BYSECOND", 14 | bySetPos: "BYSETPOS", 15 | byWeekNo: "BYWEEKNO", 16 | byYearday: "BYYEARDAY", 17 | count: "COUNT", 18 | frequency: "FREQ", 19 | interval: "INTERVAL", 20 | until: "UNTIL", 21 | workweekStart: "WKST", 22 | } as const satisfies Record; 23 | 24 | export const RRULE_TO_OBJECT_KEYS = invertKeys(RRULE_TO_KEYS); 25 | 26 | export type IcsRecurrenceRuleKey = keyof typeof RRULE_TO_OBJECT_KEYS; 27 | export type IcsRecurrenceRuleKeys = IcsRecurrenceRuleKey[]; 28 | 29 | export const RRULE_KEYS = keysFromObject(RRULE_TO_OBJECT_KEYS); 30 | 31 | export const RRULE_OBJECT_KEYS = keysFromObject(RRULE_TO_KEYS); 32 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/parse/exceptionDate.test.ts: -------------------------------------------------------------------------------- 1 | import { convertIcsEvent } from "@/lib"; 2 | import { convertIcsExceptionDates } from "@/lib/parse/values/exceptionDate"; 3 | import { icsTestData } from "../utils"; 4 | 5 | it("Test Ics Event Parse - Exception Date-Times, comma separated", async () => { 6 | const value = "20070402T010000Z,20070403T010000Z,20070404T010000Z"; 7 | 8 | const parsed = convertIcsExceptionDates(undefined, { value }); 9 | 10 | expect(parsed?.length).toBe(3); 11 | }); 12 | 13 | it("Test Ics Event Parse - multiple Exception Date-Times", async () => { 14 | const event = icsTestData([ 15 | "BEGIN:VEVENT", 16 | "UID:20070423T123432Z-541111@example.com", 17 | "DTSTAMP:20070423T123432Z", 18 | "DTSTART;VALUE=DATE:20070628", 19 | "DTEND;VALUE=DATE:20070709", 20 | "EXDATE:20070402T010000Z", 21 | "EXDATE:20070403T010000Z", 22 | "EXDATE:20070404T010000Z", 23 | "SUMMARY:Festival International de Jazz de Montreal", 24 | "TRANSP:TRANSPARENT", 25 | "END:VEVENT", 26 | ]); 27 | const parsed = convertIcsEvent(undefined, event); 28 | 29 | expect(parsed.exceptionDates?.length).toBe(3); 30 | }); 31 | -------------------------------------------------------------------------------- /packages/schema-tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ts-ics/schema-tests", 3 | "author": "Neuvernetzung Medienagentur UG", 4 | "version": "1.6.7", 5 | "description": "Zod schema validators for ts-ics", 6 | "type": "module", 7 | "main": "dist/index.cjs", 8 | "module": "dist/index.js", 9 | "private": true, 10 | "scripts": { 11 | "lint": "npx @biomejs/biome lint ./src --write", 12 | "type-check": "tsc --noEmit", 13 | "test": "jest" 14 | }, 15 | "license": "MIT", 16 | "dependencies": { 17 | "@ts-ics/schema-zod": "^2.4.0", 18 | "ts-ics": "^2.4.0" 19 | }, 20 | "devDependencies": { 21 | "@testing-library/jest-dom": "^6.8.0", 22 | "@biomejs/biome": "^2.2.4", 23 | "@types/jest": "^30.0.0", 24 | "@types/node": "^24.5.1", 25 | "jest": "^30.1.3", 26 | "jest-environment-jsdom": "^30.1.2", 27 | "jest-environment-node": "^30.1.2", 28 | "ts-jest": "^29.4.2", 29 | "ts-node": "^10.9.2", 30 | "typescript": "^5.9.2" 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "https://github.com/Neuvernetzung/ts-ics.git" 35 | }, 36 | "publishConfig": { 37 | "access": "public" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/ts-ics/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Neuvernetzung Medienagentur UG 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/schema-zod/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Neuvernetzung Medienagentur UG 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/values/attendee.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ConvertAttendee, 3 | IcsAttendeePartStatusType, 4 | } from "@/types/values/attendee"; 5 | 6 | import { replaceMailTo } from "../utils/replaceMailTo"; 7 | import { standardValidate } from "../utils/standardValidate"; 8 | 9 | export const convertIcsAttendee: ConvertAttendee = (schema, line) => 10 | standardValidate(schema, { 11 | email: replaceMailTo(line.value), 12 | delegatedFrom: line.options?.["DELEGATED-FROM"] 13 | ? replaceMailTo(line.options?.["DELEGATED-FROM"]) 14 | : undefined, 15 | dir: line.options?.DIR, 16 | member: line.options?.MEMBER 17 | ? replaceMailTo(line.options.MEMBER) 18 | : undefined, 19 | name: line.options?.CN, 20 | partstat: line.options?.PARTSTAT as IcsAttendeePartStatusType, 21 | role: line.options?.ROLE, 22 | sentBy: line.options?.["SENT-BY"] 23 | ? replaceMailTo(line.options["SENT-BY"]) 24 | : undefined, 25 | rsvp: line.options?.RSVP 26 | ? line.options?.RSVP === "TRUE" 27 | ? true 28 | : line.options?.RSVP === "FALSE" 29 | ? false 30 | : undefined 31 | : undefined, 32 | }); 33 | -------------------------------------------------------------------------------- /packages/schema-zod/src/components/alarm.ts: -------------------------------------------------------------------------------- 1 | import { 2 | convertIcsAlarm, 3 | type ParseAlarm, 4 | type IcsAlarm, 5 | type NonStandardValuesGeneric, 6 | } from "ts-ics"; 7 | import { z } from "zod"; 8 | import { zIcsTrigger } from "../values/trigger"; 9 | import { zIcsAttendee } from "../values/attendee"; 10 | import { zIcsDuration } from "../values/duration"; 11 | import { zIcsAttachment } from "../values/attachment"; 12 | 13 | // biome-ignore lint/suspicious/noExplicitAny: 14 | export const zIcsAlarm: z.ZodType, IcsAlarm> = z.object({ 15 | action: z.string().default("DISPLAY"), 16 | description: z.string().optional(), 17 | trigger: zIcsTrigger, 18 | attendees: z.array(zIcsAttendee).optional(), 19 | duration: zIcsDuration.optional(), 20 | repeat: z.number().optional(), 21 | summary: z.string().optional(), 22 | attachments: z.array(zIcsAttachment).optional(), 23 | nonStandard: z.record(z.string(), z.any()).optional(), 24 | }); 25 | 26 | export const parseIcsAlarm = ( 27 | ...props: Parameters> 28 | ): ReturnType> => convertIcsAlarm(zIcsAlarm, ...props); 29 | -------------------------------------------------------------------------------- /benchmark/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { readFileSync, writeFileSync } from "node:fs"; 3 | import { Bench } from "tinybench"; 4 | 5 | const bench = new Bench({ name: "Calendar Benchmark", time: 100 }); 6 | 7 | import { generateIcsCalendar } from "../packages/ts-ics/dist/index.cjs"; 8 | import { parseIcsCalendar } from "../packages/schema-zod/dist/index.cjs"; 9 | 10 | const file = readFileSync("benchmark/calendar.ics", "utf-8"); 11 | 12 | const parsed = parseIcsCalendar(file); 13 | 14 | bench 15 | .add("parse Calendar", () => { 16 | parseIcsCalendar(file); 17 | }) 18 | .add("generate Calendar", () => { 19 | generateIcsCalendar(parsed); 20 | }); 21 | 22 | await bench.run(); 23 | 24 | console.log(bench.table()); 25 | 26 | writeFileSync( 27 | "bench_result.json", 28 | JSON.stringify( 29 | bench.tasks.flatMap((t) => [ 30 | { 31 | name: `${t.name} - latency`, 32 | unit: "ms", 33 | value: t.result.latency.mean.toFixed(3), 34 | }, 35 | // { 36 | // name: `${t.name} - throughput`, 37 | // unit: "ops/s", 38 | // value: t.result?.throughput.mean.toFixed(0), 39 | // }, 40 | ]) 41 | ) 42 | ); 43 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/nonStandard/nonStandardValues.ts: -------------------------------------------------------------------------------- 1 | import type { StandardSchemaV1 } from "@standard-schema/spec"; 2 | import type { Line } from "../line"; 3 | 4 | export type NonStandardValueName = `X-${string}`; 5 | 6 | // biome-ignore lint/suspicious/noExplicitAny: 7 | export type NonStandardValuesGeneric = Record; 8 | 9 | export type ParseNonStandardValue = { 10 | name: NonStandardValueName; 11 | convert: (line: Line) => TValue; 12 | schema?: StandardSchemaV1; 13 | }; 14 | 15 | export type ParseNonStandardValues< 16 | TNonStandardValues extends NonStandardValuesGeneric 17 | > = { 18 | [K in keyof TNonStandardValues]: ParseNonStandardValue; 19 | }; 20 | 21 | export type GenerateNonStandardValue = { 22 | name: NonStandardValueName; 23 | generate: (value: TValue) => Line | undefined | null; 24 | }; 25 | 26 | export type GenerateNonStandardValues< 27 | TNonStandardValues extends NonStandardValuesGeneric 28 | > = { 29 | [K in keyof TNonStandardValues]: GenerateNonStandardValue< 30 | TNonStandardValues[K] 31 | >; 32 | }; 33 | -------------------------------------------------------------------------------- /packages/ts-ics/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ts-ics", 3 | "author": "Neuvernetzung Medienagentur UG", 4 | "version": "2.4.0", 5 | "description": "Create and parse ICS format for TypeScript", 6 | "type": "module", 7 | "main": "dist/index.cjs", 8 | "module": "dist/index.js", 9 | "scripts": { 10 | "lint": "npx @biomejs/biome lint ./src --write", 11 | "type-check": "tsc --noEmit", 12 | "dev": "tsup --watch", 13 | "build": "tsup", 14 | "test": "jest" 15 | }, 16 | "license": "MIT", 17 | "dependencies": { 18 | "@standard-schema/spec": "^1.0.0" 19 | }, 20 | "devDependencies": { 21 | "@testing-library/jest-dom": "^6.8.0", 22 | "@biomejs/biome": "^2.2.4", 23 | "@types/jest": "^30.0.0", 24 | "@types/node": "^24.5.1", 25 | "date-fns": "^4.1.0", 26 | "jest": "^30.1.3", 27 | "jest-environment-jsdom": "^30.1.2", 28 | "jest-environment-node": "^30.1.2", 29 | "ts-jest": "^29.4.2", 30 | "ts-node": "^10.9.2", 31 | "tsup": "^8.5.0", 32 | "typescript": "^5.9.2" 33 | }, 34 | "repository": { 35 | "type": "git", 36 | "url": "https://github.com/Neuvernetzung/ts-ics.git" 37 | }, 38 | "publishConfig": { 39 | "access": "public" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/components/timezone.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | NonStandardValuesGeneric, 3 | ParseNonStandardValues, 4 | } from "../nonStandard/nonStandardValues"; 5 | import type { ConvertComponentType, ParseComponentType } from "../parse"; 6 | import type { IcsTimezoneProp } from "./timezoneProp"; 7 | 8 | export type IcsTimezone< 9 | TNonStandardValues extends NonStandardValuesGeneric = NonStandardValuesGeneric 10 | > = { 11 | id: string; 12 | lastModified?: Date; 13 | url?: string; 14 | props: IcsTimezoneProp[]; 15 | nonStandard?: Partial; 16 | }; 17 | 18 | export type ParseTimezoneOptions< 19 | TNonStandardValues extends NonStandardValuesGeneric 20 | > = { 21 | nonStandard?: ParseNonStandardValues; 22 | timezones?: IcsTimezone[]; 23 | }; 24 | 25 | export type ConvertTimezone< 26 | TNonStandardValues extends NonStandardValuesGeneric 27 | > = ConvertComponentType< 28 | IcsTimezone, 29 | ParseTimezoneOptions 30 | >; 31 | 32 | export type ParseTimezone = 33 | ParseComponentType< 34 | IcsTimezone, 35 | ParseTimezoneOptions 36 | >; 37 | -------------------------------------------------------------------------------- /packages/schema-tests/tests/parse/alarm.test.ts: -------------------------------------------------------------------------------- 1 | import { schemaPackageNames } from "../../src/schemas"; 2 | import { icsTestData } from "ts-ics/tests/utils"; 3 | import { VALARM_OBJECT_KEYS } from "ts-ics"; 4 | 5 | describe("Tests if all values from IcsAlarm are parsed from schema package", () => { 6 | schemaPackageNames.forEach((name) => { 7 | const alarmString = icsTestData([ 8 | "BEGIN:VALARM", 9 | "SUMMARY:Some alarm.", 10 | "DESCRIPTION:This is some test alarm.", 11 | "TRIGGER;VALUE=DATE-TIME:19970317T133000Z", 12 | "REPEAT:4", 13 | "DURATION:PT15M", 14 | "ACTION:AUDIO", 15 | "ATTACH;FMTTYPE=audio/basic:ftp://example.com/pub/sounds/bell-01.aud", 16 | `ATTENDEE;MEMBER="mailto:DEV-GROUP@example.com":mailto:joecool@example.com`, 17 | "END:VALARM", 18 | ]); 19 | 20 | it(name, async () => { 21 | const schemaPackage = await import(name); 22 | 23 | const alarm = schemaPackage.parseIcsAlarm(alarmString); 24 | 25 | VALARM_OBJECT_KEYS.forEach((alarmKey) => { 26 | if (!alarm[alarmKey]) 27 | throw new Error( 28 | `The alarm does not contain the value "${alarmKey}".` 29 | ); 30 | }); 31 | }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /packages/schema-zod/src/components/timezoneProp.ts: -------------------------------------------------------------------------------- 1 | import { 2 | convertIcsTimezoneProp, 3 | type IcsTimezoneProp, 4 | type NonStandardValuesGeneric, 5 | type ParseTimezoneProp, 6 | TIMEZONE_PROP_COMPONENTS, 7 | } from "ts-ics"; 8 | import { z } from "zod"; 9 | import { zIcsRecurrenceRule } from "../values/recurrenceRule"; 10 | import { zIcsDateObject } from "../values/date"; 11 | 12 | export const zIcsTimezoneProp: z.ZodType< 13 | // biome-ignore lint/suspicious/noExplicitAny: 14 | IcsTimezoneProp, 15 | // biome-ignore lint/suspicious/noExplicitAny: 16 | IcsTimezoneProp 17 | > = z.object({ 18 | type: z.enum(TIMEZONE_PROP_COMPONENTS), 19 | start: z.date(), 20 | offsetTo: z.string(), 21 | offsetFrom: z.string(), 22 | recurrenceRule: zIcsRecurrenceRule.optional(), 23 | comment: z.string().optional(), 24 | recurrenceDate: zIcsDateObject.optional(), 25 | name: z.string().optional(), 26 | nonStandard: z.record(z.string(), z.any()).optional(), 27 | }); 28 | 29 | export const parseIcsTimezoneProp = ( 30 | ...props: Parameters> 31 | ): ReturnType> => 32 | convertIcsTimezoneProp(zIcsTimezoneProp, ...props); 33 | -------------------------------------------------------------------------------- /packages/schema-zod/src/values/trigger.ts: -------------------------------------------------------------------------------- 1 | import { 2 | convertIcsTrigger, 3 | type ParseTrigger, 4 | triggerRelations, 5 | type IcsTrigger, 6 | type IcsTriggerBase, 7 | type IcsTriggerOptions, 8 | type IcsTriggerUnion, 9 | } from "ts-ics"; 10 | import { z } from "zod"; 11 | import { zIcsDateObject } from "./date"; 12 | import { zIcsDuration } from "./duration"; 13 | 14 | export const zIcsTriggerUnion: z.ZodType = 15 | z.discriminatedUnion("type", [ 16 | z.object({ type: z.literal("absolute"), value: zIcsDateObject }), 17 | z.object({ type: z.literal("relative"), value: zIcsDuration }), 18 | ]); 19 | 20 | export const zIcsTriggerOptions: z.ZodType< 21 | IcsTriggerOptions, 22 | IcsTriggerOptions 23 | > = z.object({ 24 | related: z.enum(triggerRelations).optional(), 25 | }); 26 | 27 | export const zIcsTriggerBase: z.ZodType = 28 | z.object({ 29 | options: zIcsTriggerOptions.optional(), 30 | }); 31 | 32 | export const zIcsTrigger: z.ZodType = z.intersection( 33 | zIcsTriggerBase, 34 | zIcsTriggerUnion 35 | ); 36 | 37 | export const parseIcsTrigger: ParseTrigger = (...props) => 38 | convertIcsTrigger(zIcsTrigger, ...props); 39 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/generate/timezoneProp.test.ts: -------------------------------------------------------------------------------- 1 | import { generateIcsTimezoneProp } from "@/lib"; 2 | import type { IcsTimezoneProp } from "@/types"; 3 | 4 | it("Generate non standard value", () => { 5 | const date = new Date(2025, 13, 2); 6 | 7 | const timezoneProp: IcsTimezoneProp = { 8 | nonStandard: { wtf: "yeah" }, 9 | type: "STANDARD", 10 | offsetFrom: "-0500", 11 | offsetTo: "-0400", 12 | start: date, 13 | }; 14 | 15 | const timezonePropString = generateIcsTimezoneProp<{ wtf: string }>( 16 | timezoneProp, 17 | { 18 | nonStandard: { 19 | wtf: { name: "X-WTF", generate: (v) => ({ value: v.toString() }) }, 20 | }, 21 | } 22 | ); 23 | 24 | expect(timezonePropString).toContain("X-WTF:yeah"); 25 | }); 26 | 27 | it("Generate DTSTART - MUST be specified as a date with a local time value - gh#232", () => { 28 | const date = new Date("2025-10-01T12:00:00-04:00"); 29 | 30 | const timezoneProp: IcsTimezoneProp = { 31 | type: "STANDARD", 32 | offsetFrom: "-0500", 33 | offsetTo: "-0400", 34 | start: date, 35 | }; 36 | 37 | const timezonePropString = generateIcsTimezoneProp(timezoneProp); 38 | 39 | expect(timezonePropString).toContain("DTSTART:20251001T120000"); 40 | }); 41 | -------------------------------------------------------------------------------- /packages/ts-ics/src/constants/keys/journal.ts: -------------------------------------------------------------------------------- 1 | import type { IcsJournal } from "@/types"; 2 | import { invertKeys, keysFromObject } from "./utils"; 3 | 4 | export type IcsJournalObjectKey = Exclude; 5 | 6 | export type IcsJournalObjectKeys = IcsJournalObjectKey[]; 7 | 8 | export const VJOURNAL_TO_KEYS = { 9 | categories: "CATEGORIES", 10 | created: "CREATED", 11 | description: "DESCRIPTION", 12 | lastModified: "LAST-MODIFIED", 13 | exceptionDates: "EXDATE", 14 | recurrenceRule: "RRULE", 15 | stamp: "DTSTAMP", 16 | start: "DTSTART", 17 | summary: "SUMMARY", 18 | uid: "UID", 19 | url: "URL", 20 | geo: "GEO", 21 | class: "CLASS", 22 | organizer: "ORGANIZER", 23 | sequence: "SEQUENCE", 24 | status: "STATUS", 25 | attach: "ATTACH", 26 | recurrenceId: "RECURRENCE-ID", 27 | attendees: "ATTENDEE", 28 | comment: "COMMENT", 29 | } as const satisfies Record; 30 | 31 | export const VJOURNAL_TO_OBJECT_KEYS = invertKeys(VJOURNAL_TO_KEYS); 32 | 33 | export type IcsJournalKey = keyof typeof VJOURNAL_TO_OBJECT_KEYS; 34 | export type IcsJournalKeys = IcsJournalKey[]; 35 | 36 | export const VJOURNAL_KEYS = keysFromObject(VJOURNAL_TO_OBJECT_KEYS); 37 | 38 | export const VJOURNAL_OBJECT_KEYS = keysFromObject(VJOURNAL_TO_KEYS); 39 | -------------------------------------------------------------------------------- /packages/ts-ics/src/constants/keys/index.ts: -------------------------------------------------------------------------------- 1 | export const OBJECT_START = "BEGIN"; 2 | export const OBJECT_END = "END"; 3 | 4 | export const VCALENDAR_OBJECT_KEY = "VCALENDAR"; 5 | export const VTIMEZONE_OBJECT_KEY = "VTIMEZONE"; 6 | export const VTIMEZONE_STANDARD_OBJECT_KEY = "STANDARD"; 7 | export const VTIMEZONE_DAYLIGHT_OBJECT_KEY = "DAYLIGHT"; 8 | export const VEVENT_OBJECT_KEY = "VEVENT"; 9 | export const VALARM_OBJECT_KEY = "VALARM"; 10 | export const VTODO_OBJECT_KEY = "VTODO"; 11 | export const VJOURNAL_OBJECT_KEY = "VJOURNAL"; 12 | export const VFREEBUSY_OBJECT_KEY = "VFREEBUSY"; 13 | 14 | export type IcsComponents = typeof ICS_COMPONENTS; 15 | export type IcsComponent = IcsComponents[number]; 16 | 17 | export const ICS_COMPONENTS = [ 18 | VCALENDAR_OBJECT_KEY, 19 | VTIMEZONE_OBJECT_KEY, 20 | VTIMEZONE_STANDARD_OBJECT_KEY, 21 | VTIMEZONE_DAYLIGHT_OBJECT_KEY, 22 | VEVENT_OBJECT_KEY, 23 | VALARM_OBJECT_KEY, 24 | VTODO_OBJECT_KEY, 25 | VJOURNAL_OBJECT_KEY, 26 | VFREEBUSY_OBJECT_KEY, 27 | ] as const; 28 | 29 | export * from "./calendar"; 30 | export * from "./alarm"; 31 | export * from "./event"; 32 | export * from "./timezone"; 33 | export * from "./timezoneProp"; 34 | export * from "./recurrenceRule"; 35 | export * from "./todo"; 36 | export * from "./journal"; 37 | export * from "./freebusy"; 38 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/values/status.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertLineType, ParseLineType } from "../parse"; 2 | 3 | // VEVENT 4 | export const eventStatusTypes = [ 5 | "TENTATIVE", 6 | "CONFIRMED", 7 | "CANCELLED", 8 | ] as const; 9 | 10 | export type IcsEventStatusTypes = typeof eventStatusTypes; 11 | export type IcsEventStatusType = IcsEventStatusTypes[number]; 12 | 13 | export type ConvertEventStatus = ConvertLineType; 14 | 15 | export type ParseEventStatus = ParseLineType; 16 | 17 | // VTODO 18 | export const todoStatusTypes = [ 19 | "NEEDS-ACTION", 20 | "COMPLETED", 21 | "IN-PROGRESS", 22 | "CANCELLED", 23 | ] as const; 24 | 25 | export type IcsTodoStatusTypes = typeof todoStatusTypes; 26 | export type IcsTodoStatusType = IcsTodoStatusTypes[number]; 27 | 28 | export type ConvertTodoStatus = ConvertLineType; 29 | 30 | export type ParseTodoStatus = ParseLineType; 31 | 32 | // VJOURNAL 33 | export const journalStatusTypes = ["DRAFT", "FINAL", "CANCELLED"] as const; 34 | 35 | export type IcsJournalStatusTypes = typeof journalStatusTypes; 36 | export type IcsJournalStatusType = IcsJournalStatusTypes[number]; 37 | 38 | export type ConvertJournalStatus = ConvertLineType; 39 | 40 | export type ParseJournalStatus = ParseLineType; 41 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/generate/attendee.test.ts: -------------------------------------------------------------------------------- 1 | import { generateIcsAttendee } from "@/lib"; 2 | import { IcsAttendee } from "@/types"; 3 | 4 | it("Dont generate attendee role twice - #194", () => { 5 | const attendee: IcsAttendee = { 6 | email: "w@w.com", 7 | role: "REQ-PARTICIPANT", 8 | }; 9 | 10 | const attendeeString = generateIcsAttendee(attendee, "ATTENDEE"); 11 | 12 | expect(attendeeString).toContain( 13 | "ATTENDEE;ROLE=REQ-PARTICIPANT:MAILTO:w@w.com" 14 | ); 15 | expect(attendeeString).not.toContain( 16 | "ATTENDEE;ROLE=REQ-PARTICIPANT;ROLE=REQ-PARTICIPANT:MAILTO:w@w.com" 17 | ); 18 | }); 19 | 20 | describe("Generate RSPV Param - #194", () => { 21 | it("RSVP=TRUE", () => { 22 | const attendee: IcsAttendee = { 23 | email: "w@w.com", 24 | role: "REQ-PARTICIPANT", 25 | rsvp: true, 26 | }; 27 | 28 | const attendeeString = generateIcsAttendee(attendee, "ATTENDEE"); 29 | 30 | expect(attendeeString).toContain("RSVP=TRUE"); 31 | }); 32 | 33 | it("RSVP=FALSE", () => { 34 | const attendee: IcsAttendee = { 35 | email: "w@w.com", 36 | role: "REQ-PARTICIPANT", 37 | rsvp: false, 38 | }; 39 | 40 | const attendeeString = generateIcsAttendee(attendee, "ATTENDEE"); 41 | 42 | expect(attendeeString).toContain("RSVP=FALSE"); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/recurrence/index.ts: -------------------------------------------------------------------------------- 1 | import { addYears } from "date-fns"; 2 | 3 | import type { IcsRecurrenceRule, WeekDayNumber } from "@/types"; 4 | import { weekDays } from "@/types"; 5 | 6 | import { iterateBase } from "./iterate"; 7 | import { iterateBy } from "./iterateBy"; 8 | 9 | export type ExtendByRecurrenceRuleOptions = { 10 | start: Date; 11 | end?: Date; 12 | exceptions?: Date[]; 13 | }; 14 | 15 | export const DEFAULT_END_IN_YEARS = 2; 16 | 17 | export const extendByRecurrenceRule = ( 18 | rule: IcsRecurrenceRule, 19 | options: ExtendByRecurrenceRuleOptions 20 | ): Date[] => { 21 | const start: Date = options.start; 22 | 23 | const end: Date = 24 | rule.until?.date || options?.end || addYears(start, DEFAULT_END_IN_YEARS); 25 | 26 | const exceptions: Date[] = options.exceptions || []; 27 | 28 | const weekStartsOn = ((rule.workweekStart 29 | ? weekDays.indexOf(rule.workweekStart) 30 | : 1) % 7) as WeekDayNumber; 31 | 32 | const dateGroups: Date[][] = [[start]]; 33 | 34 | iterateBase(rule, { start, end }, dateGroups); 35 | 36 | const finalDateGroups = iterateBy( 37 | rule, 38 | { start, end, exceptions, weekStartsOn }, 39 | dateGroups 40 | ); 41 | 42 | const finalDates = rule.count 43 | ? finalDateGroups.flat().splice(0, rule.count) 44 | : finalDateGroups.flat(); 45 | 46 | return finalDates; 47 | }; 48 | -------------------------------------------------------------------------------- /packages/schema-zod/src/values/recurrenceRule.ts: -------------------------------------------------------------------------------- 1 | import { 2 | convertIcsRecurrenceRule, 3 | type ParseRecurrenceRule, 4 | type IcsRecurrenceRule, 5 | recurrenceRuleFrequencies, 6 | } from "ts-ics"; 7 | import { z } from "zod"; 8 | import { zIcsDateObject } from "./date"; 9 | import { zIcsWeekDay, zIcsWeekdayNumber } from "./weekDay"; 10 | 11 | export const zIcsRecurrenceRule: z.ZodType< 12 | IcsRecurrenceRule, 13 | IcsRecurrenceRule 14 | > = z.object({ 15 | frequency: z.enum(recurrenceRuleFrequencies), 16 | until: zIcsDateObject.optional(), 17 | count: z.number().optional(), 18 | interval: z.number().optional(), 19 | bySecond: z.array(z.number().min(0).max(60)).optional(), 20 | byMinute: z.array(z.number().min(0).max(59)).optional(), 21 | byHour: z.array(z.number().min(0).max(23)).optional(), 22 | byDay: z.array(zIcsWeekdayNumber).optional(), 23 | byMonthday: z.array(z.number().min(-31).max(31)).optional(), 24 | byYearday: z.array(z.number().min(1).max(366)).optional(), 25 | byWeekNo: z.array(z.number().min(1).max(53)).optional(), 26 | byMonth: z.array(z.number().min(0).max(11)).optional(), 27 | bySetPos: z.array(z.number().min(-366).max(366)).optional(), 28 | workweekStart: zIcsWeekDay.optional(), 29 | }); 30 | 31 | export const parseIcsRecurrenceRule: ParseRecurrenceRule = (...props) => 32 | convertIcsRecurrenceRule(zIcsRecurrenceRule, ...props); 33 | -------------------------------------------------------------------------------- /packages/ts-ics/src/constants/keys/todo.ts: -------------------------------------------------------------------------------- 1 | import type { IcsTodo } from "@/types"; 2 | import { invertKeys, keysFromObject } from "./utils"; 3 | 4 | export type IcsTodoObjectKey = Exclude; 5 | 6 | export type IcsTodoObjectKeys = IcsTodoObjectKey[]; 7 | 8 | export const VTODO_TO_KEYS = { 9 | categories: "CATEGORIES", 10 | created: "CREATED", 11 | description: "DESCRIPTION", 12 | lastModified: "LAST-MODIFIED", 13 | location: "LOCATION", 14 | exceptionDates: "EXDATE", 15 | recurrenceRule: "RRULE", 16 | stamp: "DTSTAMP", 17 | start: "DTSTART", 18 | summary: "SUMMARY", 19 | uid: "UID", 20 | url: "URL", 21 | duration: "DURATION", 22 | geo: "GEO", 23 | class: "CLASS", 24 | organizer: "ORGANIZER", 25 | priority: "PRIORITY", 26 | sequence: "SEQUENCE", 27 | status: "STATUS", 28 | attach: "ATTACH", 29 | recurrenceId: "RECURRENCE-ID", 30 | attendees: "ATTENDEE", 31 | comment: "COMMENT", 32 | completed: "COMPLETED", 33 | due: "DUE", 34 | percentComplete: "PERCENT-COMPLETE", 35 | } as const satisfies Record; 36 | 37 | export const VTODO_TO_OBJECT_KEYS = invertKeys(VTODO_TO_KEYS); 38 | 39 | export type IcsTodoKey = keyof typeof VTODO_TO_OBJECT_KEYS; 40 | export type IcsTodoKeys = IcsTodoKey[]; 41 | 42 | export const VTODO_KEYS = keysFromObject(VTODO_TO_OBJECT_KEYS); 43 | 44 | export const VTODO_OBJECT_KEYS = keysFromObject(VTODO_TO_KEYS); 45 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/values/recurrenceRule.ts: -------------------------------------------------------------------------------- 1 | import type { IcsDateObject } from "./date"; 2 | import type { ConvertLineType, ParseLineType } from "../parse"; 3 | import type { IcsTimezone } from "../components/timezone"; 4 | import type { IcsWeekDay, IcsWeekdayNumber } from "./weekday"; 5 | 6 | export const recurrenceRuleFrequencies = [ 7 | "SECONDLY", 8 | "MINUTELY", 9 | "HOURLY", 10 | "DAILY", 11 | "WEEKLY", 12 | "MONTHLY", 13 | "YEARLY", 14 | ] as const; 15 | 16 | export type IcsRecurrenceRuleFrequencies = typeof recurrenceRuleFrequencies; 17 | export type IcsRecurrenceRuleFrequency = IcsRecurrenceRuleFrequencies[number]; 18 | 19 | export type IcsRecurrenceRule = { 20 | frequency: IcsRecurrenceRuleFrequency; 21 | until?: IcsDateObject; 22 | count?: number; 23 | interval?: number; 24 | bySecond?: number[]; 25 | byMinute?: number[]; 26 | byHour?: number[]; 27 | byDay?: IcsWeekdayNumber[]; 28 | byMonthday?: number[]; 29 | byYearday?: number[]; 30 | byWeekNo?: number[]; 31 | byMonth?: number[]; 32 | bySetPos?: number[]; 33 | workweekStart?: IcsWeekDay; 34 | }; 35 | 36 | export type ParseRecurrenceRuleOptions = { 37 | timezones?: IcsTimezone[]; 38 | }; 39 | 40 | export type ConvertRecurrenceRule = ConvertLineType< 41 | IcsRecurrenceRule, 42 | ParseRecurrenceRuleOptions 43 | >; 44 | 45 | export type ParseRecurrenceRule = ParseLineType< 46 | IcsRecurrenceRule, 47 | ParseRecurrenceRuleOptions 48 | >; 49 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/values/attendee.ts: -------------------------------------------------------------------------------- 1 | import type { IcsAttendee } from "@/types/values/attendee"; 2 | 3 | import { generateIcsMail } from "../values/mail"; 4 | import { generateIcsLine } from "../utils/addLine"; 5 | import { generateIcsOptions } from "../utils/generateOptions"; 6 | 7 | export const generateIcsAttendee = (attendee: IcsAttendee, key: string) => { 8 | const options = generateIcsOptions( 9 | [ 10 | attendee.dir && { key: "DIR", value: `"${attendee.dir}"` }, 11 | attendee.delegatedFrom && { 12 | key: "DELEGATED-FROM", 13 | value: generateIcsMail(attendee.delegatedFrom, true), 14 | }, 15 | attendee.member && { 16 | key: "MEMBER", 17 | value: generateIcsMail(attendee.member, true), 18 | }, 19 | attendee.role && { key: "ROLE", value: attendee.role }, 20 | attendee.name && { key: "CN", value: attendee.name }, 21 | attendee.partstat && { key: "PARTSTAT", value: attendee.partstat }, 22 | attendee.sentBy && { 23 | key: "SENT-BY", 24 | value: generateIcsMail(attendee.sentBy, true), 25 | }, 26 | attendee.rsvp !== undefined && 27 | (attendee.rsvp === true || attendee.rsvp === false) && { 28 | key: "RSVP", 29 | value: attendee.rsvp === true ? "TRUE" : "FALSE", 30 | }, 31 | ].filter((v) => !!v) 32 | ); 33 | 34 | return generateIcsLine(key, generateIcsMail(attendee.email), options); 35 | }; 36 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/values/timeStamp.ts: -------------------------------------------------------------------------------- 1 | import type { IcsDateObject, IcsTimezone } from "@/types"; 2 | 3 | import { 4 | generateIcsDate, 5 | generateIcsLocalDateTime, 6 | generateIcsUtcDateTime, 7 | } from "./date"; 8 | import { generateIcsLine } from "../utils/addLine"; 9 | import { 10 | generateIcsOptions, 11 | type GenerateIcsOptionsProps, 12 | } from "../utils/generateOptions"; 13 | 14 | type GenerateIcsTimeStampOptions = { 15 | timezones?: IcsTimezone[]; 16 | forceUtc?: boolean; 17 | }; 18 | 19 | export const generateIcsTimeStamp = ( 20 | icsKey: string, 21 | dateObject: IcsDateObject, 22 | lineOptions: GenerateIcsOptionsProps = [], 23 | options?: GenerateIcsTimeStampOptions 24 | ) => { 25 | const icsOptions = generateIcsOptions( 26 | [ 27 | dateObject.type && { key: "VALUE", value: dateObject.type }, 28 | dateObject.local && 29 | !options?.forceUtc && { key: "TZID", value: dateObject.local.timezone }, 30 | ...lineOptions, 31 | ].filter((v) => !!v) 32 | ); 33 | 34 | const value = 35 | dateObject.type === "DATE" 36 | ? generateIcsDate(dateObject.date) 37 | : dateObject.local && !options?.forceUtc 38 | ? generateIcsLocalDateTime( 39 | dateObject.date, 40 | dateObject.local, 41 | options?.timezones 42 | ) 43 | : generateIcsUtcDateTime(dateObject.date); 44 | 45 | return generateIcsLine(icsKey, value, icsOptions); 46 | }; 47 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/components/timezone.ts: -------------------------------------------------------------------------------- 1 | import { VTIMEZONE_TO_KEYS } from "@/constants/keys/timezone"; 2 | import type { IcsTimezone } from "@/types/components/timezone"; 3 | 4 | import { generateIcsUtcDateTime } from "../values/date"; 5 | import { generateIcsTimezoneProp } from "./timezoneProp"; 6 | import { generateIcsLine } from "../utils/addLine"; 7 | import type { NonStandardValuesGeneric } from "@/types/nonStandard/nonStandardValues"; 8 | import { 9 | _generateIcsComponent, 10 | type GenerateIcsComponentProps, 11 | } from "./_component"; 12 | import { VTIMEZONE_OBJECT_KEY } from "@/constants"; 13 | 14 | export const generateIcsTimezone = ( 15 | timezone: IcsTimezone, 16 | options?: Pick< 17 | GenerateIcsComponentProps, 18 | "nonStandard" | "skipFormatLines" 19 | > 20 | ) => 21 | _generateIcsComponent(timezone, { 22 | icsComponent: VTIMEZONE_OBJECT_KEY, 23 | icsKeyMap: VTIMEZONE_TO_KEYS, 24 | generateValues: { 25 | lastModified: ({ icsKey, value }) => 26 | generateIcsLine(icsKey, generateIcsUtcDateTime(value)), 27 | }, 28 | childComponents: { 29 | props: (timezoneProp) => 30 | generateIcsTimezoneProp(timezoneProp, { 31 | nonStandard: options?.nonStandard, 32 | skipFormatLines: true, 33 | }), 34 | }, 35 | nonStandard: options?.nonStandard, 36 | skipFormatLines: options?.skipFormatLines, 37 | }); 38 | -------------------------------------------------------------------------------- /packages/ts-ics/src/constants/keys/event.ts: -------------------------------------------------------------------------------- 1 | import type { IcsEvent } from "@/types"; 2 | import { invertKeys, keysFromObject } from "./utils"; 3 | 4 | export type IcsEventObjectKey = Exclude< 5 | keyof IcsEvent, 6 | "nonStandard" | "descriptionAltRep" 7 | >; 8 | 9 | export type IcsEventObjectKeys = IcsEventObjectKey[]; 10 | 11 | export const VEVENT_TO_KEYS = { 12 | alarms: "ALARM", 13 | categories: "CATEGORIES", 14 | created: "CREATED", 15 | description: "DESCRIPTION", 16 | lastModified: "LAST-MODIFIED", 17 | location: "LOCATION", 18 | exceptionDates: "EXDATE", 19 | recurrenceRule: "RRULE", 20 | stamp: "DTSTAMP", 21 | start: "DTSTART", 22 | summary: "SUMMARY", 23 | uid: "UID", 24 | timeTransparent: "TRANSP", 25 | url: "URL", 26 | end: "DTEND", 27 | duration: "DURATION", 28 | geo: "GEO", 29 | class: "CLASS", 30 | organizer: "ORGANIZER", 31 | priority: "PRIORITY", 32 | sequence: "SEQUENCE", 33 | status: "STATUS", 34 | attach: "ATTACH", 35 | recurrenceId: "RECURRENCE-ID", 36 | attendees: "ATTENDEE", 37 | comment: "COMMENT", 38 | } as const satisfies Record; 39 | 40 | export const VEVENT_TO_OBJECT_KEYS = invertKeys(VEVENT_TO_KEYS); 41 | 42 | export type IcsEventKey = keyof typeof VEVENT_TO_OBJECT_KEYS; 43 | export type IcsEventKeys = IcsEventKey[]; 44 | 45 | export const VEVENT_KEYS = keysFromObject(VEVENT_TO_OBJECT_KEYS); 46 | 47 | export const VEVENT_OBJECT_KEYS = keysFromObject(VEVENT_TO_KEYS); 48 | -------------------------------------------------------------------------------- /packages/schema-tests/tests/parse/freebusy.test.ts: -------------------------------------------------------------------------------- 1 | import { schemaPackageNames } from "../../src/schemas"; 2 | import { icsTestData } from "ts-ics/tests/utils"; 3 | import { VFREEBUSY_OBJECT_KEYS } from "ts-ics"; 4 | 5 | describe("Tests if all values from IcsFreeBusy are parsed from schema package", () => { 6 | schemaPackageNames.forEach((name) => { 7 | const freeBusyString = icsTestData([ 8 | "BEGIN:VFREEBUSY", 9 | "UID:19970901T095957Z-76A912@example.com", 10 | "ORGANIZER:mailto:jane_doe@example.com", 11 | "ATTENDEE:mailto:john_public@example.com", 12 | "DTSTAMP:19970901T100000Z", 13 | "DTSTART:19970901T100000Z", 14 | "DTEND:19980901T100000Z", 15 | "FREEBUSY:19971015T050000Z/PT8H30M,", 16 | " 19971015T160000Z/PT5H30M,19971015T223000Z/PT6H30M", 17 | "URL:http://example.com/pub/busy/jpublic-01.ifb", 18 | "COMMENT:This iCalendar file contains busy time information for", 19 | " the next three months.", 20 | "END:VFREEBUSY", 21 | ]); 22 | 23 | it(name, async () => { 24 | const schemaPackage = await import(name); 25 | 26 | const freeBusy = schemaPackage.parseIcsFreeBusy(freeBusyString); 27 | 28 | VFREEBUSY_OBJECT_KEYS.forEach((freeBusyKey) => { 29 | if (!freeBusy[freeBusyKey]) 30 | throw new Error( 31 | `The freeBusy does not contain the value "${freeBusyKey}".` 32 | ); 33 | }); 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/utils/line.ts: -------------------------------------------------------------------------------- 1 | import { QUOTE, SEMICOLON, SEPARATOR } from "@/constants"; 2 | 3 | import { splitOptions } from "./options"; 4 | import type { Line } from "@/types"; 5 | 6 | type GetLineProps = { 7 | property: TKey; 8 | line: Line; 9 | }; 10 | 11 | export const separateValue = (line: string) => { 12 | let isInsideQuotes = false; 13 | let splitIndex: number | undefined; 14 | 15 | for (let i = 0; i < line.length; i += 1) { 16 | if (splitIndex !== undefined) break; 17 | if (line.charAt(i) === QUOTE) { 18 | isInsideQuotes = !isInsideQuotes; 19 | } 20 | if (line.charAt(i) === SEPARATOR && !isInsideQuotes) { 21 | splitIndex = i; 22 | } 23 | } 24 | 25 | if (splitIndex === undefined) throw Error(`Line not valid: ${line}`); 26 | 27 | const property = line.substring(0, splitIndex); 28 | const value = line.substring(splitIndex + 1); 29 | 30 | return { property, value }; 31 | }; 32 | 33 | export const getLine = ( 34 | line: string 35 | ): GetLineProps => { 36 | const { property, value } = separateValue(line); 37 | 38 | if (property.includes(SEMICOLON)) { 39 | const [splittedProperty, ...optionStrings] = property.split(SEMICOLON); 40 | 41 | const options = splitOptions(optionStrings); 42 | 43 | return { 44 | property: splittedProperty as TKey, 45 | line: { options, value }, 46 | }; 47 | } 48 | 49 | return { property: property as TKey, line: { value } }; 50 | }; 51 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/utils/splitLines.ts: -------------------------------------------------------------------------------- 1 | import { BREAK_REGEX, LF_BREAK } from "../../../constants"; 2 | 3 | const startsWithWhiteSpace = (value: string): boolean => /^[ \t]/.test(value); 4 | const isNewValue = (value: string): boolean => 5 | /^[A-Z]+(?:-[A-Z]+)*[:;]/.test(value); // regex checks for uppercase, "-" between and ":" or ";" at the end to know if its a new line value 6 | 7 | export const splitLines = (str: string) => { 8 | const lines: string[] = []; 9 | 10 | const rawLines = str.split(BREAK_REGEX); 11 | 12 | // remove forgotten leading linebreaks 13 | while (rawLines[0] === "") rawLines.shift(); 14 | 15 | let endIndex = rawLines.length; 16 | 17 | // remove forgotten trailing linebreaks #130 18 | while (endIndex > 0 && rawLines[endIndex - 1] === "") { 19 | endIndex -= 1; 20 | rawLines.pop(); 21 | } 22 | 23 | for (let i = 0; i < rawLines.length; ) { 24 | let line = rawLines[i]; 25 | i += 1; 26 | 27 | while ( 28 | rawLines[i] !== undefined && 29 | (startsWithWhiteSpace(rawLines[i]) || !isNewValue(rawLines[i])) 30 | ) { 31 | if (rawLines[i] === "") { 32 | // handle multiple breaks 33 | line += LF_BREAK; 34 | } else { 35 | if (startsWithWhiteSpace(rawLines[i])) { 36 | line += rawLines[i].substring(1); 37 | } else { 38 | line += LF_BREAK; 39 | line += rawLines[i]; 40 | } 41 | } 42 | i += 1; 43 | } 44 | 45 | lines.push(line); 46 | } 47 | 48 | return lines.filter((l) => l !== ""); 49 | }; 50 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/components/alarm.ts: -------------------------------------------------------------------------------- 1 | import type { IcsAttachment } from "../values/attachment"; 2 | import type { IcsAttendee } from "../values/attendee"; 3 | import type { IcsDuration } from "../values/duration"; 4 | import type { 5 | NonStandardValuesGeneric, 6 | ParseNonStandardValues, 7 | } from "../nonStandard/nonStandardValues"; 8 | import type { ConvertComponentType, ParseComponentType } from "../parse"; 9 | import type { IcsTimezone } from "./timezone"; 10 | import type { IcsTrigger } from "../values/trigger"; 11 | 12 | export type IcsAlarm< 13 | TNonStandardValues extends NonStandardValuesGeneric = NonStandardValuesGeneric 14 | > = { 15 | action?: string; 16 | description?: string; 17 | trigger: IcsTrigger; 18 | attendees?: IcsAttendee[]; 19 | duration?: IcsDuration; 20 | repeat?: number; 21 | summary?: string; 22 | attachments?: IcsAttachment[]; 23 | nonStandard?: Partial; 24 | }; 25 | 26 | export type ParseAlarmOptions< 27 | TNonStandardValues extends NonStandardValuesGeneric 28 | > = { 29 | timezones?: IcsTimezone[]; 30 | nonStandard?: ParseNonStandardValues; 31 | }; 32 | 33 | export type ConvertAlarm = 34 | ConvertComponentType< 35 | IcsAlarm, 36 | ParseAlarmOptions 37 | >; 38 | 39 | export type ParseAlarm = 40 | ParseComponentType< 41 | IcsAlarm, 42 | ParseAlarmOptions 43 | >; 44 | -------------------------------------------------------------------------------- /.github/workflows/benchmark.yml: -------------------------------------------------------------------------------- 1 | name: Benchmark 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | - "!master" 8 | 9 | permissions: 10 | # allow posting comments to pull request 11 | pull-requests: write 12 | 13 | jobs: 14 | benchmark: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v5 19 | with: 20 | fetch-depth: 0 21 | persist-credentials: false 22 | 23 | - name: NodeJs aufsetzen 24 | uses: actions/setup-node@v5 25 | with: 26 | node-version: 24 27 | cache: "npm" 28 | 29 | - name: Install dependencies 30 | run: npm ci 31 | 32 | - name: Build 33 | run: npm run build 34 | 35 | - name: Run benchmark 36 | run: npm run benchmark 37 | # Download previous benchmark result from cache (if exists) 38 | - name: Download previous benchmark data 39 | uses: actions/cache@v4 40 | with: 41 | path: ./cache 42 | key: ${{ runner.os }}-benchmark 43 | # Run `github-action-benchmark` action 44 | - name: Store benchmark result 45 | uses: benchmark-action/github-action-benchmark@v1 46 | with: 47 | tool: "customSmallerIsBetter" 48 | output-file-path: bench_result.json 49 | external-data-json-path: ./cache/benchmark-data.json 50 | github-token: ${{ secrets.GH_TOKEN }} 51 | comment-on-alert: true 52 | summary-always: true 53 | -------------------------------------------------------------------------------- /packages/schema-tests/tests/exports.test.ts: -------------------------------------------------------------------------------- 1 | import { schemaPackageNames } from "../src/schemas"; 2 | 3 | describe("Tests if all parsing functions are exported from schema package", () => { 4 | schemaPackageNames.forEach((name) => { 5 | it(name, async () => { 6 | const schemaPackage = await import(name); 7 | 8 | const requiredExports = [ 9 | "parseIcsAlarm", 10 | "parseIcsAttachment", 11 | "parseIcsAttendee", 12 | "parseIcsCalendar", 13 | "parseIcsClassType", 14 | "parseIcsDate", 15 | "parseIcsDateTime", 16 | "parseIcsDuration", 17 | "parseIcsEvent", 18 | "parseIcsTodo", 19 | "parseIcsJournal", 20 | "parseIcsFreeBusy", 21 | "parseIcsExceptionDate", 22 | "parseIcsOrganizer", 23 | "parseIcsRecurrenceId", 24 | "parseIcsRecurrenceId", 25 | "parseIcsRecurrenceRule", 26 | "parseIcsEventStatus", 27 | "parseIcsTodoStatus", 28 | "parseIcsJournalStatus", 29 | "parseIcsTimeTransparent", 30 | "parseIcsTimezone", 31 | "parseIcsTimezoneProp", 32 | "parseIcsTrigger", 33 | "parseIcsWeekDay", 34 | "parseIcsWeekdayNumber", 35 | ]; 36 | 37 | requiredExports.forEach((requiredExport) => { 38 | if (!schemaPackage[requiredExport]) 39 | throw new Error( 40 | `The export "${requiredExport}" is missing from the schema package "${name}".` 41 | ); 42 | }); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/components/timezoneProp.ts: -------------------------------------------------------------------------------- 1 | import { VTIMEZONE_PROP_TO_KEYS } from "@/constants/keys/timezoneProp"; 2 | import type { IcsTimezoneProp } from "@/types/components/timezoneProp"; 3 | 4 | import { generateIcsLocalOnlyDateTime } from "../values/date"; 5 | import { generateIcsRecurrenceRule } from "../values/recurrenceRule"; 6 | import { generateIcsTimeStamp } from "../values/timeStamp"; 7 | import { generateIcsLine } from "../utils/addLine"; 8 | import type { NonStandardValuesGeneric } from "@/types/nonStandard/nonStandardValues"; 9 | import { 10 | _generateIcsComponent, 11 | type GenerateIcsComponentProps, 12 | } from "./_component"; 13 | 14 | export const generateIcsTimezoneProp = ( 15 | timezoneProp: IcsTimezoneProp, 16 | options?: Pick< 17 | GenerateIcsComponentProps, 18 | "nonStandard" | "skipFormatLines" 19 | > 20 | ) => 21 | _generateIcsComponent(timezoneProp, { 22 | icsComponent: timezoneProp.type, 23 | icsKeyMap: VTIMEZONE_PROP_TO_KEYS, 24 | generateValues: { 25 | start: ({ icsKey, value }) => 26 | generateIcsLine( 27 | icsKey, 28 | generateIcsLocalOnlyDateTime(value, timezoneProp.offsetTo) 29 | ), 30 | recurrenceRule: ({ value }) => generateIcsRecurrenceRule(value), 31 | recurrenceDate: ({ icsKey, value }) => 32 | generateIcsTimeStamp(icsKey, value), 33 | }, 34 | omitGenerateKeys: ["type"], 35 | nonStandard: options?.nonStandard, 36 | skipFormatLines: options?.skipFormatLines, 37 | }); 38 | -------------------------------------------------------------------------------- /packages/schema-zod/src/components/calendar.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { 3 | calendarMethods, 4 | calendarVersions, 5 | convertIcsCalendar, 6 | type ParseCalendar, 7 | type IcsCalendar, 8 | type NonStandardValuesGeneric, 9 | } from "ts-ics"; 10 | import { zIcsTimezone } from "./timezone"; 11 | import { zIcsEvent } from "./event"; 12 | import { zIcsTodo } from "./todo"; 13 | import { zIcsJournal } from "./journal"; 14 | import { zIcsFreeBusy } from "./freebusy"; 15 | 16 | export const zIcsCalenderVersion = z.enum(calendarVersions); 17 | 18 | export const zIcsCalendarMethod = z.union([ 19 | z.enum(calendarMethods), 20 | z.string(), 21 | ]); 22 | 23 | export const zIcsCalendar: z.ZodType< 24 | // biome-ignore lint/suspicious/noExplicitAny: 25 | IcsCalendar, 26 | // biome-ignore lint/suspicious/noExplicitAny: 27 | IcsCalendar 28 | > = z.object({ 29 | version: zIcsCalenderVersion, 30 | prodId: z.string(), 31 | method: zIcsCalendarMethod.optional(), 32 | timezones: z.array(zIcsTimezone).optional(), 33 | events: z.array(zIcsEvent).optional(), 34 | todos: z.array(zIcsTodo).optional(), 35 | journals: z.array(zIcsJournal).optional(), 36 | freeBusy: z.array(zIcsFreeBusy).optional(), 37 | name: z.string().optional(), 38 | nonStandard: z.record(z.string(), z.any()).optional(), 39 | }); 40 | 41 | export const parseIcsCalendar = ( 42 | ...props: Parameters> 43 | ): ReturnType> => convertIcsCalendar(zIcsCalendar, ...props); 44 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/components/timezone.ts: -------------------------------------------------------------------------------- 1 | import { VTIMEZONE_OBJECT_KEY } from "@/constants"; 2 | import { VTIMEZONE_TO_OBJECT_KEYS } from "@/constants/keys/timezone"; 3 | import type { ConvertTimezone, IcsTimezone } from "@/types/components/timezone"; 4 | 5 | import { convertIcsDateTime } from "../values/date"; 6 | import { convertIcsTimezoneProp } from "../components/timezoneProp"; 7 | import type { NonStandardValuesGeneric } from "@/types/nonStandard/nonStandardValues"; 8 | import { _convertIcsComponent } from "./_component"; 9 | 10 | export const convertIcsTimezone = ( 11 | ...args: Parameters> 12 | ): ReturnType> => { 13 | const [schema, rawTimezoneString, options] = args; 14 | 15 | return _convertIcsComponent, T, "DAYLIGHT" | "STANDARD">( 16 | schema, 17 | rawTimezoneString, 18 | { 19 | icsComponent: VTIMEZONE_OBJECT_KEY, 20 | objectKeyMap: VTIMEZONE_TO_OBJECT_KEYS, 21 | convertValues: { 22 | lastModified: ({ line }) => convertIcsDateTime(undefined, line), 23 | }, 24 | childComponents: { 25 | props: { 26 | icsComponent: ["DAYLIGHT", "STANDARD"], 27 | convert: (rawTimezonePropString) => 28 | convertIcsTimezoneProp(undefined, rawTimezonePropString, { 29 | nonStandard: options?.nonStandard, 30 | timezones: options?.timezones, 31 | }), 32 | }, 33 | }, 34 | nonStandard: options?.nonStandard, 35 | timezones: options?.timezones, 36 | } 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/components/alarm.ts: -------------------------------------------------------------------------------- 1 | import { VALARM_OBJECT_KEY } from "@/constants"; 2 | import { VALARM_TO_OBJECT_KEYS } from "@/constants/keys/alarm"; 3 | import type { ConvertAlarm } from "@/types"; 4 | 5 | import { convertIcsAttachment } from "../values/attachment"; 6 | import { convertIcsAttendee } from "../values/attendee"; 7 | import { convertIcsDuration } from "../values/duration"; 8 | import { convertIcsTrigger } from "../values/trigger"; 9 | import type { NonStandardValuesGeneric } from "@/types/nonStandard/nonStandardValues"; 10 | import { _convertIcsComponent } from "./_component"; 11 | import { convertIcsInteger } from "../values"; 12 | 13 | export const convertIcsAlarm = ( 14 | ...args: Parameters> 15 | ): ReturnType> => { 16 | const [schema, rawAlarmString, options] = args; 17 | 18 | return _convertIcsComponent(schema, rawAlarmString, { 19 | icsComponent: VALARM_OBJECT_KEY, 20 | objectKeyMap: VALARM_TO_OBJECT_KEYS, 21 | convertValues: { 22 | trigger: ({ line }) => 23 | convertIcsTrigger(undefined, line, { 24 | timezones: options?.timezones, 25 | }), 26 | duration: ({ line }) => convertIcsDuration(undefined, line), 27 | repeat: ({ line }) => convertIcsInteger(undefined, line), 28 | }, 29 | convertArrayValues: { 30 | attachments: ({ line }) => convertIcsAttachment(undefined, line), 31 | attendees: ({ line }) => convertIcsAttendee(undefined, line), 32 | }, 33 | nonStandard: options?.nonStandard, 34 | timezones: options?.timezones, 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/values/duration.ts: -------------------------------------------------------------------------------- 1 | import type { ConvertDuration, IcsDuration } from "@/types"; 2 | import { standardValidate } from "../utils/standardValidate"; 3 | 4 | export const convertIcsDuration: ConvertDuration = (schema, line) => { 5 | let newString = line.value; 6 | 7 | const duration: Partial = {}; 8 | 9 | if (newString[0] === "-") { 10 | duration.before = true; 11 | newString = newString.slice(1); 12 | } 13 | newString = newString.slice(1); // P entfernen 14 | 15 | const parts = newString.split("T"); 16 | 17 | let datePart = parts[0]; 18 | 19 | if (datePart.includes("D")) { 20 | const [days, rest] = datePart.split("D"); 21 | 22 | duration.days = Number(days); 23 | datePart = rest; 24 | } 25 | 26 | if (datePart.includes("W")) { 27 | const [weeks, rest] = datePart.split("W"); 28 | 29 | duration.weeks = Number(weeks); 30 | datePart = rest; 31 | } 32 | 33 | let timePart = parts[1]; 34 | 35 | if (timePart) { 36 | if (timePart.includes("H")) { 37 | const [hours, rest] = timePart.split("H"); 38 | 39 | duration.hours = Number(hours); 40 | timePart = rest; 41 | } 42 | 43 | if (timePart.includes("M")) { 44 | const [minutes, rest] = timePart.split("M"); 45 | 46 | duration.minutes = Number(minutes); 47 | timePart = rest; 48 | } 49 | 50 | if (timePart.includes("S")) { 51 | const [seconds, rest] = timePart.split("S"); 52 | 53 | duration.seconds = Number(seconds); 54 | timePart = rest; 55 | } 56 | } 57 | 58 | return standardValidate(schema, duration as IcsDuration); 59 | }; 60 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/utils/formatLines.test.ts: -------------------------------------------------------------------------------- 1 | import { formatLines } from "./formatLines"; 2 | import { icsTestData } from "../../../../tests/utils"; 3 | 4 | it("Correcly break lines longer than the max line length", async () => { 5 | const unformatted = `ORGANIZER;CN=JohnSmith;DIR="ldap://example.com:6666/o=DC%20Associates,c=US???(cn=John%20Smith)":mailto:jsmith@example.com`; 6 | 7 | const formatted = icsTestData([ 8 | `ORGANIZER;CN=JohnSmith;DIR="ldap://example.com:6666/o=DC%20Associates,c=US?`, 9 | ` ??(cn=John%20Smith)":mailto:jsmith@example.com`, 10 | ]); 11 | 12 | expect(formatLines(unformatted)).toEqual(formatted); 13 | }); 14 | 15 | it("Correctly handle LF line breaks", async () => { 16 | const unformatted = 17 | "DESCRIPTION:Dear Mr. Admin,\n\nWe would like to use the appointment to discuss the information regarding the administration of travel documents for the trip to Norway for [Travel Company].\n\n\n\nBest regards,\n\nTest User"; 18 | 19 | const formatted = icsTestData([ 20 | "DESCRIPTION:Dear Mr. Admin,\n\nWe would like to use the appointment to disc", 21 | " uss the information regarding the administration of travel documents for th", 22 | " e trip to Norway for [Travel Company].\n\n\n\nBest regards,\n\nTest User", 23 | ]); 24 | 25 | expect(formatLines(unformatted)).toEqual(formatted); 26 | }); 27 | 28 | it("Correctly handles escaped newlines in description - gh#183", async () => { 29 | const unformatted = "DESCRIPTION:Test\n\\nb"; 30 | 31 | const formatted = icsTestData(["DESCRIPTION:Test\n\\nb"]); 32 | 33 | expect(formatLines(unformatted)).toStrictEqual(formatted); 34 | }); 35 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/generate/duration.test.ts: -------------------------------------------------------------------------------- 1 | import { generateIcsDuration } from "@/lib"; 2 | import type { IcsDuration } from "@/types"; 3 | 4 | it("Duration is generated correctly", () => { 5 | const duration: IcsDuration = { 6 | weeks: 1, 7 | days: 15, 8 | hours: 5, 9 | minutes: 10, 10 | seconds: 20, 11 | }; 12 | 13 | const durationString = generateIcsDuration(duration); 14 | 15 | expect(durationString).toContain("P1W15DT5H10M20S"); 16 | }); 17 | 18 | it("Handles 0 values correctly - #194", () => { 19 | const duration: IcsDuration = { 20 | hours: 0, 21 | minutes: 0, 22 | seconds: 0, 23 | }; 24 | 25 | const durationString = generateIcsDuration(duration); 26 | 27 | expect(durationString).toContain("PT0H0M0S"); 28 | }); 29 | 30 | describe("Handles empty object correctly - gh#194", () => { 31 | it("Empty object", () => { 32 | const duration: IcsDuration = {}; 33 | 34 | const durationString = generateIcsDuration(duration); 35 | 36 | expect(durationString).toBe(undefined); 37 | }); 38 | 39 | it("Only undefined values in object", () => { 40 | const duration: IcsDuration = { minutes: undefined, seconds: undefined }; 41 | 42 | const durationString = generateIcsDuration(duration); 43 | 44 | expect(durationString).toBe(undefined); 45 | }); 46 | 47 | it("Only undefined values in object but true before value", () => { 48 | const duration: IcsDuration = { before: true, minutes: undefined }; 49 | 50 | const durationString = generateIcsDuration(duration); 51 | 52 | expect(durationString).toBe(undefined); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/components/alarm.ts: -------------------------------------------------------------------------------- 1 | import { VALARM_TO_KEYS } from "@/constants/keys/alarm"; 2 | import type { IcsAlarm } from "@/types"; 3 | 4 | import { generateIcsAttachment } from "../values/attachment"; 5 | import { generateIcsAttendee } from "../values/attendee"; 6 | import { generateIcsDuration } from "../values/duration"; 7 | import { generateIcsTrigger } from "../values/trigger"; 8 | import { generateIcsLine } from "../utils/addLine"; 9 | import type { NonStandardValuesGeneric } from "@/types/nonStandard/nonStandardValues"; 10 | import { 11 | _generateIcsComponent, 12 | type GenerateIcsComponentProps, 13 | } from "./_component"; 14 | import { VALARM_OBJECT_KEY } from "@/constants"; 15 | import { generateIcsInteger } from "../values/integer"; 16 | 17 | export const generateIcsAlarm = ( 18 | alarm: IcsAlarm, 19 | options?: Pick< 20 | GenerateIcsComponentProps, 21 | "nonStandard" | "skipFormatLines" 22 | > 23 | ) => 24 | _generateIcsComponent(alarm, { 25 | icsComponent: VALARM_OBJECT_KEY, 26 | icsKeyMap: VALARM_TO_KEYS, 27 | generateValues: { 28 | trigger: ({ value }) => generateIcsTrigger(value), 29 | duration: ({ icsKey, value }) => 30 | generateIcsLine(icsKey, generateIcsDuration(value)), 31 | repeat: ({ icsKey, value }) => generateIcsInteger(icsKey, value), 32 | }, 33 | generateArrayValues: { 34 | attendees: ({ value }) => generateIcsAttendee(value, "ATTENDEE"), 35 | attachments: ({ value }) => generateIcsAttachment(value), 36 | }, 37 | nonStandard: options?.nonStandard, 38 | skipFormatLines: options?.skipFormatLines, 39 | }); 40 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/nonStandard/nonStandardValues.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | GenerateNonStandardValues, 3 | NonStandardValuesGeneric, 4 | } from "@/types/nonStandard/nonStandardValues"; 5 | import { generateIcsLine } from "../utils/addLine"; 6 | import { generateIcsOptions } from "../utils/generateOptions"; 7 | 8 | export const generateNonStandardValues = ( 9 | nonStandardValues?: T, 10 | nonStandardOptions?: GenerateNonStandardValues 11 | ): string => { 12 | if (!nonStandardValues) return ""; 13 | 14 | let nonStandardValuesString = ""; 15 | 16 | Object.entries(nonStandardValues).forEach(([key, value]) => { 17 | const option = nonStandardOptions?.[key]; 18 | 19 | if (!option) { 20 | nonStandardValuesString += generateIcsLine( 21 | toUpperCase(key), 22 | value?.toString() 23 | ); 24 | return; 25 | } 26 | 27 | const line = option.generate(value); 28 | 29 | if (!line) return; 30 | 31 | nonStandardValuesString += generateIcsLine( 32 | option.name, 33 | line.value, 34 | line.options 35 | ? generateIcsOptions( 36 | Object.entries(line.options).map(([key, value]) => ({ key, value })) 37 | ) 38 | : undefined 39 | ); 40 | }); 41 | 42 | return nonStandardValuesString; 43 | }; 44 | 45 | const toUpperCase = (prop: string): string => { 46 | let result = "X-"; 47 | 48 | for (const char of prop) { 49 | if (char === char.toUpperCase()) { 50 | result += "-"; 51 | } 52 | result += char.toUpperCase(); 53 | } 54 | 55 | return result; 56 | }; 57 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/parse/attendee.test.ts: -------------------------------------------------------------------------------- 1 | import { getLine } from "@/lib/parse/utils/line"; 2 | 3 | import { convertIcsAttendee } from "@/lib/parse/values/attendee"; 4 | 5 | it("Test Ics IcsAttendee Parse", async () => { 6 | const attendee = `ATTENDEE;MEMBER="mailto:DEV-GROUP@example.com":mailto:joecool@example.com`; 7 | 8 | const { line } = getLine(attendee); 9 | 10 | expect(() => convertIcsAttendee(undefined, line)).not.toThrow(); 11 | }); 12 | 13 | it("Test Ics IcsAttendee Parse", async () => { 14 | const attendee = `ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="mailto:bob@example.com";PARTSTAT=ACCEPTED;CN=Jane Doe:mailto:jdoe@example.com`; 15 | 16 | const { line } = getLine(attendee); 17 | 18 | const parsed = convertIcsAttendee(undefined, line); 19 | 20 | expect(() => parsed).not.toThrow(); 21 | 22 | expect(parsed).toEqual({ 23 | role: "REQ-PARTICIPANT", 24 | delegatedFrom: "bob@example.com", 25 | partstat: "ACCEPTED", 26 | name: "Jane Doe", 27 | email: "jdoe@example.com", 28 | }); 29 | }); 30 | 31 | describe("Parse RSPV Param - #194", () => { 32 | it("RSVP=TRUE", () => { 33 | const attendeeString = 34 | "ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT:MAILTO:w@w.com"; 35 | 36 | const { line } = getLine(attendeeString); 37 | 38 | const attendee = convertIcsAttendee(undefined, line); 39 | 40 | expect(attendee.rsvp).toEqual(true); 41 | }); 42 | 43 | it("RSVP=FALSE", () => { 44 | const attendeeString = 45 | "ATTENDEE;RSVP=FALSE;ROLE=REQ-PARTICIPANT:MAILTO:w@w.com"; 46 | 47 | const { line } = getLine(attendeeString); 48 | 49 | const attendee = convertIcsAttendee(undefined, line); 50 | 51 | expect(attendee.rsvp).toEqual(false); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/values/timeStamp.ts: -------------------------------------------------------------------------------- 1 | import { addMilliseconds } from "date-fns"; 2 | 3 | import type { Line, ParseTimeStampOptions, ConvertTimeStamp } from "@/types"; 4 | import type { DateObjectType } from "@/types/values/date"; 5 | 6 | import { convertIcsDateTime, convertIcsDate } from "./date"; 7 | import { getTimezoneObjectOffset } from "@/utils/timezone/getTimezone"; 8 | import { standardValidate } from "../utils/standardValidate"; 9 | 10 | const __convertIcsTimeStamp = (line: Line, options?: ParseTimeStampOptions) => { 11 | if (line.options?.VALUE === "DATE") 12 | return { 13 | date: convertIcsDate(undefined, line), 14 | type: line.options?.VALUE as DateObjectType, 15 | }; 16 | 17 | const type: DateObjectType = 18 | (line.options?.VALUE as DateObjectType) || "DATE-TIME"; 19 | 20 | const dateTime = convertIcsDateTime(undefined, line); 21 | 22 | if (!line.options?.TZID) 23 | return { 24 | date: dateTime, 25 | type, 26 | }; 27 | 28 | const timezone = getTimezoneObjectOffset( 29 | dateTime, 30 | line.options.TZID, 31 | options?.timezones 32 | ); 33 | 34 | if (!timezone) 35 | return { 36 | date: dateTime, 37 | type, 38 | }; 39 | 40 | return { 41 | date: addMilliseconds(dateTime, -timezone.milliseconds), 42 | type, 43 | local: line.options?.TZID 44 | ? { 45 | date: dateTime, 46 | timezone: line.options?.TZID, 47 | tzoffset: timezone.offset, 48 | } 49 | : undefined, 50 | }; 51 | }; 52 | 53 | export const convertIcsTimeStamp: ConvertTimeStamp = ( 54 | schema, 55 | line, 56 | options 57 | ) => { 58 | return standardValidate(schema, __convertIcsTimeStamp(line, options)); 59 | }; 60 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/values/date.ts: -------------------------------------------------------------------------------- 1 | import { timeZoneOffsetToMilliseconds } from "@/utils"; 2 | import { standardValidate } from "../utils/standardValidate"; 3 | import type { ConvertDate } from "@/types"; 4 | import { subMilliseconds } from "date-fns"; 5 | 6 | export const convertIcsDate: ConvertDate = (schema, line) => { 7 | const year = Number.parseInt(line.value.slice(0, 4), 10); 8 | const month = Number.parseInt(line.value.slice(4, 6), 10) - 1; // Monate in JavaScript sind 0-basiert 9 | const day = Number.parseInt(line.value.slice(6, 8), 10); 10 | 11 | const newDate = new Date(Date.UTC(year, month, day)); 12 | 13 | return standardValidate(schema, newDate); 14 | }; 15 | 16 | export const convertIcsDateTime: ConvertDate = (schema, line) => { 17 | const year = Number.parseInt(line.value.slice(0, 4), 10); 18 | const month = Number.parseInt(line.value.slice(4, 6), 10) - 1; // Monate in JavaScript sind 0-basiert 19 | const day = Number.parseInt(line.value.slice(6, 8), 10); 20 | const hour = Number.parseInt(line.value.slice(9, 11), 10); 21 | const minute = Number.parseInt(line.value.slice(11, 13), 10); 22 | const second = Number.parseInt(line.value.slice(13, 15), 10); 23 | 24 | const newDate = new Date(Date.UTC(year, month, day, hour, minute, second)); 25 | 26 | return standardValidate(schema, newDate); 27 | }; 28 | 29 | export const convertIcsLocalOnlyDateTime = ( 30 | schema: Parameters[0], 31 | line: Parameters[1], 32 | offset: string 33 | ) => { 34 | const offsetMs = timeZoneOffsetToMilliseconds(offset); 35 | const date = convertIcsDateTime(undefined, line); 36 | 37 | const offsetDate = subMilliseconds(date, offsetMs); 38 | 39 | return standardValidate(schema, offsetDate); 40 | }; 41 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/utils/formatLines.ts: -------------------------------------------------------------------------------- 1 | import { CRLF_BREAK, CRLF_BREAK_REGEX, MAX_LINE_LENGTH } from "@/constants"; 2 | 3 | // Hilfsfunktion zur Berechnung der tatsächlichen Länge unter Berücksichtigung von \n 4 | const getActualLength = (str: string): number => { 5 | // Zähle jedes \n als zwei Zeichen 6 | const newlineCount = (str.match(/\n/g) || []).length; 7 | return str.length + newlineCount; 8 | }; 9 | 10 | export const formatLines = (lines: string) => { 11 | const newLines = lines.split(CRLF_BREAK_REGEX); 12 | const formattedLines: string[] = []; 13 | 14 | newLines.forEach((line) => { 15 | if (getActualLength(line) < MAX_LINE_LENGTH) { 16 | formattedLines.push(line); 17 | return; 18 | } 19 | foldLine(line, MAX_LINE_LENGTH).forEach((l) => { 20 | formattedLines.push(l); 21 | }); 22 | }); 23 | 24 | return formattedLines.join(CRLF_BREAK); 25 | }; 26 | 27 | const foldLine = (line: string, maxLength: number) => { 28 | const lines = []; 29 | let currentLine = ""; 30 | let currentLength = 0; 31 | 32 | // Zeichen für Zeichen durchgehen 33 | for (let i = 0; i < line.length; i++) { 34 | const char = line[i]; 35 | const isNewline = char === "\n"; 36 | const charLength = isNewline ? 2 : 1; // \n zählt als 2 Zeichen 37 | 38 | // Prüfen ob das nächste Zeichen noch in die Zeile passt 39 | if (currentLength + charLength > maxLength) { 40 | lines.push(lines.length === 0 ? currentLine : ` ${currentLine}`); 41 | currentLine = char; 42 | currentLength = charLength; 43 | } else { 44 | currentLine += char; 45 | currentLength += charLength; 46 | } 47 | } 48 | 49 | // Letzte Zeile hinzufügen 50 | if (currentLine) { 51 | lines.push(lines.length === 0 ? currentLine : ` ${currentLine}`); 52 | } 53 | 54 | return lines; 55 | }; 56 | -------------------------------------------------------------------------------- /packages/schema-tests/tests/parse/journal.test.ts: -------------------------------------------------------------------------------- 1 | import { schemaPackageNames } from "../../src/schemas"; 2 | import { icsTestData } from "ts-ics/tests/utils"; 3 | import { VJOURNAL_OBJECT_KEYS } from "ts-ics"; 4 | 5 | describe("Tests if all values from IcsJournal are parsed from schema package", () => { 6 | schemaPackageNames.forEach((name) => { 7 | const journalString = icsTestData([ 8 | "BEGIN:VJOURNAL", 9 | "UID:19970901T130000Z-123403@example.com", 10 | "DTSTAMP:19970901T130000Z", 11 | "DTSTART;VALUE=DATE:19971102", 12 | "CREATED;VALUE=DATE:19971102", 13 | "LAST-MODIFIED;VALUE=DATE:19971102", 14 | "URL:https://test.de", 15 | "EXDATE:20070402T010000Z", 16 | "SUMMARY:Our Blissful Anniversary", 17 | "DESCRIPTION:Some description.", 18 | "COMMENT:Some comment.", 19 | "LOCATION:Unknown place", 20 | "GEO:48.85299;2.36885", 21 | `ORGANIZER;CN="Alice Balder, Example Inc.":MAILTO:alice@example.com`, 22 | "PRIORITY:5", 23 | "SEQUENCE:1", 24 | "STATUS:DRAFT", 25 | "ATTACH;FMTTYPE=audio/basic:ftp://example.com/pub/sounds/bell-01.aud", 26 | `ATTENDEE;MEMBER="mailto:DEV-GROUP@example.com":mailto:joecool@example.com`, 27 | "RECURRENCE-ID;VALUE=DATE:19960401", 28 | "CLASS:CONFIDENTIAL", 29 | "CATEGORIES:ANNIVERSARY,PERSONAL,SPECIAL OCCASION", 30 | "RRULE:FREQ=YEARLY", 31 | "END:VJOURNAL", 32 | ]); 33 | 34 | it(name, async () => { 35 | const schemaPackage = await import(name); 36 | 37 | const journal = schemaPackage.parseIcsJournal(journalString); 38 | 39 | VJOURNAL_OBJECT_KEYS.forEach((journalKey) => { 40 | if (!journal[journalKey]) 41 | throw new Error( 42 | `The journal does not contain the value "${journalKey}".` 43 | ); 44 | }); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/generate/recurrenceRule.test.ts: -------------------------------------------------------------------------------- 1 | import { generateIcsRecurrenceRule, convertIcsRecurrenceRule } from "@/lib"; 2 | import { getLine } from "@/lib/parse/utils/line"; 3 | import type { IcsRecurrenceRule } from "@/types"; 4 | 5 | it("IcsRecurrenceRule UNTIL is generated correctly - DATE-TIME", async () => { 6 | const rule: IcsRecurrenceRule = { 7 | frequency: "WEEKLY", 8 | until: { date: new Date(2024, 10, 3), type: "DATE-TIME" }, 9 | }; 10 | 11 | const ruleString = generateIcsRecurrenceRule(rule); 12 | 13 | const parsed = convertIcsRecurrenceRule(undefined, getLine(ruleString).line); 14 | 15 | expect(rule).toEqual(parsed); 16 | }); 17 | 18 | it("IcsRecurrenceRule UNTIL is not 'undefined' when left empty", async () => { 19 | const rule: IcsRecurrenceRule = { 20 | frequency: "WEEKLY", 21 | until: { date: new Date(2024, 10, 3) }, 22 | }; 23 | 24 | const ruleString = generateIcsRecurrenceRule(rule); 25 | 26 | expect(ruleString).not.toContain("undefined"); 27 | }); 28 | 29 | // it("IcsRecurrenceRule UNTIL is generated correctly - with local", async () => { // Dont works because cant find example of how UNTIL with Timezone string is created 30 | // const date = new Date(2023, 6, 2, 14, 30); 31 | 32 | // const rule: IcsRecurrenceRule = { 33 | // frequency: "WEEKLY", 34 | // until: { 35 | // date, 36 | // type: "DATE-TIME", 37 | // local: { 38 | // date: addMilliseconds( 39 | // date, 40 | // getOffsetFromTimezoneId("America/New_York", date) 41 | // ), 42 | // timezone: "America/New_York", 43 | // tzoffset: "-0400", 44 | // }, 45 | // }, 46 | // }; 47 | 48 | // const ruleString = generateIcsRecurrenceRule(rule); 49 | 50 | // const parsed = convertIcsRecurrenceRule(getLine(ruleString).value); 51 | 52 | // expect(rule).toEqual(parsed); 53 | // }); 54 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/components/freebusy.ts: -------------------------------------------------------------------------------- 1 | import { VFREEBUSY_OBJECT_KEY } from "@/constants"; 2 | import { VFREEBUSY_TO_OBJECT_KEYS } from "@/constants/keys/freebusy"; 3 | import type { ConvertFreeBusy } from "@/types"; 4 | 5 | import { convertIcsAttendee } from "../values/attendee"; 6 | import { convertIcsOrganizer } from "../values/organizer"; 7 | import { convertIcsTimeStamp } from "../values/timeStamp"; 8 | import type { NonStandardValuesGeneric } from "@/types/nonStandard/nonStandardValues"; 9 | import { convertIcsFreeBusyTime } from "../values/freebusyValue"; 10 | import { _convertIcsComponent } from "./_component"; 11 | import { convertIcsText } from "../values"; 12 | 13 | export const convertIcsFreeBusy = ( 14 | ...args: Parameters> 15 | ): ReturnType> => { 16 | const [schema, rawFreeBusyString, options] = args; 17 | 18 | return _convertIcsComponent(schema, rawFreeBusyString, { 19 | icsComponent: VFREEBUSY_OBJECT_KEY, 20 | objectKeyMap: VFREEBUSY_TO_OBJECT_KEYS, 21 | convertValues: { 22 | stamp: ({ line }) => 23 | convertIcsTimeStamp(undefined, line, { 24 | timezones: options?.timezones, 25 | }), 26 | start: ({ line }) => 27 | convertIcsTimeStamp(undefined, line, { 28 | timezones: options?.timezones, 29 | }), 30 | end: ({ line }) => 31 | convertIcsTimeStamp(undefined, line, { 32 | timezones: options?.timezones, 33 | }), 34 | comment: ({ line }) => convertIcsText(undefined, line), 35 | organizer: ({ line }) => convertIcsOrganizer(undefined, line), 36 | }, 37 | convertArrayValues: { 38 | attendees: ({ line }) => convertIcsAttendee(undefined, line), 39 | freeBusy: ({ line }) => convertIcsFreeBusyTime(undefined, line), 40 | }, 41 | nonStandard: options?.nonStandard, 42 | timezones: options?.timezones, 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /packages/schema-tests/tests/parse/todo.test.ts: -------------------------------------------------------------------------------- 1 | import { schemaPackageNames } from "../../src/schemas"; 2 | import { icsTestData } from "ts-ics/tests/utils"; 3 | import { VTODO_OBJECT_KEYS } from "ts-ics"; 4 | 5 | describe("Tests if all values from IcsTodo are parsed from schema package", () => { 6 | schemaPackageNames.forEach((name) => { 7 | const todoString = icsTestData([ 8 | "BEGIN:VTODO", 9 | "UID:19970901T130000Z-123403@example.com", 10 | "DTSTAMP:19970901T130000Z", 11 | "DTSTART;VALUE=DATE:19971102", 12 | "CREATED;VALUE=DATE:19971102", 13 | "LAST-MODIFIED;VALUE=DATE:19971102", 14 | "URL:https://test.de", 15 | "EXDATE:20070402T010000Z", 16 | "DURATION:P1D", 17 | "SUMMARY:Our Blissful Anniversary", 18 | "DESCRIPTION:Some description.", 19 | "COMMENT:Some comment.", 20 | "LOCATION:Unknown place", 21 | "COMPLETED:19970901T130000Z", 22 | "PERCENT-COMPLETE:80", 23 | "GEO:48.85299;2.36885", 24 | `ORGANIZER;CN="Alice Balder, Example Inc.":MAILTO:alice@example.com`, 25 | "PRIORITY:5", 26 | "SEQUENCE:1", 27 | "STATUS:NEEDS-ACTION", 28 | "ATTACH;FMTTYPE=audio/basic:ftp://example.com/pub/sounds/bell-01.aud", 29 | `ATTENDEE;MEMBER="mailto:DEV-GROUP@example.com":mailto:joecool@example.com`, 30 | "RECURRENCE-ID;VALUE=DATE:19960401", 31 | "CLASS:CONFIDENTIAL", 32 | "CATEGORIES:ANNIVERSARY,PERSONAL,SPECIAL OCCASION", 33 | "RRULE:FREQ=YEARLY", 34 | "END:VTODO", 35 | ]); 36 | 37 | it(name, async () => { 38 | const schemaPackage = await import(name); 39 | 40 | const todo = schemaPackage.parseIcsTodo(todoString); 41 | 42 | VTODO_OBJECT_KEYS.filter((k) => k !== "due").forEach((todoKey) => { 43 | if (!todo[todoKey]) 44 | throw new Error(`The todo does not contain the value "${todoKey}".`); 45 | }); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/components/timezoneProp.ts: -------------------------------------------------------------------------------- 1 | import { 2 | VTIMEZONE_DAYLIGHT_OBJECT_KEY, 3 | VTIMEZONE_STANDARD_OBJECT_KEY, 4 | } from "@/constants"; 5 | import type { IcsDateObject } from "../values/date"; 6 | import type { 7 | NonStandardValuesGeneric, 8 | ParseNonStandardValues, 9 | } from "../nonStandard/nonStandardValues"; 10 | import type { ConvertComponentType, ParseComponentType } from "../parse"; 11 | import type { IcsRecurrenceRule } from "../values/recurrenceRule"; 12 | import type { IcsTimezone } from "./timezone"; 13 | 14 | export const TIMEZONE_PROP_COMPONENTS = [ 15 | VTIMEZONE_STANDARD_OBJECT_KEY, 16 | VTIMEZONE_DAYLIGHT_OBJECT_KEY, 17 | ] as const; 18 | 19 | export type IcsTimezonePropTypes = typeof TIMEZONE_PROP_COMPONENTS; 20 | export type IcsTimezonePropType = IcsTimezonePropTypes[number]; 21 | 22 | export type IcsTimezoneProp< 23 | TNonStandardValues extends NonStandardValuesGeneric = NonStandardValuesGeneric 24 | > = { 25 | type: IcsTimezonePropType; 26 | start: Date; 27 | offsetTo: string; 28 | offsetFrom: string; 29 | recurrenceRule?: IcsRecurrenceRule; 30 | comment?: string; 31 | recurrenceDate?: IcsDateObject; 32 | name?: string; 33 | nonStandard?: TNonStandardValues; 34 | }; 35 | 36 | export type ParseTimezonePropOptions< 37 | TNonStandardValues extends NonStandardValuesGeneric 38 | > = { 39 | timezones?: IcsTimezone[]; 40 | nonStandard?: ParseNonStandardValues; 41 | }; 42 | 43 | export type ConvertTimezoneProp< 44 | TNonStandardValues extends NonStandardValuesGeneric 45 | > = ConvertComponentType< 46 | IcsTimezoneProp, 47 | ParseTimezonePropOptions 48 | >; 49 | 50 | export type ParseTimezoneProp< 51 | TNonStandardValues extends NonStandardValuesGeneric 52 | > = ParseComponentType< 53 | IcsTimezoneProp, 54 | ParseTimezonePropOptions 55 | >; 56 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # ts-ics 2 | 3 | ## 1.2.2 4 | 5 | ### Patch Changes 6 | 7 | - abd34d4: fix attendee participation status per specs #61 8 | 9 | ## 1.2.1 10 | 11 | ### Patch Changes 12 | 13 | - 135afba: Deps updaten #58 14 | 15 | ## 1.2.0 16 | 17 | ### Minor Changes 18 | 19 | - 898ff3d: Extend dates with recurrence #49 20 | - 9adc635: Add recurrence to timezones #50 21 | 22 | ### Patch Changes 23 | 24 | - a966b60: Update readme #51 25 | 26 | ## 1.1.0 27 | 28 | ### Minor Changes 29 | 30 | - 24e423f: parseTimestamp and parseIcsEvent should accept IcsTimezone[] | undefined #42 31 | 32 | ## 1.0.12 33 | 34 | ### Patch Changes 35 | 36 | - ee6e623: Changed splitLines to merge lines based on whitespace at start #41 37 | 38 | ## 1.0.11 39 | 40 | ### Patch Changes 41 | 42 | - 7a51531: Update deps #37 43 | - 7a51531: Fixed parsing events with long fields (i.e. description) #36 44 | 45 | ## 1.0.10 46 | 47 | ### Patch Changes 48 | 49 | - a2e3929: Date - Strip Milliseconds #33 50 | 51 | ## 1.0.9 52 | 53 | ### Patch Changes 54 | 55 | - 042f3bc: Deps updaten #30 56 | 57 | ## 1.0.8 58 | 59 | ### Patch Changes 60 | 61 | - 3cb10fb: IcsAttendee korrekt parsen (CN) #25 62 | 63 | ## 1.0.7 64 | 65 | ### Patch Changes 66 | 67 | - a004028: foldLine fixen #22 68 | 69 | ## 1.0.6 70 | 71 | ### Patch Changes 72 | 73 | - d155057: IcsAttendee Partstat Type anpassen #19 74 | 75 | ## 1.0.5 76 | 77 | ### Patch Changes 78 | 79 | - 771e773: Funktion um IcsDuration zu berechnen zwischen 2 Dates #16 80 | 81 | ## 1.0.4 82 | 83 | ### Patch Changes 84 | 85 | - 28f5b91: Export hinzufügen für getEvendEnd #13 86 | 87 | ## 1.0.3 88 | 89 | ### Patch Changes 90 | 91 | - 2621304: Funktionen für IcsDuration bereitstellen #8 92 | 93 | ## 1.0.2 94 | 95 | ### Patch Changes 96 | 97 | - 732e6b5: Types optimieren #5 98 | 99 | ## 1.0.1 100 | 101 | ### Patch Changes 102 | 103 | - df32a78: Npm Ignore anpassen 104 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/utils/unescapeText.test.ts: -------------------------------------------------------------------------------- 1 | import { icsTestData } from "../../../../tests/utils"; 2 | import { convertIcsEvent } from "../components/event"; 3 | import { unescapeTextString } from "./unescapeText"; 4 | 5 | describe("unescapeTextString", () => { 6 | it("unescapes basic special characters", () => { 7 | expect(unescapeTextString("Hello\\, World")).toBe("Hello, World"); 8 | expect(unescapeTextString("Test\\; Text")).toBe("Test; Text"); 9 | expect(unescapeTextString("Path\\\\to\\\\file")).toBe("Path\\to\\file"); 10 | expect(unescapeTextString("Line 1\\nLine 2")).toBe("Line 1\nLine 2"); 11 | expect(unescapeTextString("Line 1\\NLine 2")).toBe("Line 1\nLine 2"); 12 | }); 13 | 14 | it("handles multiple escaped characters", () => { 15 | expect( 16 | unescapeTextString( 17 | "Path\\, Name\\; Value\\\\Backslash\\NNewline\\nnewline" 18 | ) 19 | ).toBe("Path, Name; Value\\Backslash\nNewline\nnewline"); 20 | }); 21 | 22 | it("handles complex combinations correctly", () => { 23 | const input = "Path\\\\to\\\\file\\, Description\\; Multiple\\nLines"; 24 | const expected = "Path\\to\\file, Description; Multiple\nLines"; 25 | expect(unescapeTextString(input)).toBe(expected); 26 | }); 27 | }); 28 | 29 | describe("Test in IcsEvent", () => { 30 | it("should unescape text - gh#183", () => { 31 | const calendar = icsTestData([ 32 | "BEGIN:VEVENT", 33 | "UID:1", 34 | "DTSTART:20250522T173000Z", 35 | "DTEND:20250523T150000Z", 36 | "CREATED:20250520T114851Z", 37 | "DESCRIPTION:Start\\,-\\;-\\n-\\\\N-\\\\-\\n-\\\\n-\\\\\\\\End", 38 | "DTSTAMP:20250526T123957Z", 39 | "LAST-MODIFIED:20250526T123957Z", 40 | "SUMMARY:Summary", 41 | "END:VEVENT", 42 | ]); 43 | 44 | const icsEvent = convertIcsEvent(undefined, calendar); 45 | 46 | expect(icsEvent.description).toBe(`Start,-;-\n-\\N-\\- 47 | -\\n-\\\\End`); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/components/timezoneProp.ts: -------------------------------------------------------------------------------- 1 | import { VTIMEZONE_PROP_TO_OBJECT_KEYS } from "@/constants/keys/timezoneProp"; 2 | import { 3 | type IcsTimezonePropType, 4 | TIMEZONE_PROP_COMPONENTS, 5 | type ConvertTimezoneProp, 6 | } from "@/types/components/timezoneProp"; 7 | 8 | import { convertIcsLocalOnlyDateTime } from "../values/date"; 9 | import { convertIcsRecurrenceRule } from "../values/recurrenceRule"; 10 | import { convertIcsTimeStamp } from "../values/timeStamp"; 11 | import type { NonStandardValuesGeneric } from "@/types/nonStandard/nonStandardValues"; 12 | import { _convertIcsComponent } from "./_component"; 13 | import { BREAK_REGEX } from "@/constants"; 14 | 15 | export const convertIcsTimezoneProp = ( 16 | ...args: Parameters> 17 | ): ReturnType> => { 18 | const [schema, rawTimezonePropString, options] = args; 19 | 20 | const rawType = rawTimezonePropString 21 | .split("BEGIN:")[1] 22 | .split(BREAK_REGEX)[0]; 23 | 24 | const type = TIMEZONE_PROP_COMPONENTS.includes(rawType as IcsTimezonePropType) 25 | ? (rawType as IcsTimezonePropType) 26 | : "STANDARD"; 27 | 28 | const offsetTo = rawTimezonePropString 29 | .split("TZOFFSETTO:")[1] 30 | .split(BREAK_REGEX)[0]; 31 | 32 | return _convertIcsComponent(schema, rawTimezonePropString, { 33 | icsComponent: type, 34 | objectKeyMap: VTIMEZONE_PROP_TO_OBJECT_KEYS, 35 | defaultValues: { type }, 36 | convertValues: { 37 | start: ({ line }) => 38 | convertIcsLocalOnlyDateTime(undefined, line, offsetTo), 39 | recurrenceRule: ({ line }) => 40 | convertIcsRecurrenceRule(undefined, line, { 41 | timezones: options?.timezones, 42 | }), 43 | recurrenceDate: ({ line }) => 44 | convertIcsTimeStamp(undefined, line, { 45 | timezones: options?.timezones, 46 | }), 47 | }, 48 | nonStandard: options?.nonStandard, 49 | timezones: options?.timezones, 50 | }); 51 | }; 52 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/components/calendar.ts: -------------------------------------------------------------------------------- 1 | import type { IcsEvent } from "./event"; 2 | import type { IcsFreeBusy } from "./freebusy"; 3 | import type { IcsJournal } from "./journal"; 4 | import type { 5 | NonStandardValuesGeneric, 6 | ParseNonStandardValues, 7 | } from "../nonStandard/nonStandardValues"; 8 | import type { ConvertComponentType, ParseComponentType } from "../parse"; 9 | import type { IcsTimezone } from "./timezone"; 10 | import type { IcsTodo } from "./todo"; 11 | 12 | export const calendarMethods = ["PUBLISH"] as const; 13 | 14 | export type IcsCalendarMethods = typeof calendarMethods; 15 | export type IcsCalenderMethod = IcsCalendarMethods[number]; 16 | 17 | export const calendarVersions = ["2.0"] as const; 18 | 19 | export type IcsCalendarVersions = typeof calendarVersions; 20 | export type IcsCalendarVersion = IcsCalendarVersions[number]; 21 | 22 | export type IcsCalendar< 23 | TNonStandardValues extends NonStandardValuesGeneric = NonStandardValuesGeneric 24 | > = { 25 | version: IcsCalendarVersion; 26 | prodId: string; 27 | method?: IcsCalenderMethod | string; 28 | timezones?: IcsTimezone[]; 29 | events?: IcsEvent[]; 30 | todos?: IcsTodo[]; 31 | journals?: IcsJournal[]; 32 | freeBusy?: IcsFreeBusy[]; 33 | name?: string; 34 | nonStandard?: Partial; 35 | }; 36 | 37 | export type ParseCalendarOptions< 38 | TNonStandardValues extends NonStandardValuesGeneric 39 | > = { 40 | nonStandard?: ParseNonStandardValues; 41 | }; 42 | 43 | export type ConvertCalendar< 44 | TNonStandardValues extends NonStandardValuesGeneric 45 | > = ConvertComponentType< 46 | IcsCalendar, 47 | ParseCalendarOptions 48 | >; 49 | 50 | export type ParseCalendar = 51 | ParseComponentType< 52 | IcsCalendar, 53 | ParseCalendarOptions 54 | >; 55 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/values/date.ts: -------------------------------------------------------------------------------- 1 | import type { DateObjectTzProps, IcsTimezone } from "@/types"; 2 | import { getTimezoneObjectOffset, timeZoneOffsetToMilliseconds } from "@/utils"; 3 | import { addMilliseconds, isDate } from "date-fns"; 4 | 5 | export const generateIcsDate = (date: Date) => { 6 | if (!isDate(date)) throw Error(`Incorrect date object: ${date}`); 7 | 8 | const isoDate = date.toISOString(); 9 | 10 | const year = isoDate.slice(0, 4); 11 | const month = isoDate.slice(5, 7); 12 | const d = isoDate.slice(8, 10); 13 | 14 | return `${year}${month}${d}`; 15 | }; 16 | 17 | export const generateIcsUtcDateTime = (date: Date) => { 18 | if (!isDate(date)) throw Error(`Incorrect date object: ${date}`); 19 | 20 | return generateIcsDateTime(date); 21 | }; 22 | 23 | export const generateIcsLocalDateTime = ( 24 | date: Date, 25 | local: DateObjectTzProps, 26 | timezones?: IcsTimezone[] 27 | ): string => { 28 | const localDate = local.date; 29 | 30 | if (!isDate(localDate)) throw Error(`Incorrect date object: ${localDate}`); 31 | 32 | const timezone = getTimezoneObjectOffset( 33 | localDate, 34 | local.timezone, 35 | timezones 36 | ); 37 | 38 | if (!timezone) { 39 | return generateIcsUtcDateTime(date); 40 | } 41 | 42 | return generateIcsDateTime(localDate, true); 43 | }; 44 | 45 | const generateIcsDateTime = (date: Date, isLocal?: boolean): string => { 46 | const isoDate = date.toISOString(); 47 | 48 | const year = isoDate.slice(0, 4); 49 | const month = isoDate.slice(5, 7); 50 | const d = isoDate.slice(8, 10); 51 | const hour = isoDate.slice(11, 13); 52 | const minutes = isoDate.slice(14, 16); 53 | const seconds = isoDate.slice(17, 19); 54 | 55 | return `${year}${month}${d}T${hour}${minutes}${seconds}${isLocal ? "" : "Z"}`; 56 | }; 57 | 58 | export const generateIcsLocalOnlyDateTime = (date: Date, offset: string) => { 59 | const offsetMs = timeZoneOffsetToMilliseconds(offset); 60 | const offsetDate = addMilliseconds(date, offsetMs); 61 | 62 | return generateIcsDateTime(offsetDate, true); 63 | }; 64 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/components/calendar.ts: -------------------------------------------------------------------------------- 1 | import { VCALENDAR_OBJECT_KEY, VCALENDAR_TO_KEYS } from "@/constants/keys"; 2 | import type { IcsCalendar } from "@/types"; 3 | 4 | import { generateIcsEvent } from "./event"; 5 | import { generateIcsTimezone } from "./timezone"; 6 | import type { NonStandardValuesGeneric } from "@/types/nonStandard/nonStandardValues"; 7 | import { generateIcsTodo } from "./todo"; 8 | import { generateIcsJournal } from "./journal"; 9 | import { generateIcsFreeBusy } from "./freebusy"; 10 | import { 11 | _generateIcsComponent, 12 | type GenerateIcsComponentProps, 13 | } from "./_component"; 14 | 15 | export const generateIcsCalendar = ( 16 | calendar: IcsCalendar, 17 | options?: Pick, "nonStandard"> 18 | ) => 19 | _generateIcsComponent(calendar, { 20 | icsComponent: VCALENDAR_OBJECT_KEY, 21 | icsKeyMap: VCALENDAR_TO_KEYS, 22 | generateValues: {}, 23 | childComponents: { 24 | timezones: (timezone) => 25 | generateIcsTimezone(timezone, { 26 | nonStandard: options?.nonStandard, 27 | skipFormatLines: true, 28 | }), 29 | events: (event) => 30 | generateIcsEvent(event, { 31 | skipFormatLines: true, 32 | timezones: calendar.timezones, 33 | nonStandard: options?.nonStandard, 34 | }), 35 | todos: (todo) => 36 | generateIcsTodo(todo, { 37 | skipFormatLines: true, 38 | timezones: calendar.timezones, 39 | nonStandard: options?.nonStandard, 40 | }), 41 | journals: (journal) => 42 | generateIcsJournal(journal, { 43 | skipFormatLines: true, 44 | timezones: calendar.timezones, 45 | nonStandard: options?.nonStandard, 46 | }), 47 | freeBusy: (freeBusy) => 48 | generateIcsFreeBusy(freeBusy, { 49 | skipFormatLines: true, 50 | timezones: calendar.timezones, 51 | nonStandard: options?.nonStandard, 52 | }), 53 | }, 54 | nonStandard: options?.nonStandard, 55 | }); 56 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/components/freebusy.ts: -------------------------------------------------------------------------------- 1 | import { VFREEBUSY_TO_KEYS } from "@/constants/keys/freebusy"; 2 | 3 | import type { IcsFreeBusy } from "@/types"; 4 | 5 | import { generateIcsAttendee } from "../values/attendee"; 6 | import { generateIcsOrganizer } from "../values/organizer"; 7 | import { generateIcsTimeStamp } from "../values/timeStamp"; 8 | import type { NonStandardValuesGeneric } from "@/types/nonStandard/nonStandardValues"; 9 | import { generateIcsFreeBusyTime } from "../values/freebusyValue"; 10 | import { 11 | _generateIcsComponent, 12 | type GenerateIcsComponentProps, 13 | } from "./_component"; 14 | import { VFREEBUSY_OBJECT_KEY } from "@/constants"; 15 | import { generateIcsText } from "../values"; 16 | 17 | export const generateIcsFreeBusy = ( 18 | freeBusy: IcsFreeBusy, 19 | options?: Pick< 20 | GenerateIcsComponentProps, 21 | "nonStandard" | "skipFormatLines" | "timezones" 22 | > 23 | ) => 24 | _generateIcsComponent(freeBusy, { 25 | icsComponent: VFREEBUSY_OBJECT_KEY, 26 | icsKeyMap: VFREEBUSY_TO_KEYS, 27 | generateValues: { 28 | stamp: ({ icsKey, value }) => 29 | generateIcsTimeStamp(icsKey, value, undefined, { 30 | timezones: options?.timezones, 31 | forceUtc: true, 32 | }), 33 | start: ({ icsKey, value }) => 34 | generateIcsTimeStamp(icsKey, value, undefined, { 35 | timezones: options?.timezones, 36 | }), 37 | end: ({ icsKey, value }) => 38 | generateIcsTimeStamp(icsKey, value, undefined, { 39 | timezones: options?.timezones, 40 | }), 41 | comment: ({ icsKey, value }) => generateIcsText(icsKey, value), 42 | organizer: ({ value }) => generateIcsOrganizer(value), 43 | }, 44 | generateArrayValues: { 45 | attendees: ({ value }) => generateIcsAttendee(value, "ATTENDEE"), 46 | freeBusy: ({ value }) => generateIcsFreeBusyTime(value, "FREEBUSY"), 47 | }, 48 | nonStandard: options?.nonStandard, 49 | skipFormatLines: options?.skipFormatLines, 50 | timezones: options?.timezones, 51 | }); 52 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/parse/fixtures/longDescriptionEvent.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VEVENT 2 | DESCRIPTION:xx xxxxxnx & xxxxxx\,\n\nxx xxxxxxxxx\, nx xxx xxx 3 | x xxx xn xnxxxxxxxxnxx xxxx xxxxxx xx xxxx xxxxxxx xx xxx nxxx xxxx.\n\nxx 4 | ’x xxxxxxx: xxxxx://xxx.xxnxxxxn.xxx/xn/xxxxxnxxxxxxxxx/\n\nxxnxxxxxx xx 5 | xxxxx - xxxxx://xxx.xxnxxxxn.xxx/xn/xxxxxx-xxnxxxxxxx/\n\nxxx xxxxxx - xxx 6 | xx://xxx.xxxxxn.xxxx/xn/xxxx/xxxxxxx/xxxxxxxx-xxxxxxxxxnx-xnxxnxxx-xxxx-xx 7 | xx\n\nxxxxxx xxx xx xnxx xx xxx xxxx nxxxx xnx xxxxx xxxx.\n\nxxxnxx\,\n\n 8 | xxxxx\n\nxxx xxxx xxxn xnxxxxx xx xn xnxxnx xxxxxnx\, xxxxxxx xx xxxxxn xx 9 | xxx.\n\nxxxxx xx xxxn xxx xxxxxnx: xxxxx://xxxxx.xxx/\nxxxxxnx x 10 | x: xxxx xx xxxx\nx xxxxxxx xx xxxxxxxnxxx xx xxx xxx xxx xxxx xxxxxxxx’x 11 | xxxxxxxxnx xnx xxxxxxxx.\n\nxxxx xn xxxnx xxxx xxxnx:\nxnxxxx xxxxxx xxxx 12 | -xxxx (x): +x xxx-xxx-xxxx\nxxxxxnx xx: xxxx xx xxxx\n 13 | xnx-xxxxx xxxxxx xxxx-xn (xnxxxx xxxxxx (x)): +x xxx-xxx-xxxx\,\,\,xxxxxxx 14 | xxx#\nxnxxxx xxxxxx (x): +x xxx-xxx-xx 15 | xx\nxnxxxnxxxxnxx: xxxxx://xxxxx.xxx/xxxxxnnxxxxxx/\nx 16 | xxx-xn xxxxnxxxx xxxx xnxxx *x xx xxxx xx xnxxxx xxxxxxxxxx.\n\nxx xxnnxxx 17 | xxxx xn xn-xxxx xxxxx xxxxxx\; xxx xnx xx xxx xxxxxxxnx xxxxxn xxxxx xxxx 18 | xxx:\nxxx xxxxx xxxxxx: xxxxxxxxxx@xxxx.xxxxx.xn xx xxxx.xxxxx.xn\nx.xxx xxxxxx: xx.xxx.xxx.xxx xx xx.xxx.xx.xxx\nx 20 | x xxxxxxxx xnxxx xxx xxxxxnx xxN: xxxxxxxxxx#\nxxxnxxxx xxxxxn xxxxx xx xx 21 | xxx://xxx.xxxxxn.xxx/xxxxx/xxxnxxxx\nxxx xnxxxxxxxxn xxxxx xxxxxxnx xn xxx 22 | xxn xxxxx xxxxxnx\, xxx xxxxx://xxx.xxxxxn.xxx/xxxxx/xxxxxnx-xxxxxxx\n 23 | UID:040000008200E00074C5B7101A82E00800000000F0BEC878B1C3D901000000000000000 24 | 010000000DD8309E078039740BC00E43EF7064CA4 25 | SUMMARY:Some really cool event with long description 26 | DTSTART;TZID=America/New_York:20231024T173000 27 | DTEND;TZID=America/New_York:20231024T180000 28 | CLASS:PUBLIC 29 | PRIORITY:5 30 | DTSTAMP:20231024T204754Z 31 | TRANSP:OPAQUE 32 | STATUS:CONFIRMED 33 | SEQUENCE:0 34 | LOCATION:Online 35 | END:VEVENT -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/generate/values/recurrenceRule.ts: -------------------------------------------------------------------------------- 1 | import type { IcsRecurrenceRule } from "@/types"; 2 | 3 | import { generateIcsLine } from "../utils/addLine"; 4 | import { generateIcsOptions } from "../utils/generateOptions"; 5 | import { generateIcsWeekdayNumber } from "../values/weekdayNumber"; 6 | import { generateIcsDate, generateIcsUtcDateTime } from "./date"; 7 | 8 | export const generateIcsRecurrenceRule = (value: IcsRecurrenceRule) => { 9 | let icsString = ""; 10 | 11 | const options = generateIcsOptions( 12 | [ 13 | value.byDay && { 14 | key: "BYDAY", 15 | value: value.byDay.map((v) => generateIcsWeekdayNumber(v)).join(","), 16 | }, 17 | value.byHour && { key: "BYHOUR", value: value.byHour.join(",") }, 18 | value.byMinute && { key: "BYMINUTE", value: value.byMinute.join(",") }, 19 | value.byMonth && { 20 | key: "BYMONTH", 21 | value: value.byMonth.map((v) => v + 1).join(","), // Javascript Monat fängt bei 0 an, ICS byMonth bei 1 22 | }, 23 | value.byMonthday && { 24 | key: "BYMONTHDAY", 25 | value: value.byMonthday.join(","), 26 | }, 27 | value.bySecond && { key: "BYSECOND", value: value.bySecond.join(",") }, 28 | value.bySetPos && { key: "BYSETPOS", value: value.bySetPos.join(",") }, 29 | value.byWeekNo && { key: "BYWEEKNO", value: value.byWeekNo.join(",") }, 30 | value.byYearday && { key: "BYYEARDAY", value: value.byYearday.join(",") }, 31 | value.count && { key: "COUNT", value: value.count.toString() }, 32 | value.frequency && { key: "FREQ", value: value.frequency }, 33 | value.interval && { key: "INTERVAL", value: value.interval.toString() }, 34 | value.until && { 35 | key: "UNTIL", 36 | value: 37 | value.until.type === "DATE" 38 | ? generateIcsDate(value.until.date) 39 | : generateIcsUtcDateTime( 40 | value.until.local?.date || value.until.date 41 | ), 42 | }, 43 | value.workweekStart && { key: "WKST", value: value.workweekStart }, 44 | ].filter((v) => !!v) 45 | ); 46 | 47 | icsString += generateIcsLine("RRULE", options); 48 | 49 | return icsString; 50 | }; 51 | -------------------------------------------------------------------------------- /packages/schema-tests/tests/parse/event.test.ts: -------------------------------------------------------------------------------- 1 | import { schemaPackageNames } from "../../src/schemas"; 2 | import { icsTestData } from "ts-ics/tests/utils"; 3 | import { VEVENT_OBJECT_KEYS } from "ts-ics"; 4 | 5 | describe("Tests if all values from IcsEvent are parsed from schema package", () => { 6 | schemaPackageNames.forEach((name) => { 7 | const eventString = icsTestData([ 8 | "BEGIN:VEVENT", 9 | "UID:19970901T130000Z-123403@example.com", 10 | "DTSTAMP:19970901T130000Z", 11 | "DTSTART;VALUE=DATE:19971102", 12 | "CREATED;VALUE=DATE:19971102", 13 | "LAST-MODIFIED;VALUE=DATE:19971102", 14 | "URL:https://test.de", 15 | "EXDATE:20070402T010000Z", 16 | "DURATION:P1D", 17 | "SUMMARY:Our Blissful Anniversary", 18 | "DESCRIPTION:Some description.", 19 | "COMMENT:Some comment.", 20 | "TRANSP:TRANSPARENT", 21 | "LOCATION:Unknown place", 22 | "GEO:48.85299;2.36885", 23 | `ORGANIZER;CN="Alice Balder, Example Inc.":MAILTO:alice@example.com`, 24 | "PRIORITY:5", 25 | "SEQUENCE:1", 26 | "STATUS:CONFIRMED", 27 | "ATTACH;FMTTYPE=audio/basic:ftp://example.com/pub/sounds/bell-01.aud", 28 | `ATTENDEE;MEMBER="mailto:DEV-GROUP@example.com":mailto:joecool@example.com`, 29 | "RECURRENCE-ID;VALUE=DATE:19960401", 30 | "CLASS:CONFIDENTIAL", 31 | "CATEGORIES:ANNIVERSARY,PERSONAL,SPECIAL OCCASION", 32 | "RRULE:FREQ=YEARLY", 33 | "BEGIN:VALARM", 34 | "TRIGGER;VALUE=DATE-TIME:19970317T133000Z", 35 | "REPEAT:4", 36 | "DURATION:PT15M", 37 | "ACTION:AUDIO", 38 | "ATTACH;FMTTYPE=audio/basic:ftp://example.com/pub/sounds/bell-01.aud", 39 | "END:VALARM", 40 | "END:VEVENT", 41 | ]); 42 | 43 | it(name, async () => { 44 | const schemaPackage = await import(name); 45 | 46 | const event = schemaPackage.parseIcsEvent(eventString); 47 | 48 | VEVENT_OBJECT_KEYS.filter((k) => k !== "end").forEach((eventKey) => { 49 | if (!event[eventKey]) 50 | throw new Error( 51 | `The event does not contain the value "${eventKey}".` 52 | ); 53 | }); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /packages/ts-ics/src/lib/parse/nonStandard/nonStandardValues.ts: -------------------------------------------------------------------------------- 1 | import type { Line } from "@/types"; 2 | import type { 3 | NonStandardValuesGeneric, 4 | ParseNonStandardValue, 5 | ParseNonStandardValues, 6 | } from "@/types/nonStandard/nonStandardValues"; 7 | import { standardValidate } from "../utils/standardValidate"; 8 | 9 | export const convertNonStandardValues = < 10 | T extends { nonStandard?: NonStandardValuesGeneric }, 11 | TNonStandardValues extends NonStandardValuesGeneric 12 | >( 13 | base: T, 14 | nonStandardValues: Record, 15 | nonStandardOptions?: ParseNonStandardValues 16 | ) => { 17 | if (!nonStandardValues) return base; 18 | 19 | const finalNonStandardValues: NonStandardValuesGeneric = {}; 20 | 21 | Object.entries(nonStandardValues).forEach(([property, line]) => { 22 | const nonStandardOption: [string, ParseNonStandardValue] | undefined = 23 | Object.entries(nonStandardOptions || {}).find( 24 | ([_, option]) => option.name === property 25 | ); 26 | 27 | if (!nonStandardOption) { 28 | finalNonStandardValues[toCamelCase(property)] = line.value; 29 | return; 30 | } 31 | 32 | const value = nonStandardOption[1].convert(line); 33 | 34 | const schema = nonStandardOption[1].schema; 35 | 36 | if (!schema) { 37 | finalNonStandardValues[nonStandardOption[0]] = value; 38 | return; 39 | } 40 | 41 | finalNonStandardValues[nonStandardOption[0]] = standardValidate( 42 | schema, 43 | value 44 | ); 45 | }); 46 | 47 | base.nonStandard = finalNonStandardValues; 48 | 49 | return base as T & { nonStandard: TNonStandardValues }; 50 | }; 51 | 52 | const toCamelCase = (prop: string): string => { 53 | const propWithoutPrefix = prop.startsWith("X-") ? prop.slice(2) : prop; 54 | 55 | let result = ""; 56 | let capitalizeNext = false; 57 | 58 | for (const char of propWithoutPrefix) { 59 | if (char === "-") { 60 | capitalizeNext = true; 61 | } else { 62 | result += capitalizeNext ? char.toUpperCase() : char.toLowerCase(); 63 | capitalizeNext = false; 64 | } 65 | } 66 | 67 | return result; 68 | }; 69 | -------------------------------------------------------------------------------- /packages/schema-zod/src/components/journal.ts: -------------------------------------------------------------------------------- 1 | import { 2 | convertIcsJournal, 3 | type NonStandardValuesGeneric, 4 | type IcsJournal, 5 | type ParseJournal, 6 | } from "ts-ics"; 7 | import { z } from "zod"; 8 | import { zIcsDateObject } from "../values/date"; 9 | import { zIcsExceptionDates } from "../values/exceptionDate"; 10 | import { zIcsAttendee } from "../values/attendee"; 11 | import { zIcsRecurrenceRule } from "../values/recurrenceRule"; 12 | import { zIcsClassType } from "../values/class"; 13 | import { zIcsOrganizer } from "../values/organizer"; 14 | import { zIcsJournalStatusType } from "../values/status"; 15 | import { zIcsRecurrenceId } from "../values/recurrenceId"; 16 | 17 | export const zIcsJournal: z.ZodType< 18 | // biome-ignore lint/suspicious/noExplicitAny: 19 | IcsJournal, 20 | // biome-ignore lint/suspicious/noExplicitAny: 21 | IcsJournal 22 | > = z.object({ 23 | summary: z.string().optional(), 24 | uid: z.string(), 25 | created: zIcsDateObject.optional(), 26 | lastModified: zIcsDateObject.optional(), 27 | completed: zIcsDateObject.optional(), 28 | start: zIcsDateObject.optional(), 29 | stamp: zIcsDateObject, 30 | location: z.string().optional(), 31 | description: z.string().optional(), 32 | categories: z.array(z.string()).optional(), 33 | exceptionDates: zIcsExceptionDates.optional(), 34 | recurrenceRule: zIcsRecurrenceRule.optional(), 35 | url: z.url().optional(), 36 | geo: z.string().optional(), 37 | class: zIcsClassType.optional(), 38 | organizer: zIcsOrganizer.optional(), 39 | priority: z.string().optional(), 40 | sequence: z.number().int().optional(), 41 | status: zIcsJournalStatusType.optional(), 42 | attach: z.string().optional(), 43 | recurrenceId: zIcsRecurrenceId.optional(), 44 | attendees: z.array(zIcsAttendee).optional(), 45 | comment: z.string().optional(), 46 | nonStandard: z.record(z.string(), z.any()).optional(), 47 | percentComplete: z.number().int().optional(), 48 | }); 49 | 50 | export const parseIcsJournal = ( 51 | ...props: Parameters> 52 | ): ReturnType> => convertIcsJournal(zIcsJournal, ...props); 53 | -------------------------------------------------------------------------------- /packages/ts-ics/src/types/components/journal.ts: -------------------------------------------------------------------------------- 1 | import type { IcsDateObject } from "../values/date"; 2 | import type { IcsTimezone } from "./timezone"; 3 | import type { ConvertComponentType, ParseComponentType } from "../parse"; 4 | import type { 5 | NonStandardValuesGeneric, 6 | ParseNonStandardValues, 7 | } from "../nonStandard/nonStandardValues"; 8 | import type { IcsClassType } from "../values/class"; 9 | import type { IcsOrganizer } from "../values/organizer"; 10 | import type { IcsRecurrenceId } from "../values/recurrenceId"; 11 | import type { IcsJournalStatusType } from "../values/status"; 12 | import type { IcsRecurrenceRule } from "../values/recurrenceRule"; 13 | import type { IcsAttendee } from "../values/attendee"; 14 | import type { IcsExceptionDates } from "../values/exceptionDate"; 15 | 16 | export type IcsJournal< 17 | TNonStandardValues extends NonStandardValuesGeneric = NonStandardValuesGeneric 18 | > = { 19 | stamp: IcsDateObject; 20 | uid: string; 21 | class?: IcsClassType; 22 | created?: IcsDateObject; 23 | start?: IcsDateObject; 24 | lastModified?: IcsDateObject; 25 | organizer?: IcsOrganizer; 26 | recurrenceId?: IcsRecurrenceId; 27 | sequence?: number; 28 | status?: IcsJournalStatusType; 29 | summary?: string; 30 | url?: string; 31 | recurrenceRule?: IcsRecurrenceRule; 32 | attach?: string; 33 | attendees?: IcsAttendee[]; 34 | categories?: string[]; 35 | comment?: string; 36 | description?: string; 37 | geo?: string; 38 | exceptionDates?: IcsExceptionDates; 39 | nonStandard?: Partial; 40 | }; 41 | 42 | export type ParseJournalOptions< 43 | TNonStandardValues extends NonStandardValuesGeneric 44 | > = { 45 | timezones?: IcsTimezone[]; 46 | nonStandard?: ParseNonStandardValues; 47 | }; 48 | 49 | export type ConvertJournal< 50 | TNonStandardValues extends NonStandardValuesGeneric 51 | > = ConvertComponentType< 52 | IcsJournal, 53 | ParseJournalOptions 54 | >; 55 | 56 | export type ParseJournal = 57 | ParseComponentType< 58 | IcsJournal, 59 | ParseJournalOptions 60 | >; 61 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/timezone/getTimezone.ts: -------------------------------------------------------------------------------- 1 | import { 2 | compareAsc, 3 | millisecondsToHours, 4 | millisecondsToMinutes, 5 | } from "date-fns"; 6 | 7 | import type { DateObjectTzProps, IcsTimezone } from "@/types"; 8 | 9 | import { extendTimezoneProps } from "./extendProps"; 10 | import { timeZoneOffsetToMilliseconds } from "./offsetToMilliseconds"; 11 | import { getOffsetFromTimezoneId } from "./getOffsetFromTimezoneId"; 12 | 13 | export const getTimezoneObjectOffset = ( 14 | date: Date, 15 | tzid: string, 16 | timezones?: IcsTimezone[] 17 | ): 18 | | { offset: DateObjectTzProps["tzoffset"]; milliseconds: number } 19 | | undefined => { 20 | const vTimezone = timezones?.find((timezone) => timezone.id === tzid); 21 | 22 | if (vTimezone) { 23 | const sortedProps = extendTimezoneProps(date, vTimezone.props).sort( 24 | (a, b) => compareAsc(a.start, b.start) 25 | ); 26 | 27 | for (let i = 0; i < sortedProps.length; i += 1) { 28 | if (date < sortedProps[i].start) { 29 | const icsOffset = sortedProps[i - 1] 30 | ? sortedProps[i - 1].offsetTo 31 | : sortedProps[i].offsetFrom; 32 | const offset = 33 | icsOffset.length > 5 ? icsOffset.substring(0, 5) : icsOffset; 34 | 35 | return { offset, milliseconds: timeZoneOffsetToMilliseconds(offset) }; 36 | } 37 | } 38 | 39 | const icsOffset = sortedProps[sortedProps.length - 1].offsetTo; 40 | const offset = icsOffset.length > 5 ? icsOffset.substring(0, 5) : icsOffset; 41 | 42 | return { offset, milliseconds: timeZoneOffsetToMilliseconds(offset) }; 43 | } 44 | 45 | const ianaTimezone = getOffsetFromTimezoneId(tzid, date); 46 | 47 | if (!Number.isNaN(ianaTimezone)) { 48 | const isNegative = ianaTimezone < 0; 49 | const hours = Math.abs(millisecondsToHours(ianaTimezone)); 50 | const minutes = Math.abs(millisecondsToMinutes(ianaTimezone)) - hours * 60; 51 | 52 | const pHours = 53 | hours.toString().length === 1 ? `0${hours}` : hours.toString(); 54 | const pMinutes = 55 | minutes.toString().length === 1 ? `0${minutes}` : minutes.toString(); 56 | 57 | return { 58 | offset: `${isNegative ? "-" : "+"}${pHours}${pMinutes}`, 59 | milliseconds: ianaTimezone, 60 | }; 61 | } 62 | }; 63 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/parse/timezoneProp.test.ts: -------------------------------------------------------------------------------- 1 | import { convertIcsTimezoneProp } from "@/lib"; 2 | import { icsTestData } from "../utils"; 3 | import { z } from "zod"; 4 | 5 | it("Type is correctly set - DAYLIGHT", async () => { 6 | const timeZonePropString = icsTestData([ 7 | "BEGIN:DAYLIGHT", 8 | "DTSTART:20070311T020000", 9 | "TZOFFSETFROM:-0500", 10 | "TZOFFSETTO:-0400", 11 | "TZNAME:EDT", 12 | "END:DAYLIGHT", 13 | ]); 14 | 15 | const timeZoneProp = convertIcsTimezoneProp(undefined, timeZonePropString); 16 | 17 | expect(timeZoneProp.type).toBe("DAYLIGHT"); 18 | }); 19 | 20 | it("Type is correctly set - STANDARD", async () => { 21 | const timeZonePropString = icsTestData([ 22 | "BEGIN:STANDARD", 23 | "DTSTART:20070311T020000", 24 | "TZOFFSETFROM:-0500", 25 | "TZOFFSETTO:-0400", 26 | "TZNAME:EDT", 27 | "END:STANDARD", 28 | ]); 29 | 30 | const timeZoneProp = convertIcsTimezoneProp(undefined, timeZonePropString); 31 | 32 | expect(timeZoneProp.type).toBe("STANDARD"); 33 | }); 34 | 35 | it("Test non standard value", async () => { 36 | const nonStandardValue = "yeah"; 37 | 38 | const timeZonePropString = icsTestData([ 39 | "BEGIN:DAYLIGHT", 40 | "DTSTART:20070311T020000", 41 | "TZOFFSETFROM:-0500", 42 | "TZOFFSETTO:-0400", 43 | "TZNAME:EDT", 44 | `X-WTF:${nonStandardValue}`, 45 | "END:DAYLIGHT", 46 | ]); 47 | 48 | const timeZoneProp = convertIcsTimezoneProp(undefined, timeZonePropString, { 49 | nonStandard: { 50 | wtf: { 51 | name: "X-WTF", 52 | convert: (line) => line.value, 53 | schema: z.string(), 54 | }, 55 | }, 56 | }); 57 | 58 | expect(timeZoneProp.nonStandard?.wtf).toBe(nonStandardValue); 59 | }); 60 | 61 | it("Convert DTSTART - MUST be specified as a date with a local time value - gh#232", async () => { 62 | const date = new Date("2025-10-01T12:00:00-04:00"); 63 | 64 | const timeZonePropString = icsTestData([ 65 | "BEGIN:DAYLIGHT", 66 | "DTSTART:20251001T120000", 67 | "TZOFFSETFROM:-0500", 68 | "TZOFFSETTO:-0400", 69 | "TZNAME:EDT", 70 | "END:DAYLIGHT", 71 | ]); 72 | 73 | const timeZoneProp = convertIcsTimezoneProp(undefined, timeZonePropString); 74 | 75 | expect(timeZoneProp.start).toStrictEqual(date); 76 | }); 77 | -------------------------------------------------------------------------------- /packages/ts-ics/src/utils/duration/durationFromInterval.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | addDays, 3 | addHours, 4 | addMinutes, 5 | addMonths, 6 | addSeconds, 7 | addWeeks, 8 | addYears, 9 | } from "date-fns"; 10 | import { getDurationFromInterval } from "./durationFromInterval"; 11 | 12 | const weeks = 1; 13 | const days = 2; 14 | const hours = 3; 15 | const minutes = 4; 16 | const seconds = 5; 17 | 18 | it("Test getDurationFromInterval", async () => { 19 | const start = new Date("2023-09-01"); 20 | const end = addWeeks( 21 | addDays( 22 | addHours(addMinutes(addSeconds(start, seconds), minutes), hours), 23 | days 24 | ), 25 | weeks 26 | ); 27 | 28 | const duration = getDurationFromInterval(start, end); 29 | 30 | expect(duration.before).toBe(false); 31 | expect(duration.weeks).toBe(weeks); 32 | expect(duration.days).toBe(days); 33 | expect(duration.hours).toBe(hours); 34 | expect(duration.minutes).toBe(minutes); 35 | expect(duration.seconds).toBe(seconds); 36 | }); 37 | 38 | it("Test getDurationFromInterval", async () => { 39 | const start = new Date("2023-09-01"); 40 | const end = addWeeks( 41 | addDays( 42 | addHours(addMinutes(addSeconds(start, seconds), minutes), hours), 43 | days 44 | ), 45 | weeks 46 | ); 47 | 48 | const duration = getDurationFromInterval(end, start); 49 | 50 | expect(duration.before).toBe(true); 51 | expect(duration.weeks).toBe(weeks); 52 | expect(duration.days).toBe(days); 53 | expect(duration.hours).toBe(hours); 54 | expect(duration.minutes).toBe(minutes); 55 | expect(duration.seconds).toBe(seconds); 56 | }); 57 | 58 | const years = 1; 59 | const months = 1; 60 | 61 | it("Test getDurationFromInterval", async () => { 62 | const start = new Date("2023-09-01"); 63 | const end = addYears( 64 | addMonths( 65 | addWeeks( 66 | addDays( 67 | addHours(addMinutes(addSeconds(start, seconds), minutes), hours), 68 | days 69 | ), 70 | weeks 71 | ), 72 | months 73 | ), 74 | years 75 | ); 76 | 77 | const duration = getDurationFromInterval(start, end); 78 | 79 | expect(duration.before).toBe(false); 80 | expect(duration.weeks).toBe(57); 81 | expect(duration.days).toBe(6); 82 | expect(duration.hours).toBe(hours); 83 | expect(duration.minutes).toBe(minutes); 84 | expect(duration.seconds).toBe(seconds); 85 | }); 86 | -------------------------------------------------------------------------------- /packages/ts-ics/tests/parse/alarm.test.ts: -------------------------------------------------------------------------------- 1 | import { convertIcsAlarm } from "@/lib/parse/components/alarm"; 2 | import { icsTestData } from "../utils"; 3 | import { z } from "zod"; 4 | 5 | it("Test Ics Alarm Parse", async () => { 6 | const alarm = icsTestData([ 7 | "BEGIN:VALARM", 8 | "TRIGGER;VALUE=DATE-TIME:19970317T133000Z", 9 | "REPEAT:4", 10 | "DURATION:PT15M", 11 | "ACTION:AUDIO", 12 | "ATTACH;FMTTYPE=audio/basic:ftp://example.com/pub/sounds/bell-01.aud", 13 | "END:VALARM", 14 | ]); 15 | expect(() => convertIcsAlarm(undefined, alarm)).not.toThrow(); 16 | }); 17 | 18 | it("Test Ics Alarm Parse", async () => { 19 | const alarm = icsTestData([ 20 | "BEGIN:VALARM", 21 | "TRIGGER:-PT30M", 22 | "REPEAT:2", 23 | "DURATION:PT15M", 24 | "ACTION:DISPLAY", 25 | "DESCRIPTION:Breakfast meeting with executive\n", 26 | " team at 8:30 AM EST.", 27 | "END:VALARM", 28 | ]); 29 | expect(() => convertIcsAlarm(undefined, alarm)).not.toThrow(); 30 | }); 31 | 32 | it("Test Ics Alarm Parse", async () => { 33 | const alarm = icsTestData([ 34 | "BEGIN:VALARM", 35 | "TRIGGER;RELATED=END:-P2D", 36 | "ACTION:EMAIL", 37 | "ATTENDEE:mailto:john_doe@example.com", 38 | "SUMMARY:*** REMINDER: SEND AGENDA FOR WEEKLY STAFF MEETING ***", 39 | "DESCRIPTION:A draft agenda needs to be sent out to the attendees to the we", 40 | " ekly managers meeting (MGR-LIST). Attached is a pointer the document templ", 41 | " ate for the agenda file.", 42 | "ATTACH;FMTTYPE=application/msword:http://example.com/templates/agenda.doc", 43 | "END:VALARM", 44 | ]); 45 | expect(() => convertIcsAlarm(undefined, alarm)).not.toThrow(); 46 | }); 47 | 48 | it("Test non standard value", async () => { 49 | const nonStandardValue = "yeah"; 50 | 51 | const alarmString = icsTestData([ 52 | "BEGIN:VALARM", 53 | "TRIGGER;VALUE=DATE-TIME:19970317T133000Z", 54 | "REPEAT:4", 55 | "DURATION:PT15M", 56 | "ACTION:AUDIO", 57 | `X-WTF:${nonStandardValue}`, 58 | "ATTACH;FMTTYPE=audio/basic:ftp://example.com/pub/sounds/bell-01.aud", 59 | "END:VALARM", 60 | ]); 61 | 62 | const alarm = convertIcsAlarm(undefined, alarmString, { 63 | nonStandard: { 64 | wtf: { 65 | name: "X-WTF", 66 | convert: (line) => line.value, 67 | schema: z.string(), 68 | }, 69 | }, 70 | }); 71 | 72 | expect(alarm.nonStandard?.wtf).toBe(nonStandardValue); 73 | }); 74 | -------------------------------------------------------------------------------- /packages/schema-tests/tests/parse/timezone.test.ts: -------------------------------------------------------------------------------- 1 | import { schemaPackageNames } from "../../src/schemas"; 2 | import { icsTestData } from "ts-ics/tests/utils"; 3 | import { VTIMEZONE_OBJECT_KEYS, VTIMEZONE_PROP_OBJECT_KEYS } from "ts-ics"; 4 | 5 | describe("Tests if all values from IcsTimezone are parsed from schema package", () => { 6 | schemaPackageNames.forEach((name) => { 7 | const timezoneString = icsTestData([ 8 | "BEGIN:VTIMEZONE", 9 | "TZID:America/New_York", 10 | "LAST-MODIFIED:20050809T050000Z", 11 | "TZURL:https://test.de", 12 | "BEGIN:STANDARD", 13 | "DTSTART:20071104T020000", 14 | "TZOFFSETFROM:-0400", 15 | "TZOFFSETTO:-0500", 16 | "TZNAME:EST", 17 | "END:STANDARD", 18 | "BEGIN:DAYLIGHT", 19 | "DTSTART:20070311T020000", 20 | "TZOFFSETFROM:-0500", 21 | "TZOFFSETTO:-0400", 22 | "TZNAME:EDT", 23 | "END:DAYLIGHT", 24 | "END:VTIMEZONE", 25 | ]); 26 | 27 | it(name, async () => { 28 | const schemaPackage = await import(name); 29 | 30 | const timezone = schemaPackage.parseIcsTimezone(timezoneString); 31 | 32 | VTIMEZONE_OBJECT_KEYS.forEach((timezoneKey) => { 33 | if (!timezone[timezoneKey]) 34 | throw new Error( 35 | `The timezone does not contain the value "${timezoneKey}".` 36 | ); 37 | }); 38 | }); 39 | }); 40 | }); 41 | 42 | describe("Tests if all values from IcsTimezoneProp are parsed from schema package", () => { 43 | schemaPackageNames.forEach((name) => { 44 | const timezonePropString = icsTestData([ 45 | "BEGIN:DAYLIGHT", 46 | "DTSTART:20070311T020000", 47 | "TZOFFSETFROM:-0500", 48 | "TZOFFSETTO:-0400", 49 | "TZNAME:EDT", 50 | "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10", 51 | "COMMENT:Some comment.", 52 | "RDATE:18930401T000000", 53 | "END:DAYLIGHT", 54 | ]); 55 | 56 | it(name, async () => { 57 | const schemaPackage = await import(name); 58 | 59 | const timezoneProp = 60 | schemaPackage.parseIcsTimezoneProp(timezonePropString); 61 | 62 | VTIMEZONE_PROP_OBJECT_KEYS.forEach((timezonePropKey) => { 63 | if (!timezoneProp[timezonePropKey]) 64 | throw new Error( 65 | `The timezoneProp does not contain the value "${timezonePropKey}".` 66 | ); 67 | }); 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /packages/schema-zod/src/components/freebusy.ts: -------------------------------------------------------------------------------- 1 | import { 2 | convertIcsFreeBusy, 3 | type NonStandardValuesGeneric, 4 | type IcsFreeBusy, 5 | type ParseFreeBusy, 6 | freeBusyTypes, 7 | type IcsFreeBusyTimeValueDurationOrEnd, 8 | type IcsFreeBusyTimeValueBase, 9 | type IcsFreeBusyTimeValue, 10 | type IcsFreeBusyTime, 11 | } from "ts-ics"; 12 | import { z } from "zod"; 13 | import { zIcsDateObject } from "../values/date"; 14 | import { zIcsAttendee } from "../values/attendee"; 15 | import { zIcsClassType } from "../values/class"; 16 | import { zIcsOrganizer } from "../values/organizer"; 17 | import { zIcsDuration } from "../values/duration"; 18 | 19 | export const zIcsFreeBusyValueDurationOrEnd: z.ZodType< 20 | IcsFreeBusyTimeValueDurationOrEnd, 21 | IcsFreeBusyTimeValueDurationOrEnd 22 | > = z.union([ 23 | z.object({ duration: zIcsDuration, end: z.never().optional() }), 24 | z.object({ duration: z.never().optional(), end: z.date() }), 25 | ]); 26 | 27 | export const zIcsFreeBusyValueBase: z.ZodType< 28 | IcsFreeBusyTimeValueBase, 29 | IcsFreeBusyTimeValueBase 30 | > = z.object({ 31 | start: z.date(), 32 | }); 33 | 34 | export const zIcsFreeBusyValue: z.ZodType< 35 | IcsFreeBusyTimeValue, 36 | IcsFreeBusyTimeValue 37 | > = z.intersection(zIcsFreeBusyValueBase, zIcsFreeBusyValueDurationOrEnd); 38 | 39 | export const zIcsFreeBusyTime: z.ZodType = 40 | z.object({ 41 | type: z.enum(freeBusyTypes).optional(), 42 | values: z.array(zIcsFreeBusyValue), 43 | }); 44 | 45 | export const zIcsFreeBusy: z.ZodType< 46 | // biome-ignore lint/suspicious/noExplicitAny: 47 | IcsFreeBusy, 48 | // biome-ignore lint/suspicious/noExplicitAny: 49 | IcsFreeBusy 50 | > = z.object({ 51 | uid: z.string(), 52 | start: zIcsDateObject.optional(), 53 | end: zIcsDateObject.optional(), 54 | stamp: zIcsDateObject, 55 | url: z.url().optional(), 56 | class: zIcsClassType.optional(), 57 | organizer: zIcsOrganizer.optional(), 58 | attendees: z.array(zIcsAttendee).optional(), 59 | comment: z.string().optional(), 60 | freeBusy: z.array(zIcsFreeBusyTime).optional(), 61 | nonStandard: z.record(z.string(), z.any()).optional(), 62 | }); 63 | 64 | export const parseIcsFreeBusy = ( 65 | ...props: Parameters> 66 | ): ReturnType> => convertIcsFreeBusy(zIcsFreeBusy, ...props); 67 | --------------------------------------------------------------------------------