@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.
@@ -1,3 +1,4 @@
1
+ export { readIcs, writeIcs } from "./ics.js";
1
2
  export { repeatRule } from "./recurrence.js";
2
3
  export { calendarStatus, calendarSyncWindow, parseCalendarCursor, syncCalendars, } from "./sync.js";
3
4
  export { CALENDAR_WRITE_DEFAULTS, CALENDAR_WRITE_KEYS, calendarConflict, EVENTS_DELETE_SERIES_KEY, EVENTS_NOTIFY_KEY, } from "./writer.js";
@@ -1,7 +1,7 @@
1
1
  import { type Predicate, type QueryRegistry } from "@wirecat/cli-base/query";
2
2
  import type { Issue, Meta } from "@wirecat/cli-base/standards";
3
3
  import { type QueryWords } from "../cli/list-query.js";
4
- import { type EventDetails, type EventFilter, type EventStore, type StoredCalendar, type StoredEntry } from "../meetings/index.js";
4
+ import { type EntryEventType, type EventDetails, type EventFilter, type EventStore, type StoredCalendar, type StoredEntry } from "../meetings/index.js";
5
5
  import type { AccountKey, MessageStore } from "../store/store.js";
6
6
  /** Who looks a person up: the shared store's `people.personOf`, for an attendee's `email` contact. */
7
7
  export type PersonOfEmail = (email: string) => Promise<string | null>;
@@ -17,10 +17,12 @@ export declare const calendarItem: (one: StoredCalendar) => {
17
17
  synced: boolean;
18
18
  seen_at: string | null;
19
19
  };
20
- /** A stored calendar by its id or the provider's id, among the account's calendars. */
20
+ /** A stored calendar of the account by its id, the provider's id, or its title when only one has it. */
21
21
  export declare const calendarIn: (store: EventStore, accountId: number, reference: string) => Promise<StoredCalendar>;
22
22
  export declare const eventIdOf: (reference: string | number) => number;
23
23
  export declare const plusDays: (date: string, days: number) => string;
24
+ /** The kinds a calendar entry is stored as (`EntryEventType`); a copy with none is `default`. */
25
+ export declare const EVENT_TYPES: readonly EntryEventType[];
24
26
  /** `events list`'s query. The store answers `ends_at` and `changed_at` from below only; `starts_at` either way. */
25
27
  export declare const EVENTS_QUERY: QueryRegistry;
26
28
  export declare const EVENTS_QUERY_NOTE: string;
@@ -53,7 +55,7 @@ export declare const entryItem: (entry: StoredEntry) => {
53
55
  kind: import("../meetings/event-store.js").EntryKind;
54
56
  title: string | null;
55
57
  status: import("../meetings/event-store.js").EntryStatus;
56
- event_type: import("../meetings/event-store.js").EntryEventType | null;
58
+ event_type: EntryEventType | null;
57
59
  own_response: import("../meetings/event-store.js").ParticipantResponse | null;
58
60
  starts_at: string | null;
59
61
  ends_at: string | null;
@@ -94,7 +96,7 @@ export declare const eventAnswer: (details: EventDetails, personOf?: PersonOfEma
94
96
  kind: import("../meetings/event-store.js").EntryKind;
95
97
  title: string | null;
96
98
  status: import("../meetings/event-store.js").EntryStatus;
97
- event_type: import("../meetings/event-store.js").EntryEventType | null;
99
+ event_type: EntryEventType | null;
98
100
  own_response: import("../meetings/event-store.js").ParticipantResponse | null;
99
101
  starts_at: string | null;
100
102
  ends_at: string | null;
@@ -238,3 +240,14 @@ export declare const sourceAccounts: (messages: MessageStore, running: AccountKe
238
240
  * calendar, or a calendar whose sync never finished or is in the middle of a round, so its events may be missing.
239
241
  */
240
242
  export declare const accountsMeta: (store: EventStore, accounts: readonly ReadAccount[], skipped: readonly Issue[], now: number) => Promise<Pick<Meta, "accounts" | "complete" | "issues">>;
243
+ /** The most events one `events export` writes: what the store reads in one go. */
244
+ export declare const EXPORT_MAX = 1000;
245
+ /** The events of a filter as one iCalendar text; a filter that finds more than `EXPORT_MAX` is refused. */
246
+ export declare const eventsIcs: (store: EventStore, accountId: number, filter: EventFilter | null, options: {
247
+ now: number;
248
+ product: string;
249
+ }) => Promise<{
250
+ format: "ics";
251
+ events: number;
252
+ content: string;
253
+ }>;
@@ -1,9 +1,10 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { dateRange, dayBoundary, queryError, queryFieldsOf, } from "@wirecat/cli-base/query";
3
- import { LIST_QUERY_GUIDE, listQuery, nativeTerms, typedQuery } from "../cli/list-query.js";
3
+ import { LIST_QUERY_GUIDE, listQuery, nativeTermLists, typedQuery } from "../cli/list-query.js";
4
4
  import { event, eventChange, eventItem, listed, } from "../meetings/index.js";
5
5
  import { entriesOfList, fitting } from "../search/account-list.js";
6
6
  import { listContextOf } from "../services/search-kind.js";
7
+ import { writeIcs } from "./ics.js";
7
8
  import { calendarStatus } from "./sync.js";
8
9
  const iso = (at) => (at === null ? null : new Date(at).toISOString());
9
10
  /** Fields whose text whoever sent the invitation wrote: an agent reads them as data. */
@@ -30,30 +31,33 @@ export const calendarItem = (one) => ({
30
31
  synced: one.synced,
31
32
  seen_at: iso(one.seenAt),
32
33
  });
33
- /** A stored calendar by its id or the provider's id, among the accounts' calendars; one the id names twice is refused. */
34
- const calendarAmong = async (store, accountIds, reference) => {
35
- if (accountIds.length === 1)
36
- return calendarIn(store, accountIds[0], reference);
37
- const calendars = (await Promise.all(accountIds.map((id) => store.calendars(id)))).flat();
34
+ const sameTitle = (one, reference) => one.title !== null && one.title.trim().toLocaleLowerCase() === reference.trim().toLocaleLowerCase();
35
+ /**
36
+ * A calendar by its id, then the provider's id, then its title, case aside. Two calendars may share a title, a shared
37
+ * one beside the owner's own: such a title is refused with their ids, never one of them picked.
38
+ */
39
+ const calendarOfList = (calendars, reference, where) => {
38
40
  const byId = calendars.find((one) => String(one.id) === reference);
39
41
  if (byId)
40
42
  return byId;
41
43
  const byExternal = calendars.filter((one) => one.externalId === reference);
42
- if (byExternal.length > 1)
43
- throw new CliError("validation_error", `calendar ${reference} is in more than one of these accounts; name it by its id: ` +
44
- byExternal.map(({ id }) => id).join(", "));
45
- if (!byExternal[0])
46
- throw new CliError("not_found", `no calendar ${reference} in these accounts`);
47
- return byExternal[0];
44
+ const found = byExternal.length ? byExternal : calendars.filter((one) => sameTitle(one, reference));
45
+ if (found.length > 1)
46
+ throw new CliError("validation_error", `calendar ${reference} names ${found.length} calendars ${where}; name one by its id: ` +
47
+ found.map(({ id, title }) => (title === null ? String(id) : `${id} (${title})`)).join(", "), { reason: "calendar.ambiguous", candidates: found.map(({ id }) => String(id)) });
48
+ if (!found[0])
49
+ throw new CliError("not_found", `no calendar ${reference} ${where}; calendars list shows them`);
50
+ return found[0];
48
51
  };
49
- /** A stored calendar by its id or the provider's id, among the account's calendars. */
50
- export const calendarIn = async (store, accountId, reference) => {
51
- const calendars = await store.calendars(accountId);
52
- const found = calendars.find((one) => String(one.id) === reference) ?? calendars.find((one) => one.externalId === reference);
53
- if (!found)
54
- throw new CliError("not_found", `no calendar ${reference} in this account; calendars list shows them`);
55
- return found;
52
+ /** A stored calendar among the accounts' calendars, as `calendarIn` finds one. */
53
+ const calendarAmong = async (store, accountIds, reference) => {
54
+ if (accountIds.length === 1)
55
+ return calendarIn(store, accountIds[0], reference);
56
+ const calendars = (await Promise.all(accountIds.map((id) => store.calendars(id)))).flat();
57
+ return calendarOfList(calendars, reference, "in these accounts");
56
58
  };
59
+ /** A stored calendar of the account by its id, the provider's id, or its title when only one has it. */
60
+ export const calendarIn = async (store, accountId, reference) => calendarOfList(await store.calendars(accountId), reference, "in this account");
57
61
  export const eventIdOf = (reference) => {
58
62
  const text = String(reference).replace(/^event:/u, "");
59
63
  if (!/^[1-9]\d*$/u.test(text) || !Number.isSafeInteger(Number(text)))
@@ -66,6 +70,15 @@ export const plusDays = (date, days) => {
66
70
  return new Date(Date.UTC(year, month - 1, day + days)).toISOString().slice(0, 10);
67
71
  };
68
72
  const LIST = "events list";
73
+ /** The kinds a calendar entry is stored as (`EntryEventType`); a copy with none is `default`. */
74
+ export const EVENT_TYPES = [
75
+ "default",
76
+ "out-of-office",
77
+ "focus",
78
+ "working-location",
79
+ "birthday",
80
+ "from-mail",
81
+ ];
69
82
  const oneSided = (field, example) => (node) => {
70
83
  if (node.operator !== "range" || node.upper !== "*")
71
84
  queryError("query.unsupported-operator", node.span, `${field} takes only an earliest moment, such as ${example}`, {
@@ -78,9 +91,19 @@ export const EVENTS_QUERY = {
78
91
  entity: "event",
79
92
  backend: "native",
80
93
  fields: queryFieldsOf(event, {
81
- native: ["starts_at", "ends_at", "changed_at", "overlapping", "calendar", "person"],
94
+ native: ["id", "starts_at", "ends_at", "changed_at", "overlapping", "calendar", "person", "event_type"],
82
95
  relations: {
83
- calendar: { type: "kw", example: "calendar:3", relation: "one calendar, by its id or the provider's id" },
96
+ event_type: {
97
+ type: "enum",
98
+ values: EVENT_TYPES,
99
+ example: "event_type:default",
100
+ relation: "events with a copy of this kind; -event_type:<kind> leaves out events with one",
101
+ },
102
+ calendar: {
103
+ type: "kw",
104
+ example: "calendar:3",
105
+ relation: "one calendar, by its id, the provider's id or its title",
106
+ },
84
107
  person: {
85
108
  type: "kw",
86
109
  example: "person:ana@example.com",
@@ -88,6 +111,7 @@ export const EVENTS_QUERY = {
88
111
  },
89
112
  },
90
113
  examples: {
114
+ id: "id:42",
91
115
  starts_at: "starts_at:[2026-11-02 TO 2026-11-07}",
92
116
  ends_at: "ends_at>=2026-11-02",
93
117
  changed_at: "changed_at>=2026-10-01",
@@ -108,9 +132,9 @@ export const EVENTS_QUERY = {
108
132
  }
109
133
  : field),
110
134
  };
111
- export const EVENTS_QUERY_NOTE = "\nends_at keeps events that end after its moment, so one that ends exactly then is left out. Without starts_at " +
112
- "or ends_at: events that end after the start of today, in your time zone.\noverlapping:true keeps timed events " +
113
- "that overlap another in the accounts read, each with overlaps: the others. Left out: all-day, cancelled, " +
135
+ export const EVENTS_QUERY_NOTE = "\nends_at keeps events that end after its moment, so one that ends exactly then is left out. Without id, " +
136
+ "starts_at or ends_at: events that end after the start of today, in your time zone.\noverlapping:true keeps timed " +
137
+ "events that overlap another in the accounts read, each with overlaps: the others. Left out: all-day, cancelled, " +
114
138
  "declined and free events and working-location blocks; back-to-back events do not overlap.";
115
139
  /** A time term as epoch ms: from `lower` on, and before `upper`. */
116
140
  export const timeWindow = (node, zone, now) => {
@@ -132,8 +156,20 @@ export const eventsFilter = async (query, context) => {
132
156
  const { zone, now } = context;
133
157
  const span = (text) => ({ start: 0, end: text.length });
134
158
  const day = (date) => dayBoundary(date, zone, false, span(date));
135
- for (const [field, node] of nativeTerms(listQuery(typedQuery(query), EVENTS_QUERY, LIST), LIST)) {
136
- if (field === "starts_at") {
159
+ const terms = nativeTermLists(listQuery(typedQuery(query), EVENTS_QUERY, LIST), LIST, [], ["event_type"]);
160
+ const kinds = (nodes = []) => nodes.map(({ value }) => value.toLowerCase());
161
+ const excluded = kinds(terms.get("-event_type"));
162
+ if (excluded.length)
163
+ filter.notEventTypes = [...new Set(excluded)];
164
+ for (const [field, nodes] of terms) {
165
+ const node = nodes[0];
166
+ if (field === "-event_type")
167
+ continue;
168
+ if (field === "event_type")
169
+ filter.eventType = kinds(nodes)[0];
170
+ else if (field === "id")
171
+ filter.id = eventIdOf(node.value);
172
+ else if (field === "starts_at") {
137
173
  const { lower, upper } = timeWindow(node, zone, now);
138
174
  if (lower !== undefined)
139
175
  filter.startsFrom = lower;
@@ -165,7 +201,11 @@ export const eventsFilter = async (query, context) => {
165
201
  filter.personId = person;
166
202
  }
167
203
  }
168
- if (filter.from === undefined && filter.startsFrom === undefined && filter.to === undefined)
204
+ // One event named by its id is found whenever it is.
205
+ if (filter.id === undefined &&
206
+ filter.from === undefined &&
207
+ filter.startsFrom === undefined &&
208
+ filter.to === undefined)
169
209
  filter.from = day(localDate(now, zone));
170
210
  return datedFilter(filter, zone);
171
211
  };
@@ -436,3 +476,51 @@ export const accountsMeta = async (store, accounts, skipped, now) => {
436
476
  ...(issues.length ? { issues } : {}),
437
477
  };
438
478
  };
479
+ /** The most events one `events export` writes: what the store reads in one go. */
480
+ export const EXPORT_MAX = 1000;
481
+ const stampOf = (at) => new Date(at)
482
+ .toISOString()
483
+ .replace(/[-:]/gu, "")
484
+ .replace(/\.\d{3}/u, "");
485
+ /**
486
+ * One event as iCalendar, from its first live copy in the account. An occurrence of a repeating event is written as
487
+ * an event of its own, its UID the series' with its slot after it, so a file never holds two events with one UID.
488
+ */
489
+ const icsEventOf = (details) => {
490
+ const entry = details.entries.find((one) => one.deletedAt === null) ?? details.entries[0];
491
+ const { event } = details;
492
+ const participants = details.participants.filter(({ entryId }) => entryId === entry.id);
493
+ const organizer = participants.find((one) => one.organizer);
494
+ const slot = entry.originalStartsAt ?? entry.startsAt;
495
+ const uid = entry.kind === "occurrence" && slot !== null
496
+ ? `${entry.icalUid ?? entry.seriesExternalId ?? entry.externalId}_${stampOf(slot)}`
497
+ : (entry.icalUid ?? entry.externalId);
498
+ const times = event.allDay && entry.startDate !== null && entry.endDate !== null
499
+ ? { startDate: entry.startDate, endDate: entry.endDate }
500
+ : event.startsAt === null
501
+ ? {}
502
+ : { startsAt: event.startsAt, endsAt: event.endsAt ?? event.startsAt };
503
+ return {
504
+ uid,
505
+ title: event.title,
506
+ description: event.description,
507
+ location: event.location,
508
+ ...times,
509
+ status: entry.status,
510
+ transparency: entry.transparency,
511
+ organizer: organizer ? { email: organizer.email, name: organizer.name } : null,
512
+ attendees: participants
513
+ .filter((one) => !one.organizer)
514
+ .map(({ email, name, response, optional }) => ({ email, name, response, optional })),
515
+ };
516
+ };
517
+ /** The events of a filter as one iCalendar text; a filter that finds more than `EXPORT_MAX` is refused. */
518
+ export const eventsIcs = async (store, accountId, filter, options) => {
519
+ const { rows, hasMore } = filter === null ? { rows: [], hasMore: false } : await page(store, { ...filter, limit: EXPORT_MAX, offset: 0 });
520
+ if (hasMore)
521
+ throw new CliError("validation_error", `the query finds more than ${EXPORT_MAX} events; narrow it, such as with starts_at:[2026-11-01 TO 2026-12-01}`, { reason: "export.too-many" });
522
+ const events = [];
523
+ for (const row of rows)
524
+ events.push(icsEventOf(await eventOf(store, accountId, row.id)));
525
+ return { format: "ics", events: events.length, content: writeIcs(events, options) };
526
+ };
@@ -489,8 +489,13 @@ export const memoryEventStore = () => {
489
489
  (event.allDay && filter.startsFromDate !== undefined
490
490
  ? has(event, (one) => one.startDate !== null && one.startDate >= (filter.startsFromDate ?? ""))
491
491
  : event.startsAt !== null && event.startsAt >= filter.startsFrom)) &&
492
+ (filter.id === undefined || event.id === filter.id) &&
492
493
  (filter.calendarId === undefined || has(event, (one) => one.calendarId === filter.calendarId)) &&
493
494
  (accounts === undefined || has(event, inAccounts)) &&
495
+ (filter.eventType === undefined ||
496
+ has(event, (one) => inAccounts(one) && (one.eventType ?? "default") === filter.eventType)) &&
497
+ (!filter.notEventTypes?.length ||
498
+ !has(event, (one) => inAccounts(one) && (filter.notEventTypes ?? []).includes(one.eventType ?? "default"))) &&
494
499
  (filter.personId === undefined ||
495
500
  has(event, (one) => participants.some((p) => p.entryId === one.id && p.identityId === filter.personId))) &&
496
501
  (filter.changedSince === undefined ||
@@ -1,7 +1,7 @@
1
1
  import type { EntryInput } from "../../meetings/index.js";
2
2
  import { type CalendarWriter } from "../writer.js";
3
3
  export interface FakeCalendarWriterCall {
4
- write: "create" | "update" | "remove" | "respond" | "move";
4
+ write: "create" | "quick-add" | "import" | "update" | "remove" | "respond" | "move";
5
5
  calendar: string;
6
6
  externalId: string;
7
7
  notify: boolean;
@@ -73,6 +73,46 @@ export const fakeCalendarWriter = (options = {}) => {
73
73
  of(calendar.externalId).set(id, entry);
74
74
  return answer(entry);
75
75
  },
76
+ /** Reads no date: the event is the whole line as its title, an hour from noon UTC of 2 November 2026. */
77
+ async quickAdd(calendar, text, { notify }) {
78
+ const id = `quick${++version}`;
79
+ calls.push({ write: "quick-add", calendar: calendar.externalId, externalId: id, notify });
80
+ const noon = Date.UTC(2026, 10, 2, 12);
81
+ const entry = stamp({
82
+ externalId: id,
83
+ icalUid: `${id}@example.com`,
84
+ kind: "single",
85
+ status: "confirmed",
86
+ title: text,
87
+ startsAt: noon,
88
+ endsAt: noon + 3_600_000,
89
+ participants: [],
90
+ });
91
+ of(calendar.externalId).set(id, entry);
92
+ return answer(entry);
93
+ },
94
+ /** Keeps one event per UID on a calendar: a UID sent again replaces the event under the same id. */
95
+ async importEvent(calendar, event) {
96
+ const held = [...of(calendar.externalId).values()].find((one) => one.icalUid === event.uid);
97
+ const id = held?.externalId ?? `imported${++version}`;
98
+ calls.push({ write: "import", calendar: calendar.externalId, externalId: id, notify: false });
99
+ const entry = stamp({
100
+ externalId: id,
101
+ icalUid: event.uid,
102
+ kind: event.recurrence ? "series" : "single",
103
+ status: event.status ?? "confirmed",
104
+ title: event.title,
105
+ description: event.description ?? null,
106
+ location: event.location ?? null,
107
+ ...(event.startDate === undefined
108
+ ? { startsAt: event.startsAt ?? null, endsAt: event.endsAt ?? null, timezone: event.timezone ?? null }
109
+ : { allDay: true, startDate: event.startDate, endDate: event.endDate ?? null }),
110
+ ...(event.recurrence ? { recurrence: event.recurrence } : {}),
111
+ participants: (event.attendees ?? []).map(({ email, name }) => ({ email, name: name ?? null })),
112
+ });
113
+ of(calendar.externalId).set(id, entry);
114
+ return answer(entry);
115
+ },
76
116
  async update(calendar, externalId, patch, { scope, etag, notify }) {
77
117
  calls.push({ write: "update", calendar: calendar.externalId, externalId, notify, scope });
78
118
  const entry = stamp({ ...current(calendar, externalId, etag), ...fields(patch) });
@@ -1,6 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import type { EntryInput, ParticipantResponse, StoredCalendar } from "../meetings/index.js";
3
3
  import type { Level, PermissionKey } from "../sends/permissions.js";
4
+ import type { IcsEvent } from "./ics.js";
4
5
  /**
5
6
  * A new event in neutral words: a timed one has `startsAt` and `endsAt` (epoch ms, end exclusive) in `timezone`; an
6
7
  * all-day one has `startDate` and `endDate` (`YYYY-MM-DD`, end exclusive). A repeating one has `recurrence`, RFC 5545
@@ -64,6 +65,19 @@ export interface CalendarWriter {
64
65
  etag: string;
65
66
  notify: boolean;
66
67
  }): Promise<EntryInput>;
68
+ /**
69
+ * Optional: the provider makes an event from one line of text, reading its date, time and place its own way. The
70
+ * provider picks the id, so a retry after `outcome_unknown` may make a second event.
71
+ */
72
+ quickAdd?(calendar: StoredCalendar, text: string, options: {
73
+ notify: boolean;
74
+ }): Promise<EntryInput>;
75
+ /**
76
+ * Optional: adds a private copy of an event read from an iCalendar file, under its own UID, as Google's
77
+ * `events.import`. The provider mails nobody. Sending the same UID again goes to the provider again; what it does
78
+ * with an event it holds already is its own.
79
+ */
80
+ importEvent?(calendar: StoredCalendar, event: IcsEvent): Promise<EntryInput>;
67
81
  /**
68
82
  * Optional: moves the event, keeping its id, to `target`, another calendar of the same account. `etag` is checked
69
83
  * before the move; a provider whose move takes no precondition reads the event first. A retry after
@@ -75,7 +89,7 @@ export interface CalendarWriter {
75
89
  }): Promise<EntryInput>;
76
90
  }
77
91
  /** Which write a command makes, for `CalendarWrites.extraKeys`. */
78
- export type CalendarWrite = "create" | "update" | "delete" | "respond" | "move";
92
+ export type CalendarWrite = "create" | "quick-add" | "import" | "update" | "delete" | "respond" | "move";
79
93
  /** Mailing the other attendees: an agent may write to the calendar and still not to people (WC-PERM-14's spirit). */
80
94
  export declare const EVENTS_NOTIFY_KEY: PermissionKey;
81
95
  export declare const EVENTS_DELETE_SERIES_KEY: PermissionKey;
@@ -10,6 +10,8 @@ export const CALENDAR_WRITE_KEYS = [EVENTS_NOTIFY_KEY, EVENTS_DELETE_SERIES_KEY]
10
10
  */
11
11
  export const CALENDAR_WRITE_DEFAULTS = {
12
12
  "events.create": "allow",
13
+ // One import writes a whole file of events, and may change events the calendar already holds under their UIDs.
14
+ "events.import": "ask",
13
15
  "events.update": "allow",
14
16
  "events.respond": "allow",
15
17
  "events.move": "allow",
@@ -3,6 +3,7 @@ import type { AppIdentity } from "../ports/app.js";
3
3
  import type { Warn } from "../runs/events.js";
4
4
  import { type Asker, type GuardSettings } from "../sends/guard.js";
5
5
  import { type PermissionKey } from "../sends/permissions.js";
6
+ import { entryItem } from "./reads.js";
6
7
  import { type CalendarWrite, type CalendarWriter, type EventPatch, type EventResponse } from "./writer.js";
7
8
  /** What a CLI or an MCP host hands the calendar writes for one profile. */
8
9
  export interface CalendarWrites {
@@ -83,6 +84,51 @@ export declare const createEvent: (context: WriteContext, calendarReference: str
83
84
  } | null;
84
85
  hint?: string | undefined;
85
86
  }>;
87
+ /**
88
+ * An event the provider makes from one line of text, as Google's quickAdd. Nobody is mailed unless `notify` says so:
89
+ * the attendees are known only once the provider has read the text.
90
+ */
91
+ export declare const createEventFromText: (context: WriteContext, calendarReference: string, text: string, options?: {
92
+ notify?: boolean;
93
+ }) => Promise<{
94
+ operation_id: string;
95
+ event: string | null;
96
+ entry: {
97
+ id: string;
98
+ calendar_id: string;
99
+ external_id: string;
100
+ kind: import("../meetings/event-store.js").EntryKind;
101
+ title: string | null;
102
+ status: import("../meetings/event-store.js").EntryStatus;
103
+ event_type: import("../meetings/event-store.js").EntryEventType | null;
104
+ own_response: import("../meetings/event-store.js").ParticipantResponse | null;
105
+ starts_at: string | null;
106
+ ends_at: string | null;
107
+ join_url: string | null;
108
+ url: string | null;
109
+ deleted_at: string | null;
110
+ } | null;
111
+ hint?: string | undefined;
112
+ }>;
113
+ /** The most events one import sends: a file of more is refused before anything goes. */
114
+ export declare const IMPORT_MAX = 500;
115
+ /**
116
+ * The events of an iCalendar text into a calendar, one provider call each, in the file's order. The whole import is
117
+ * one write for the guard: one question, one journal line. A failure part way names the UIDs already imported.
118
+ */
119
+ export declare const importEvents: (context: WriteContext, calendarReference: string, text: string) => Promise<{
120
+ operation_id: string;
121
+ calendar: string;
122
+ imported: {
123
+ uid: string;
124
+ event: string | null;
125
+ entry: ReturnType<typeof entryItem> | null;
126
+ }[];
127
+ skipped: {
128
+ uid: string | null;
129
+ reason: string;
130
+ }[];
131
+ }>;
86
132
  interface ScopeChoice {
87
133
  calendar?: string;
88
134
  series?: boolean;
@@ -5,6 +5,7 @@ import { guardFor } from "../sends/guard.js";
5
5
  import { guardedWrite } from "../sends/guarded.js";
6
6
  import { DEFAULT_PERMISSIONS, levelFor } from "../sends/permissions.js";
7
7
  import { newOperationId } from "../sends/send-id.js";
8
+ import { readIcs } from "./ics.js";
8
9
  import { calendarIn, entryItem, eventIdOf, eventOf, plusDays } from "./reads.js";
9
10
  import { kindBoundLines, repeatRule, ruleCount, ruleEnd, ruleEndingBefore, ruleLine, splitDates } from "./recurrence.js";
10
11
  import { CALENDAR_WRITE_DEFAULTS, EVENTS_DELETE_SERIES_KEY, EVENTS_NOTIFY_KEY, } from "./writer.js";
@@ -12,6 +13,8 @@ export const EVENT_RESPONSES = ["accepted", "tentative", "declined"];
12
13
  /** Each write is checked by its command's path, the key a declared CLI's `commandPermissions` already knows. */
13
14
  const COMMAND_KEY = {
14
15
  create: "events.create",
16
+ "quick-add": "events.create",
17
+ import: "events.import",
15
18
  update: "events.update",
16
19
  delete: "events.delete",
17
20
  respond: "events.respond",
@@ -191,6 +194,101 @@ export const createEvent = async (context, calendarReference, fields, options =
191
194
  const [saved] = await savedAfterCreate(context, id, () => context.store.saveEntries(calendar.id, [entry], context.now));
192
195
  return answerOf(id, saved, draft.recurrence !== undefined);
193
196
  };
197
+ /** The longest line `createEventFromText` sends; a quick add is one sentence, not a description. */
198
+ const TEXT_MAX = 1000;
199
+ /**
200
+ * An event the provider makes from one line of text, as Google's quickAdd. Nobody is mailed unless `notify` says so:
201
+ * the attendees are known only once the provider has read the text.
202
+ */
203
+ export const createEventFromText = async (context, calendarReference, text, options = {}) => {
204
+ const quickAdd = context.writes.writer.quickAdd;
205
+ if (!quickAdd)
206
+ throw new CliError("configuration_error", "this provider cannot make an event from text");
207
+ const line = text.trim();
208
+ if (!line)
209
+ throw new CliError("validation_error", "the text is empty: say what, when and where");
210
+ if (line.length > TEXT_MAX || /[\r\n]/u.test(line))
211
+ throw new CliError("validation_error", `the text is one line of at most ${TEXT_MAX} characters`);
212
+ const calendar = await calendarIn(context.store, context.accountId, calendarReference);
213
+ const notify = options.notify ?? false;
214
+ const guard = writeGuard(context, keysFor(context, "quick-add", notify));
215
+ const id = newEventId();
216
+ let entry;
217
+ try {
218
+ entry = await guardedWrite(guard, { chatId: null, kind: "edit", key: COMMAND_KEY["quick-add"], operationId: id, sendId: id, notify }, () => quickAdd.call(context.writes.writer, calendar, line, { notify }));
219
+ }
220
+ catch (error) {
221
+ if (isCliError(error) && error.code === "outcome_unknown")
222
+ throw new CliError("outcome_unknown", `${error.message} — the provider may have made the event, and a create from text cannot be retried safely: ` +
223
+ "sync, then look with events list changed_at>=today before adding it again", { ...error.details, operation_id: id, retryable: false });
224
+ throw error;
225
+ }
226
+ let saved;
227
+ try {
228
+ ;
229
+ [saved] = await context.store.saveEntries(calendar.id, [entry], context.now);
230
+ }
231
+ catch (error) {
232
+ throw new CliError(isCliError(error) ? error.code : "invalid_response", `the provider made event ${entry.externalId}, but the local store did not save it ` +
233
+ `(${error instanceof Error ? error.message : String(error)}); sync to store it — do not create it again`, { ...(isCliError(error) ? error.details : {}), operation_id: id, id: entry.externalId, retryable: false });
234
+ }
235
+ return answerOf(id, saved, (entry.recurrence?.length ?? 0) > 0);
236
+ };
237
+ /** The most events one import sends: a file of more is refused before anything goes. */
238
+ export const IMPORT_MAX = 500;
239
+ /**
240
+ * The events of an iCalendar text into a calendar, one provider call each, in the file's order. The whole import is
241
+ * one write for the guard: one question, one journal line. A failure part way names the UIDs already imported.
242
+ */
243
+ export const importEvents = async (context, calendarReference, text) => {
244
+ const writer = context.writes.writer;
245
+ if (!writer.importEvent)
246
+ throw new CliError("configuration_error", "this provider cannot import events");
247
+ const { events, skipped } = readIcs(text, context.zone);
248
+ if (events.length === 0)
249
+ throw new CliError("validation_error", skipped.length
250
+ ? `no event in the file can be imported: ${skipped.map(({ reason }) => reason).join("; ")}`
251
+ : "the file holds no event (VEVENT)", { reason: "ics.no-events", skipped: skipped.map(({ uid, reason }) => ({ uid, reason })) });
252
+ if (events.length > IMPORT_MAX)
253
+ throw new CliError("validation_error", `the file holds ${events.length} events; one import takes at most ${IMPORT_MAX}`, { reason: "ics.too-many" });
254
+ const calendar = await calendarIn(context.store, context.accountId, calendarReference);
255
+ const guard = writeGuard(context, keysFor(context, "import", false));
256
+ const id = newEventId();
257
+ const imported = [];
258
+ await guardedWrite(guard, { chatId: null, kind: "edit", key: COMMAND_KEY.import, operationId: id, sendId: id, notify: false }, async () => {
259
+ for (const event of events) {
260
+ try {
261
+ const entry = await writer.importEvent?.call(writer, calendar, event);
262
+ if (!entry)
263
+ throw new CliError("invalid_response", "the provider returned no event");
264
+ const [saved] = await context.store.saveEntries(calendar.id, [entry], context.now);
265
+ imported.push({
266
+ uid: event.uid,
267
+ event: saved?.eventId == null ? null : `event:${saved.eventId}`,
268
+ entry: saved ? entryItem(saved) : null,
269
+ });
270
+ }
271
+ catch (error) {
272
+ const done = imported.map(({ uid }) => uid);
273
+ throw new CliError(isCliError(error) ? error.code : "provider_error", `${error instanceof Error ? error.message : String(error)} — at the event with UID ${event.uid}; ` +
274
+ `${done.length} of ${events.length} were imported before it (details.imported). Running the import ` +
275
+ "again sends every event again under its UID", {
276
+ ...(isCliError(error) ? error.details : {}),
277
+ operation_id: id,
278
+ imported: done,
279
+ failed: event.uid,
280
+ retryable: false,
281
+ });
282
+ }
283
+ }
284
+ });
285
+ return {
286
+ operation_id: id,
287
+ calendar: String(calendar.id),
288
+ imported,
289
+ skipped: skipped.map(({ uid, reason }) => ({ uid, reason })),
290
+ };
291
+ };
194
292
  const targetOf = async (context, reference, { calendar, scope }) => {
195
293
  const details = await eventOf(context.store, context.accountId, eventIdOf(reference));
196
294
  const calendars = await context.store.calendars(context.accountId);
@@ -39,9 +39,10 @@ export declare const queryArgument: (registry: QueryRegistry) => v.OptionalSchem
39
39
  export declare const nativeTerms: (ast: QueryAst | undefined, command: string) => Map<string, Predicate>;
40
40
  /**
41
41
  * As `nativeTerms`, but each `repeatable` field may be named again, every term kept in order: `person:a person:b`
42
- * asks for both, as AND reads.
42
+ * asks for both, as AND reads. A `negatable` field may also be excluded, `-field:value` or `NOT field:value`, as
43
+ * often as wanted; those terms are kept under `-field`.
43
44
  */
44
- export declare const nativeTermLists: (ast: QueryAst | undefined, command: string, repeatable?: readonly string[]) => Map<string, Predicate[]>;
45
+ export declare const nativeTermLists: (ast: QueryAst | undefined, command: string, repeatable?: readonly string[], negatable?: readonly string[]) => Map<string, Predicate[]>;
45
46
  /**
46
47
  * A store that compares whole values: each field's `field:value` or `field:"two words"`, enum values lowercased
47
48
  * as validation already accepts them. A range or wildcard is refused rather than read as a value.
@@ -112,9 +112,10 @@ export const queryArgument = (registry) => {
112
112
  export const nativeTerms = (ast, command) => new Map([...nativeTermLists(ast, command)].map(([field, [node]]) => [field, node]));
113
113
  /**
114
114
  * As `nativeTerms`, but each `repeatable` field may be named again, every term kept in order: `person:a person:b`
115
- * asks for both, as AND reads.
115
+ * asks for both, as AND reads. A `negatable` field may also be excluded, `-field:value` or `NOT field:value`, as
116
+ * often as wanted; those terms are kept under `-field`.
116
117
  */
117
- export const nativeTermLists = (ast, command, repeatable = []) => {
118
+ export const nativeTermLists = (ast, command, repeatable = [], negatable = []) => {
118
119
  const terms = new Map();
119
120
  if (!ast)
120
121
  return terms;
@@ -123,8 +124,16 @@ export const nativeTermLists = (ast, command, repeatable = []) => {
123
124
  const add = (node) => {
124
125
  if (node.kind === "boolean") {
125
126
  for (const clause of node.clauses) {
127
+ const excluded = clause.occur === "must_not" && clause.node.kind === "predicate" && negatable.includes(clause.node.field);
128
+ if (excluded && clause.node.kind === "predicate") {
129
+ const key = `-${clause.node.field}`;
130
+ terms.set(key, [...(terms.get(key) ?? []), clause.node]);
131
+ continue;
132
+ }
126
133
  if (clause.occur !== "must")
127
- queryError("query.unsupported-placement", clause.node.span, "this list takes only field:value terms joined by AND", context);
134
+ queryError("query.unsupported-placement", clause.node.span, negatable.length
135
+ ? `this list takes only field:value terms joined by AND, and -field:value for ${negatable.join(", ")}`
136
+ : "this list takes only field:value terms joined by AND", context);
128
137
  add(clause.node);
129
138
  }
130
139
  return;
@@ -3,9 +3,9 @@ import type { CacheDatabase } from "../../store/driver.js";
3
3
  import type { Messenger } from "./context.js";
4
4
  /** `doctor`'s short summary of the store. Must answer for a broken file, so an error is a field. */
5
5
  export declare const storeSummary: (env: NodeJS.ProcessEnv) => Promise<{
6
+ error?: undefined;
6
7
  path: string;
7
8
  exists: boolean;
8
- error?: undefined;
9
9
  } | {
10
10
  path: string;
11
11
  exists: boolean;