@wirecat/cli-core 0.538.0 → 0.539.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.539.0 — 01.11.2026
11
+
12
+ ### Added
13
+
14
+ - **`events create --text <line>`**: an event the provider reads from one line, such as 'Lunch with Ana tomorrow
15
+ 1pm', when the writer has the new optional `CalendarWriter.quickAdd`. Checked as `events.create` and
16
+ `extraKeys("quick-add")`; takes no `--operation-id`, since the provider picks the id, and mails nobody without
17
+ `--notify`.
18
+ - **`events export [query…] [--output <file>]`**: the events `events list`'s query finds, as iCalendar (RFC 5545).
19
+ `--output` exists when the host passes `deps.writeFile`, and is left out of MCP tools: an agent gets the text.
20
+ `writeIcs` and `readIcs` are in `./calendar`.
21
+ - **`events import <calendar> <file>`**: an iCalendar file's events into a calendar, each under its UID, when the
22
+ writer has the new optional `CalendarWriter.importEvent` and the host passes `deps.readFile`. Its key `events.import`
23
+ asks by default (`CALENDAR_WRITE_DEFAULTS`).
24
+ - **`events list` takes `id:<id>` and `event_type:<kind>`**, and `-event_type:<kind>` or `NOT event_type:<kind>` to
25
+ leave a kind out, such as birthdays and working-location blocks; filtered in the store
26
+ (`EventFilter.id`, `eventType`, `notEventTypes`). `nativeTermLists` takes the fields a list lets the query exclude.
27
+ - **A calendar by its title**: `calendar:`, `<calendar>`, `--calendar` and `--to-calendar` take a calendar's title,
28
+ case aside, after its id and the provider's id. A title two calendars share is refused (`calendar.ambiguous`) with
29
+ their ids.
30
+
31
+ ### Changed — may break callers
32
+
33
+ - `CalendarWrite` has `quick-add` and `import`: a `Record<CalendarWrite, …>`, such as a CLI's map of write to its API
34
+ keys, needs both.
35
+
10
36
  ## 0.538.0 — 01.11.2026
11
37
 
12
38
  ### Added
@@ -29,7 +29,7 @@ export const BUSY_QUERY = {
29
29
  calendar: {
30
30
  type: "kw",
31
31
  example: "calendar:3",
32
- relation: "one of the account's calendars, by its id or the provider's id; repeat for more",
32
+ relation: "one of the account's calendars, by its id, the provider's id or its title; repeat for more",
33
33
  },
34
34
  person: { type: "kw", example: "person:ana@example.com", relation: "someone else, by address; repeat for more" },
35
35
  },
@@ -236,6 +236,42 @@ export declare const calendarEventWrite: v.StrictObjectSchema<{
236
236
  readonly hint: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
237
237
  readonly untrusted: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
238
238
  }, undefined>;
239
+ export declare const calendarExport: v.StrictObjectSchema<{
240
+ readonly format: v.LiteralSchema<"ics", undefined>;
241
+ readonly events: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
242
+ /** The iCalendar text, when no `--output` took it. */
243
+ readonly content: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
244
+ readonly output: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
245
+ readonly untrusted: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
246
+ }, undefined>;
247
+ export declare const calendarImport: v.StrictObjectSchema<{
248
+ readonly operation_id: v.StringSchema<undefined>;
249
+ readonly calendar: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
250
+ readonly imported: v.ArraySchema<v.StrictObjectSchema<{
251
+ readonly uid: v.StringSchema<undefined>;
252
+ readonly event: v.NullableSchema<v.StringSchema<undefined>, undefined>;
253
+ readonly entry: v.NullableSchema<v.StrictObjectSchema<{
254
+ readonly id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
255
+ readonly calendar_id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
256
+ readonly external_id: v.StringSchema<undefined>;
257
+ readonly kind: v.StringSchema<undefined>;
258
+ readonly title: v.NullableSchema<v.StringSchema<undefined>, undefined>;
259
+ readonly status: v.StringSchema<undefined>;
260
+ readonly event_type: v.NullableSchema<v.StringSchema<undefined>, undefined>;
261
+ readonly own_response: v.NullableSchema<v.StringSchema<undefined>, undefined>;
262
+ readonly starts_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
263
+ readonly ends_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
264
+ readonly join_url: v.NullableSchema<v.StringSchema<undefined>, undefined>;
265
+ readonly url: v.NullableSchema<v.StringSchema<undefined>, undefined>;
266
+ readonly deleted_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
267
+ }, undefined>, undefined>;
268
+ }, undefined>, undefined>;
269
+ readonly skipped: v.ArraySchema<v.StrictObjectSchema<{
270
+ readonly uid: v.NullableSchema<v.StringSchema<undefined>, undefined>;
271
+ readonly reason: v.StringSchema<undefined>;
272
+ }, undefined>, undefined>;
273
+ readonly untrusted: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
274
+ }, undefined>;
239
275
  export declare const calendarAttendee: v.StrictObjectSchema<{
240
276
  readonly email: v.StringSchema<undefined>;
241
277
  readonly name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
@@ -175,6 +175,21 @@ export const calendarEventWrite = v.strictObject({
175
175
  hint: v.optional(text),
176
176
  untrusted,
177
177
  });
178
+ export const calendarExport = v.strictObject({
179
+ format: v.literal("ics"),
180
+ events: count,
181
+ /** The iCalendar text, when no `--output` took it. */
182
+ content: v.optional(text),
183
+ output: v.optional(text),
184
+ untrusted: v.optional(v.array(text)),
185
+ });
186
+ export const calendarImport = v.strictObject({
187
+ operation_id: text,
188
+ calendar: id,
189
+ imported: v.array(v.strictObject({ uid: text, event: nullableText, entry: v.nullable(entry) })),
190
+ skipped: v.array(v.strictObject({ uid: nullableText, reason: text })),
191
+ untrusted,
192
+ });
178
193
  export const calendarAttendee = v.strictObject({
179
194
  ...attendee,
180
195
  /** How many of the listed events the person attends; only on the week's list. */
@@ -225,6 +240,8 @@ const ANSWERS = {
225
240
  "events list": { name: "event" },
226
241
  "events show": { name: "calendar_event", schema: calendarEvent },
227
242
  "events prepare": { name: "calendar_event_preparation", schema: calendarEventPreparation },
243
+ "events export": { name: "calendar_export", schema: calendarExport },
244
+ "events import": { name: "calendar_import", schema: calendarImport },
228
245
  "events create": { name: "calendar_event_write", schema: calendarEventWrite },
229
246
  "events update": { name: "calendar_event_write", schema: calendarEventWrite },
230
247
  "events delete": { name: "calendar_event_write", schema: calendarEventWrite },
@@ -39,6 +39,10 @@ export interface CalendarCommandDeps {
39
39
  timezone?: string;
40
40
  /** The provider's writer and the profile's guard: `events create|update|delete|respond` exist only with it. */
41
41
  writes?: CalendarWrites;
42
+ /** Writes `events export --output`'s file; without it the export is printed only. */
43
+ writeFile?: (path: string, content: string) => Promise<void>;
44
+ /** Reads `events import`'s file; without it, and the writer's `import`, there is no `events import`. */
45
+ readFile?: (path: string) => Promise<string>;
42
46
  /** A profile lock pins the process (WC-ACCT-5): `events list --source` may then read only the profile's account. */
43
47
  locked?: boolean;
44
48
  }
@@ -4,6 +4,7 @@ import { timezoneOf } from "@wirecat/cli-base/query";
4
4
  import { Argument, InvalidArgumentError, Option } from "commander";
5
5
  import { environmentOf, runBudgetOf } from "../../cli/context.js";
6
6
  import { honoursDryRun } from "../../cli/dry-run-guard.js";
7
+ import { ownerOnlyOption } from "../../cli/effects.js";
7
8
  import { queryTerms, withQuery } from "../../cli/list-query.js";
8
9
  import { terminalAsker } from "../../cli/messenger/ask.js";
9
10
  import { lineOf } from "../../cli/steps.js";
@@ -12,11 +13,11 @@ import { dateWindow } from "../../services/moment.js";
12
13
  import { linkPeople } from "../../services/people.js";
13
14
  import { BUSY_QUERY, BUSY_QUERY_NOTE, busyAnswer, busyQuery, workingHoursOf } from "../busy.js";
14
15
  import { eventPreparation, nextMeeting, PREPARE_MESSAGES } from "../prepare.js";
15
- import { accountsMeta, CALENDAR_UNTRUSTED_FIELDS, calendarIn, calendarItem, EVENTS_QUERY, EVENTS_QUERY_NOTE, eventAnswer, eventIdOf, eventOf, eventsAnswer, eventsFilter, peopleAnswer, resolveAnswer, reviewsAnswer, sourceAccounts, weekFilter, } from "../reads.js";
16
+ import { accountsMeta, CALENDAR_UNTRUSTED_FIELDS, calendarIn, calendarItem, EVENTS_QUERY, EVENTS_QUERY_NOTE, EXPORT_MAX, eventAnswer, eventIdOf, eventOf, eventsAnswer, eventsFilter, eventsIcs, peopleAnswer, resolveAnswer, reviewsAnswer, sourceAccounts, weekFilter, } from "../reads.js";
16
17
  import { REPEAT_HELP } from "../recurrence.js";
17
18
  import { bothSearchAnswer, serverSearchAnswer } from "../server-search.js";
18
19
  import { calendarStatus, syncCalendars } from "../sync.js";
19
- import { createEvent, deleteEvent, EVENT_RESPONSES, moveEvent, respondToEvent, updateEvent, } from "../writes.js";
20
+ import { createEvent, createEventFromText, deleteEvent, EVENT_RESPONSES, IMPORT_MAX, importEvents, moveEvent, respondToEvent, updateEvent, } from "../writes.js";
20
21
  import { CALENDARS_QUERY, declareCalendarAnswers, PEOPLE_QUERY } from "./answers.js";
21
22
  const LOCAL_READ = { remote: "none", local: "read" };
22
23
  const LOCAL_WRITE = { remote: "none", local: "write" };
@@ -33,6 +34,9 @@ const ATTENDEE_UNTRUSTED = ["name"];
33
34
  const REVIEW_UNTRUSTED = ["candidates.title"];
34
35
  const SEARCH_UNTRUSTED = ["hits.title", "hits.location", "hits.excerpt"];
35
36
  const SERVER_SEARCH_UNTRUSTED = ["hits.title", "hits.location"];
37
+ const IMPORT_UNTRUSTED = ["imported.uid", "imported.entry.title", "skipped.uid", "skipped.reason"];
38
+ /** The largest .ics file `events import` reads. */
39
+ const ICS_MAX_BYTES = 10 * 1024 * 1024;
36
40
  const eachMarked = (answer, untrusted) => ({
37
41
  ...answer,
38
42
  items: answer.items.map((item) => ({ ...item, untrusted })),
@@ -101,7 +105,7 @@ const addCalendarCommandsDefinition = (program, deps) => {
101
105
  ])
102
106
  example(annotate(calendars.command(name), { effects: LOCAL_WRITE })
103
107
  .description(synced ? "sync this calendar from now on" : "stop syncing this calendar; what is stored stays")
104
- .argument("<calendar>", "the calendar's id, or the provider's id"), `calendars ${name} 3`).action(async (reference, _opts, command) => {
108
+ .argument("<calendar>", "the calendar's id, the provider's id, or its title"), `calendars ${name} 3`).action(async (reference, _opts, command) => {
105
109
  const calendar = await calendarIn(deps.store, deps.accountId, reference);
106
110
  await deps.store.setCalendarSynced(calendar.id, synced, now());
107
111
  print(calendarItem({ ...calendar, synced }), command);
@@ -234,6 +238,34 @@ const addCalendarCommandsDefinition = (program, deps) => {
234
238
  meta: await accountsMeta(deps.store, accounts, skipped, now()),
235
239
  }, command);
236
240
  });
241
+ const exporter = example(withQuery(annotate(events.command("export"), { effects: deps.writeFile ? LOCAL_WRITE : LOCAL_READ }).description(`write stored events as iCalendar (.ics), at most ${EXPORT_MAX}: one with id:<id>, or those the query ` +
242
+ "finds; each occurrence of a repeating event as an event of its own"), EVENTS_QUERY).addHelpText("after", EVENTS_QUERY_NOTE), ...(deps.writeFile
243
+ ? [
244
+ "events export id:42 --output planning.ics",
245
+ "events export starts_at:[2026-11-02 TO 2026-11-09} NOT event_type:birthday --output week.ics",
246
+ ]
247
+ : ["events export id:42", "events export starts_at:[2026-11-02 TO 2026-11-09} NOT event_type:birthday"]));
248
+ // Not over MCP: an agent would choose where on the owner's disk to write. It gets the text in the answer.
249
+ if (deps.writeFile)
250
+ exporter.addOption(ownerOnlyOption("--output <file>", "the file to write; printed in the answer when absent"));
251
+ exporter.action(async (words, opts, command) => {
252
+ const filter = await eventsFilter(words, {
253
+ store: deps.store,
254
+ accountId: deps.accountId,
255
+ zone: zone(),
256
+ now: now(),
257
+ personOf: personId,
258
+ });
259
+ const exported = await eventsIcs(deps.store, deps.accountId, filter, { now: now(), product: program.name() });
260
+ if (opts.output === undefined) {
261
+ print({ ...exported, untrusted: ["content"] }, command);
262
+ return;
263
+ }
264
+ if (!opts.output.trim())
265
+ throw new CliError("validation_error", "--output names no file");
266
+ await deps.writeFile?.(opts.output, exported.content);
267
+ print({ format: exported.format, events: exported.events, output: opts.output }, command);
268
+ });
237
269
  example(annotate(events.command("show"), { effects: LOCAL_READ })
238
270
  .description("one event: its calendar copies, attendees with their person, Drive files and history")
239
271
  .argument("<event>", "the event's id, or event:<id>"), "events show event:42 --json").action(async (reference, _opts, command) => {
@@ -404,6 +436,7 @@ const addEventWrites = (events, writes, { deps, now, zone, print, example }) =>
404
436
  // The empty default marks the flag as repeatable, so an agent's tool takes a list; empty is "not given".
405
437
  .option("--attendee <email>", `an attendee; repeat for each${replacing ? ", replacing the list" : ""}`, collect, [])
406
438
  .option("--repeat <rule>", `how it repeats${replacing ? ", with --series" : ""}: ${REPEAT_HELP}`);
439
+ const flagOf = (field) => field === "attendees" ? "--attendee" : `--${field.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`)}`;
407
440
  const fieldsOf = ({ attendee, ...given }) => ({
408
441
  ...Object.fromEntries(Object.entries(given).filter(([, value]) => value !== undefined)),
409
442
  ...(attendee === undefined || attendee.length === 0 ? {} : { attendees: attendee }),
@@ -417,13 +450,30 @@ const addEventWrites = (events, writes, { deps, now, zone, print, example }) =>
417
450
  const write = (name) => annotate(events.command(name), { effects: REMOTE_WRITE, offline: false });
418
451
  const target = (command) => command
419
452
  .argument("<event>", "the event's id, or event:<id>")
420
- .option("--calendar <id>", "the calendar, when the event is on more than one of the account's");
421
- example(notify(fields(write("create"), false)
422
- .description("add an event to a calendar; with --repeat, one that repeats")
423
- .argument("<calendar>", "the calendar's id, or the provider's id")).option("--operation-id <id>", "the operation_id a create that ended in outcome_unknown named: a retry finds that event"), "events create 3 --title 'Example planning' --start-time 2026-11-02T10:00 --end-time 2026-11-02T11:00", "events create 3 --title 'Example review' --start-date 2026-11-03 --end-date 2026-11-03 " +
453
+ .option("--calendar <id>", "the calendar, by id or title, when the event is on more than one of the account's");
454
+ const quick = writes.writer.quickAdd !== undefined;
455
+ const create = example(notify(fields(write("create"), false)
456
+ .description(quick
457
+ ? "add an event to a calendar; with --repeat, one that repeats; with --text, one the provider reads " +
458
+ "from a line such as 'Lunch with Ana tomorrow 1pm'"
459
+ : "add an event to a calendar; with --repeat, one that repeats")
460
+ .argument("<calendar>", "the calendar's id, the provider's id, or its title")).option("--operation-id <id>", "the operation_id a create that ended in outcome_unknown named: a retry finds that event"), quick
461
+ ? "events create Work --text 'Example lunch with Ana tomorrow 1pm at Example Cafe'"
462
+ : "events create 3 --title 'Example planning' --start-time 2026-11-02T10:00 --end-time 2026-11-02T11:00", "events create 3 --title 'Example review' --start-date 2026-11-03 --end-date 2026-11-03 " +
424
463
  "--attendee ana@example.com --no-notify", "events create 3 --title 'Example standup' --start-time 2026-11-02T09:30 --end-time 2026-11-02T09:45 " +
425
- "--repeat 'FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=12'").action(async (calendar, opts, command) => {
426
- const { notify: choice, operationId: id, ...rest } = opts;
464
+ "--repeat 'FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=12'");
465
+ if (quick)
466
+ create.option("--text <text>", "one line the provider reads the title, date, time and place from, in place of the other fields; nobody is " +
467
+ "mailed without --notify, and a retry after outcome_unknown may add the event twice");
468
+ create.action(async (calendar, opts, command) => {
469
+ const { notify: choice, operationId: id, text, ...rest } = opts;
470
+ if (text !== undefined) {
471
+ const given = Object.keys(fieldsOf(rest));
472
+ if (given.length || id !== undefined)
473
+ throw new CliError("validation_error", `--text takes the whole event from its line; leave out ${[...given.map(flagOf), ...(id === undefined ? [] : ["--operation-id"])].join(", ")}`, { reason: "event.text-with-fields", param: "--text" });
474
+ printWritten(await createEventFromText(context(command), calendar, text, choice === undefined ? {} : { notify: choice }), command);
475
+ return;
476
+ }
427
477
  printWritten(await createEvent(context(command), calendar, fieldsOf(rest), {
428
478
  ...(choice === undefined ? {} : { notify: choice }),
429
479
  ...(id === undefined ? {} : { id }),
@@ -449,10 +499,24 @@ const addEventWrites = (events, writes, { deps, now, zone, print, example }) =>
449
499
  .option("--following", "delete this occurrence and every later one: the repeating event ends before it")), "events delete event:42 --no-notify", "events delete event:42 --following --no-notify").action(async (reference, opts, command) => {
450
500
  printWritten(await deleteEvent(context(command), reference, choicesOf(opts)), command);
451
501
  });
502
+ const readFile = deps.readFile;
503
+ if (writes.writer.importEvent && readFile)
504
+ example(write("import")
505
+ .description(`add the events of an iCalendar (.ics) file to a calendar, at most ${IMPORT_MAX}, each under its own UID; ` +
506
+ "nobody is mailed; a changed occurrence of a repeating event (RECURRENCE-ID) is skipped")
507
+ .argument("<calendar>", "the calendar's id, the provider's id, or its title")
508
+ .argument("<file>", "the .ics file to read"), "events import Work planning.ics").action(async (calendar, file, _opts, command) => {
509
+ const text = await readFile(file);
510
+ if (Buffer.byteLength(text, "utf8") > ICS_MAX_BYTES)
511
+ throw new CliError("validation_error", `${file} is larger than ${ICS_MAX_BYTES / 1024 / 1024} MB`, {
512
+ reason: "ics.too-large",
513
+ });
514
+ print({ ...(await importEvents(context(command), calendar, text)), untrusted: IMPORT_UNTRUSTED }, command);
515
+ });
452
516
  if (writes.writer.move)
453
517
  example(notify(target(write("move"))
454
518
  .description("move an event, with its id, to another calendar of the account; a repeating one moves whole")
455
- .requiredOption("--to-calendar <id>", "the calendar to move it to: its id, or the provider's id")
519
+ .requiredOption("--to-calendar <id>", "the calendar to move it to: its id, the provider's id, or its title")
456
520
  .option("--series", "move the whole repeating event an occurrence belongs to")), "events move event:42 --to-calendar 4 --no-notify").action(async (reference, opts, command) => {
457
521
  const { series, calendar, notify } = opts;
458
522
  printWritten(await moveEvent(context(command), reference, opts.toCalendar, choicesOf({ series, calendar, notify })), command);
@@ -0,0 +1,55 @@
1
+ import type { EntryStatus, EntryTransparency, ParticipantResponse } from "../meetings/index.js";
2
+ /**
3
+ * The part of iCalendar (RFC 5545, https://www.rfc-editor.org/rfc/rfc5545) the calendar commands read and write: one
4
+ * VCALENDAR of VEVENTs, with no VTIMEZONE. Times are written in UTC; a time read with a TZID keeps that zone's name,
5
+ * which the provider takes as it is.
6
+ */
7
+ export interface IcsEvent {
8
+ uid: string;
9
+ title: string | null;
10
+ description?: string | null;
11
+ location?: string | null;
12
+ /** A timed event, epoch ms, end exclusive. */
13
+ startsAt?: number;
14
+ endsAt?: number;
15
+ /** An all-day event, `YYYY-MM-DD`, end exclusive. */
16
+ startDate?: string;
17
+ endDate?: string;
18
+ /** The zone a read time named (`TZID`), or the reader's zone for a floating one. */
19
+ timezone?: string | null;
20
+ /** RRULE, RDATE and EXDATE lines, as written. */
21
+ recurrence?: string[];
22
+ status?: EntryStatus | null;
23
+ transparency?: EntryTransparency | null;
24
+ organizer?: {
25
+ email: string;
26
+ name?: string | null;
27
+ } | null;
28
+ attendees?: {
29
+ email: string;
30
+ name?: string | null;
31
+ response?: ParticipantResponse | null;
32
+ optional?: boolean;
33
+ }[];
34
+ url?: string | null;
35
+ }
36
+ /** Lines longer than 75 octets go on under a leading space, never splitting a character's UTF-8 bytes. */
37
+ export declare const foldLine: (line: string) => string;
38
+ /** One VCALENDAR holding the events, CRLF line ends and folded lines, as RFC 5545 §3.1 asks. */
39
+ export declare const writeIcs: (events: readonly IcsEvent[], options: {
40
+ now: number;
41
+ product: string;
42
+ }) => string;
43
+ /** An event the reader could not take, by its UID when it had one. */
44
+ export interface IcsSkip {
45
+ uid: string | null;
46
+ reason: string;
47
+ }
48
+ /**
49
+ * The VEVENTs of an iCalendar text; times with neither `Z` nor a TZID are read in `zone`. An event that cannot be
50
+ * imported alone is in `skipped` with the reason; a text with no VCALENDAR is refused.
51
+ */
52
+ export declare const readIcs: (text: string, zone: string) => {
53
+ events: IcsEvent[];
54
+ skipped: IcsSkip[];
55
+ };
@@ -0,0 +1,314 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { zonedMoment } from "@wirecat/cli-base/query";
3
+ const CRLF = "\r\n";
4
+ const LINE_OCTETS = 75;
5
+ const escapeText = (text) => text
6
+ .replace(/\\/gu, "\\\\")
7
+ .replace(/;/gu, "\\;")
8
+ .replace(/,/gu, "\\,")
9
+ .replace(/\r\n|\r|\n/gu, "\\n");
10
+ /** A parameter value: quoted when it holds `:`, `;` or `,`; a double quote cannot be written in one, so it goes. */
11
+ const paramValue = (text) => {
12
+ const clean = text.replace(/["\r\n]/gu, "");
13
+ return /[:;,]/u.test(clean) ? `"${clean}"` : clean;
14
+ };
15
+ /** Lines longer than 75 octets go on under a leading space, never splitting a character's UTF-8 bytes. */
16
+ export const foldLine = (line) => {
17
+ const parts = [];
18
+ let current = "";
19
+ let octets = 0;
20
+ for (const char of line) {
21
+ const size = Buffer.byteLength(char, "utf8");
22
+ const room = parts.length === 0 ? LINE_OCTETS : LINE_OCTETS - 1;
23
+ if (octets + size > room) {
24
+ parts.push(current);
25
+ current = "";
26
+ octets = 0;
27
+ }
28
+ current += char;
29
+ octets += size;
30
+ }
31
+ parts.push(current);
32
+ return parts.join(`${CRLF} `);
33
+ };
34
+ const pad = (value) => String(value).padStart(2, "0");
35
+ const utcStamp = (at) => {
36
+ const date = new Date(at);
37
+ return (`${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}T` +
38
+ `${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`);
39
+ };
40
+ const dateValue = (date) => date.replaceAll("-", "");
41
+ const PARTSTAT = {
42
+ "needs-action": "NEEDS-ACTION",
43
+ accepted: "ACCEPTED",
44
+ tentative: "TENTATIVE",
45
+ declined: "DECLINED",
46
+ delegated: "DELEGATED",
47
+ };
48
+ const eventLines = (event, stamp) => {
49
+ const lines = ["BEGIN:VEVENT", `UID:${escapeText(event.uid)}`, `DTSTAMP:${stamp}`];
50
+ if (event.startDate !== undefined && event.endDate !== undefined)
51
+ lines.push(`DTSTART;VALUE=DATE:${dateValue(event.startDate)}`, `DTEND;VALUE=DATE:${dateValue(event.endDate)}`);
52
+ else if (event.startsAt !== undefined)
53
+ lines.push(`DTSTART:${utcStamp(event.startsAt)}`, `DTEND:${utcStamp(event.endsAt ?? event.startsAt)}`);
54
+ if (event.title)
55
+ lines.push(`SUMMARY:${escapeText(event.title)}`);
56
+ if (event.description)
57
+ lines.push(`DESCRIPTION:${escapeText(event.description)}`);
58
+ if (event.location)
59
+ lines.push(`LOCATION:${escapeText(event.location)}`);
60
+ for (const rule of event.recurrence ?? [])
61
+ lines.push(rule);
62
+ if (event.status)
63
+ lines.push(`STATUS:${event.status.toUpperCase()}`);
64
+ if (event.transparency)
65
+ lines.push(`TRANSP:${event.transparency.toUpperCase()}`);
66
+ if (event.organizer)
67
+ lines.push(`ORGANIZER${event.organizer.name ? `;CN=${paramValue(event.organizer.name)}` : ""}:mailto:${event.organizer.email}`);
68
+ for (const one of event.attendees ?? []) {
69
+ const params = [
70
+ ...(one.name ? [`CN=${paramValue(one.name)}`] : []),
71
+ ...(one.response ? [`PARTSTAT=${PARTSTAT[one.response]}`] : []),
72
+ ...(one.optional ? ["ROLE=OPT-PARTICIPANT"] : []),
73
+ ];
74
+ lines.push(`ATTENDEE${params.map((param) => `;${param}`).join("")}:mailto:${one.email}`);
75
+ }
76
+ if (event.url)
77
+ lines.push(`URL:${event.url}`);
78
+ lines.push("END:VEVENT");
79
+ return lines;
80
+ };
81
+ /** One VCALENDAR holding the events, CRLF line ends and folded lines, as RFC 5545 §3.1 asks. */
82
+ export const writeIcs = (events, options) => [
83
+ "BEGIN:VCALENDAR",
84
+ "VERSION:2.0",
85
+ `PRODID:-//WireCat//${options.product.replace(/[^\w .-]/gu, "")}//EN`,
86
+ "CALSCALE:GREGORIAN",
87
+ ...events.flatMap((event) => eventLines(event, utcStamp(options.now))),
88
+ "END:VCALENDAR",
89
+ ]
90
+ .map(foldLine)
91
+ .join(CRLF)
92
+ .concat(CRLF);
93
+ const contentLine = (line) => {
94
+ let at = 0;
95
+ let quoted = false;
96
+ for (; at < line.length; at++) {
97
+ const char = line[at];
98
+ if (char === '"')
99
+ quoted = !quoted;
100
+ else if (char === ":" && !quoted)
101
+ break;
102
+ }
103
+ if (at === line.length)
104
+ return null;
105
+ const head = line.slice(0, at);
106
+ const parts = [];
107
+ let part = "";
108
+ quoted = false;
109
+ for (const char of head) {
110
+ if (char === '"')
111
+ quoted = !quoted;
112
+ if (char === ";" && !quoted) {
113
+ parts.push(part);
114
+ part = "";
115
+ }
116
+ else
117
+ part += char;
118
+ }
119
+ parts.push(part);
120
+ const [name = "", ...rest] = parts;
121
+ const params = {};
122
+ for (const param of rest) {
123
+ const equals = param.indexOf("=");
124
+ if (equals > 0)
125
+ params[param.slice(0, equals).toUpperCase()] = param.slice(equals + 1).replace(/^"|"$/gu, "");
126
+ }
127
+ return { name: name.toUpperCase(), params, value: line.slice(at + 1) };
128
+ };
129
+ const unescapeText = (text) => text.replace(/\\([\\;,nN])/gu, (_match, char) => (char === "n" || char === "N" ? "\n" : char));
130
+ const DATE = /^(\d{4})(\d{2})(\d{2})$/u;
131
+ const DATE_TIME = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/u;
132
+ /** A DATE or DATE-TIME: UTC with `Z`, in its TZID, or floating, read in `zone`. */
133
+ const momentOf = (line, zone) => {
134
+ const value = line.value.trim();
135
+ const date = DATE.exec(value);
136
+ if (date || line.params.VALUE === "DATE") {
137
+ if (!date)
138
+ throw new Error(`${line.name} ${value} is not a date`);
139
+ return { date: `${date[1]}-${date[2]}-${date[3]}` };
140
+ }
141
+ const time = DATE_TIME.exec(value);
142
+ if (!time)
143
+ throw new Error(`${line.name} ${value} is not a date or a time`);
144
+ const [, year, month, day, hour, minute, second, utc] = time;
145
+ const local = `${year}-${month}-${day}T${hour}:${minute}:${second}`;
146
+ if (utc)
147
+ return { at: Date.parse(`${local}Z`), zone: "UTC" };
148
+ const named = line.params.TZID;
149
+ const where = named ?? zone;
150
+ try {
151
+ return { at: zonedMoment(local, where, { start: 0, end: local.length }), zone: where };
152
+ }
153
+ catch {
154
+ throw new Error(`${line.name} names the time zone ${where}, which is not an IANA zone such as Europe/Madrid`);
155
+ }
156
+ };
157
+ const DURATION = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/u;
158
+ const durationMs = (text) => {
159
+ const found = DURATION.exec(text.trim());
160
+ if (!found || found[1] === "-")
161
+ throw new Error(`DURATION ${text} is not a length of time`);
162
+ const [, , weeks, days, hours, minutes, seconds] = found.map((one) => Number(one ?? 0));
163
+ return ((((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60_000 +
164
+ seconds * 1000);
165
+ };
166
+ const plusDays = (date, days) => {
167
+ const [year, month, day] = date.split("-").map(Number);
168
+ return new Date(Date.UTC(year, month - 1, day + days)).toISOString().slice(0, 10);
169
+ };
170
+ const STATUS = { CONFIRMED: "confirmed", TENTATIVE: "tentative", CANCELLED: "cancelled" };
171
+ const RESPONSE = {
172
+ "NEEDS-ACTION": "needs-action",
173
+ ACCEPTED: "accepted",
174
+ TENTATIVE: "tentative",
175
+ DECLINED: "declined",
176
+ DELEGATED: "delegated",
177
+ };
178
+ const emailOf = (value) => {
179
+ const email = value
180
+ .trim()
181
+ .replace(/^mailto:/iu, "")
182
+ .toLowerCase();
183
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email) ? email : null;
184
+ };
185
+ const eventOf = (lines, zone) => {
186
+ const one = (name) => lines.find((line) => line.name === name);
187
+ const uid = one("UID")?.value.trim();
188
+ if (!uid)
189
+ throw new Error("the event has no UID");
190
+ if (one("RECURRENCE-ID"))
191
+ throw new Error("a changed occurrence of a repeating event (RECURRENCE-ID) is not imported on its own");
192
+ const startLine = one("DTSTART");
193
+ if (!startLine)
194
+ throw new Error("the event has no DTSTART");
195
+ const start = momentOf(startLine, zone);
196
+ const endLine = one("DTEND");
197
+ const duration = one("DURATION");
198
+ const text = (name) => {
199
+ const found = one(name);
200
+ return found ? unescapeText(found.value) : null;
201
+ };
202
+ const event = { uid, title: text("SUMMARY") };
203
+ if ("date" in start) {
204
+ const end = endLine ? momentOf(endLine, zone) : undefined;
205
+ if (end && !("date" in end))
206
+ throw new Error("DTSTART is a date and DTEND a time");
207
+ event.startDate = start.date;
208
+ event.endDate =
209
+ end?.date ?? plusDays(start.date, duration ? Math.max(1, Math.ceil(durationMs(duration.value) / 86_400_000)) : 1);
210
+ if (event.endDate <= event.startDate)
211
+ throw new Error("the event ends before it starts");
212
+ }
213
+ else {
214
+ const end = endLine ? momentOf(endLine, zone) : undefined;
215
+ if (end && "date" in end)
216
+ throw new Error("DTSTART is a time and DTEND a date");
217
+ event.startsAt = start.at;
218
+ event.endsAt = end?.at ?? start.at + (duration ? durationMs(duration.value) : 0);
219
+ event.timezone = start.zone;
220
+ if (event.endsAt < event.startsAt)
221
+ throw new Error("the event ends before it starts");
222
+ }
223
+ const description = text("DESCRIPTION");
224
+ if (description)
225
+ event.description = description;
226
+ const location = text("LOCATION");
227
+ if (location)
228
+ event.location = location;
229
+ const recurrence = lines
230
+ .filter(({ name }) => ["RRULE", "RDATE", "EXDATE"].includes(name))
231
+ .map(({ name, params, value }) => {
232
+ const kept = Object.entries(params).map(([key, param]) => `;${key}=${paramValue(param)}`);
233
+ return `${name}${kept.join("")}:${value.trim()}`;
234
+ });
235
+ if (recurrence.length)
236
+ event.recurrence = recurrence;
237
+ const status = STATUS[one("STATUS")?.value.trim().toUpperCase() ?? ""];
238
+ if (status)
239
+ event.status = status;
240
+ const transparency = one("TRANSP")?.value.trim().toUpperCase();
241
+ if (transparency === "TRANSPARENT" || transparency === "OPAQUE")
242
+ event.transparency = transparency === "TRANSPARENT" ? "transparent" : "opaque";
243
+ const organizerLine = one("ORGANIZER");
244
+ const organizer = organizerLine && emailOf(organizerLine.value);
245
+ if (organizerLine && organizer)
246
+ event.organizer = { email: organizer, name: organizerLine.params.CN ?? null };
247
+ const attendees = lines
248
+ .filter(({ name }) => name === "ATTENDEE")
249
+ .flatMap(({ params, value }) => {
250
+ const email = emailOf(value);
251
+ if (!email)
252
+ return [];
253
+ return [
254
+ {
255
+ email,
256
+ name: params.CN ?? null,
257
+ response: RESPONSE[(params.PARTSTAT ?? "").toUpperCase()] ?? null,
258
+ optional: (params.ROLE ?? "").toUpperCase() === "OPT-PARTICIPANT",
259
+ },
260
+ ];
261
+ });
262
+ if (attendees.length)
263
+ event.attendees = attendees;
264
+ const url = one("URL")?.value.trim();
265
+ if (url)
266
+ event.url = url;
267
+ return event;
268
+ };
269
+ /**
270
+ * The VEVENTs of an iCalendar text; times with neither `Z` nor a TZID are read in `zone`. An event that cannot be
271
+ * imported alone is in `skipped` with the reason; a text with no VCALENDAR is refused.
272
+ */
273
+ export const readIcs = (text, zone) => {
274
+ const unfolded = text.replace(/^\uFEFF/u, "").replace(/\r?\n[ \t]/gu, "");
275
+ const lines = unfolded.split(/\r?\n/u).filter((line) => line.trim() !== "");
276
+ if (!/^BEGIN:VCALENDAR$/iu.test(lines[0]?.trim() ?? ""))
277
+ throw new CliError("validation_error", "the file is not iCalendar: it does not start with BEGIN:VCALENDAR", {
278
+ reason: "ics.not-icalendar",
279
+ });
280
+ const events = [];
281
+ const skipped = [];
282
+ let held = null;
283
+ // Components inside a VEVENT, such as VALARM, are passed over whole.
284
+ let inner = 0;
285
+ for (const raw of lines) {
286
+ const line = contentLine(raw);
287
+ if (!line)
288
+ continue;
289
+ const value = line.value.trim().toUpperCase();
290
+ if (line.name === "BEGIN" && value === "VEVENT" && held === null) {
291
+ held = [];
292
+ continue;
293
+ }
294
+ if (held === null)
295
+ continue;
296
+ if (line.name === "BEGIN")
297
+ inner++;
298
+ else if (line.name === "END" && inner > 0)
299
+ inner--;
300
+ else if (line.name === "END" && value === "VEVENT") {
301
+ try {
302
+ events.push(eventOf(held, zone));
303
+ }
304
+ catch (error) {
305
+ const uid = held.find(({ name }) => name === "UID")?.value.trim() || null;
306
+ skipped.push({ uid, reason: error instanceof Error ? error.message : String(error) });
307
+ }
308
+ held = null;
309
+ }
310
+ else if (inner === 0)
311
+ held.push(line);
312
+ }
313
+ return { events, skipped };
314
+ };
@@ -1,4 +1,5 @@
1
1
  export type { CalendarAccessRole, CalendarInput, EntryAttachment, EntryAttachmentInput, EntryEventType, EntryInput, EntryKind, EntryParticipantInput, EntryRevision, EntryStatus, EntryTransparency, EntryVisibility, EventDetails, EventFilter, EventStore, LinkReview, ParticipantResponse, ReviewDecision, StoredCalendar, StoredEntry, StoredParticipant, } from "../meetings/index.js";
2
+ export { type IcsEvent, type IcsSkip, readIcs, writeIcs } from "./ics.js";
2
3
  export { repeatRule } from "./recurrence.js";
3
4
  export type { BusyTimes, CalendarExpander, CalendarSource, CalendarSourceTraits, ChangePage, ChangeResult, FreeBusyRequest, SearchPage, SyncWindow, } from "./source.js";
4
5
  export { type CalendarCursor, type CalendarSyncOptions, type CalendarSyncReport, type CalendarSyncResult, calendarStatus, calendarSyncWindow, type PendingRound, parseCalendarCursor, syncCalendars, } from "./sync.js";