@wirecat/cli-core 0.538.0 → 0.540.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/calendar/busy.js +1 -1
  3. package/dist/calendar/cli/answers.d.ts +36 -0
  4. package/dist/calendar/cli/answers.js +17 -0
  5. package/dist/calendar/cli/index.d.ts +4 -0
  6. package/dist/calendar/cli/index.js +77 -12
  7. package/dist/calendar/ics.d.ts +55 -0
  8. package/dist/calendar/ics.js +314 -0
  9. package/dist/calendar/index.d.ts +1 -0
  10. package/dist/calendar/index.js +1 -0
  11. package/dist/calendar/mcp/index.js +2 -1
  12. package/dist/calendar/prepare.d.ts +2 -0
  13. package/dist/calendar/reads.d.ts +17 -4
  14. package/dist/calendar/reads.js +116 -27
  15. package/dist/calendar/testing/memory.js +5 -0
  16. package/dist/calendar/testing/writer.d.ts +1 -1
  17. package/dist/calendar/testing/writer.js +40 -0
  18. package/dist/calendar/writer.d.ts +15 -1
  19. package/dist/calendar/writer.js +2 -0
  20. package/dist/calendar/writes.d.ts +46 -0
  21. package/dist/calendar/writes.js +98 -0
  22. package/dist/cli/bot/mcp.js +2 -2
  23. package/dist/cli/effects.d.ts +14 -0
  24. package/dist/cli/effects.js +33 -2
  25. package/dist/cli/envelope.d.ts +4 -0
  26. package/dist/cli/index.d.ts +2 -2
  27. package/dist/cli/index.js +2 -2
  28. package/dist/cli/list-query.d.ts +8 -3
  29. package/dist/cli/list-query.js +21 -15
  30. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  31. package/dist/errors/reasons.js +7 -0
  32. package/dist/mcp/agent.js +12 -3
  33. package/dist/mcp/app-server.js +2 -2
  34. package/dist/mcp/server.js +2 -2
  35. package/dist/meetings/event-store.d.ts +6 -0
  36. package/dist/meetings/testing/event-store-contract.js +15 -0
  37. package/dist/store/sqlite/calendar-events.js +15 -0
  38. package/package.json +3 -3
@@ -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";
@@ -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";
@@ -30,7 +30,8 @@ export const calendarTools = [
30
30
  name: "events_list",
31
31
  description: "stored events, earliest first; `query` takes field terms joined by AND: starts_at (>=, <, or " +
32
32
  "[a TO b}), ends_at>=, changed_at>= (events new, moved or cancelled since, each with change and was), " +
33
- "overlapping:true (timed events that clash, each with overlaps), calendar:<id>; a date in the zone given, " +
33
+ "overlapping:true (timed events that clash, each with overlaps), calendar:<id>, event_type:<kind>, and " +
34
+ "-event_type:<kind> to leave one kind out; a date in the zone given, " +
34
35
  "UTC when absent; without starts_at or ends_at, from today on",
35
36
  properties: {
36
37
  account_id: integer,
@@ -126,6 +126,8 @@ export declare const eventPreparation: (details: EventDetails, options: Preparat
126
126
  }[] | undefined;
127
127
  truncated?: boolean | undefined;
128
128
  timezone?: string | undefined;
129
+ now?: string | undefined;
130
+ now_local?: string | undefined;
129
131
  window?: {
130
132
  since?: string | undefined;
131
133
  until?: string | undefined;
@@ -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, negatableOf, 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,20 @@ 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"],
95
+ negatable: ["event_type"],
82
96
  relations: {
83
- calendar: { type: "kw", example: "calendar:3", relation: "one calendar, by its id or the provider's id" },
97
+ event_type: {
98
+ type: "enum",
99
+ values: EVENT_TYPES,
100
+ example: "event_type:default",
101
+ relation: "events with a copy of this kind; -event_type:<kind> leaves out events with one",
102
+ },
103
+ calendar: {
104
+ type: "kw",
105
+ example: "calendar:3",
106
+ relation: "one calendar, by its id, the provider's id or its title",
107
+ },
84
108
  person: {
85
109
  type: "kw",
86
110
  example: "person:ana@example.com",
@@ -88,6 +112,7 @@ export const EVENTS_QUERY = {
88
112
  },
89
113
  },
90
114
  examples: {
115
+ id: "id:42",
91
116
  starts_at: "starts_at:[2026-11-02 TO 2026-11-07}",
92
117
  ends_at: "ends_at>=2026-11-02",
93
118
  changed_at: "changed_at>=2026-10-01",
@@ -108,9 +133,9 @@ export const EVENTS_QUERY = {
108
133
  }
109
134
  : field),
110
135
  };
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, " +
136
+ 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, " +
137
+ "starts_at or ends_at: events that end after the start of today, in your time zone.\noverlapping:true keeps timed " +
138
+ "events that overlap another in the accounts read, each with overlaps: the others. Left out: all-day, cancelled, " +
114
139
  "declined and free events and working-location blocks; back-to-back events do not overlap.";
115
140
  /** A time term as epoch ms: from `lower` on, and before `upper`. */
116
141
  export const timeWindow = (node, zone, now) => {
@@ -132,8 +157,20 @@ export const eventsFilter = async (query, context) => {
132
157
  const { zone, now } = context;
133
158
  const span = (text) => ({ start: 0, end: text.length });
134
159
  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") {
160
+ const terms = nativeTermLists(listQuery(typedQuery(query), EVENTS_QUERY, LIST), LIST, [], negatableOf(EVENTS_QUERY));
161
+ const kinds = (nodes = []) => nodes.map(({ value }) => value.toLowerCase());
162
+ const excluded = kinds(terms.get("-event_type"));
163
+ if (excluded.length)
164
+ filter.notEventTypes = [...new Set(excluded)];
165
+ for (const [field, nodes] of terms) {
166
+ const node = nodes[0];
167
+ if (field === "-event_type")
168
+ continue;
169
+ if (field === "event_type")
170
+ filter.eventType = kinds(nodes)[0];
171
+ else if (field === "id")
172
+ filter.id = eventIdOf(node.value);
173
+ else if (field === "starts_at") {
137
174
  const { lower, upper } = timeWindow(node, zone, now);
138
175
  if (lower !== undefined)
139
176
  filter.startsFrom = lower;
@@ -165,7 +202,11 @@ export const eventsFilter = async (query, context) => {
165
202
  filter.personId = person;
166
203
  }
167
204
  }
168
- if (filter.from === undefined && filter.startsFrom === undefined && filter.to === undefined)
205
+ // One event named by its id is found whenever it is.
206
+ if (filter.id === undefined &&
207
+ filter.from === undefined &&
208
+ filter.startsFrom === undefined &&
209
+ filter.to === undefined)
169
210
  filter.from = day(localDate(now, zone));
170
211
  return datedFilter(filter, zone);
171
212
  };
@@ -436,3 +477,51 @@ export const accountsMeta = async (store, accounts, skipped, now) => {
436
477
  ...(issues.length ? { issues } : {}),
437
478
  };
438
479
  };
480
+ /** The most events one `events export` writes: what the store reads in one go. */
481
+ export const EXPORT_MAX = 1000;
482
+ const stampOf = (at) => new Date(at)
483
+ .toISOString()
484
+ .replace(/[-:]/gu, "")
485
+ .replace(/\.\d{3}/u, "");
486
+ /**
487
+ * One event as iCalendar, from its first live copy in the account. An occurrence of a repeating event is written as
488
+ * an event of its own, its UID the series' with its slot after it, so a file never holds two events with one UID.
489
+ */
490
+ const icsEventOf = (details) => {
491
+ const entry = details.entries.find((one) => one.deletedAt === null) ?? details.entries[0];
492
+ const { event } = details;
493
+ const participants = details.participants.filter(({ entryId }) => entryId === entry.id);
494
+ const organizer = participants.find((one) => one.organizer);
495
+ const slot = entry.originalStartsAt ?? entry.startsAt;
496
+ const uid = entry.kind === "occurrence" && slot !== null
497
+ ? `${entry.icalUid ?? entry.seriesExternalId ?? entry.externalId}_${stampOf(slot)}`
498
+ : (entry.icalUid ?? entry.externalId);
499
+ const times = event.allDay && entry.startDate !== null && entry.endDate !== null
500
+ ? { startDate: entry.startDate, endDate: entry.endDate }
501
+ : event.startsAt === null
502
+ ? {}
503
+ : { startsAt: event.startsAt, endsAt: event.endsAt ?? event.startsAt };
504
+ return {
505
+ uid,
506
+ title: event.title,
507
+ description: event.description,
508
+ location: event.location,
509
+ ...times,
510
+ status: entry.status,
511
+ transparency: entry.transparency,
512
+ organizer: organizer ? { email: organizer.email, name: organizer.name } : null,
513
+ attendees: participants
514
+ .filter((one) => !one.organizer)
515
+ .map(({ email, name, response, optional }) => ({ email, name, response, optional })),
516
+ };
517
+ };
518
+ /** The events of a filter as one iCalendar text; a filter that finds more than `EXPORT_MAX` is refused. */
519
+ export const eventsIcs = async (store, accountId, filter, options) => {
520
+ const { rows, hasMore } = filter === null ? { rows: [], hasMore: false } : await page(store, { ...filter, limit: EXPORT_MAX, offset: 0 });
521
+ if (hasMore)
522
+ 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" });
523
+ const events = [];
524
+ for (const row of rows)
525
+ events.push(icsEventOf(await eventOf(store, accountId, row.id)));
526
+ return { format: "ics", events: events.length, content: writeIcs(events, options) };
527
+ };
@@ -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;