@wirecat/cli-core 0.537.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/calendar/busy.js +1 -1
  3. package/dist/calendar/cli/answers.d.ts +179 -0
  4. package/dist/calendar/cli/answers.js +45 -1
  5. package/dist/calendar/cli/index.d.ts +4 -0
  6. package/dist/calendar/cli/index.js +109 -10
  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/prepare.d.ts +134 -0
  12. package/dist/calendar/prepare.js +179 -0
  13. package/dist/calendar/reads.d.ts +17 -4
  14. package/dist/calendar/reads.js +115 -27
  15. package/dist/calendar/testing/answers.d.ts +2 -1
  16. package/dist/calendar/testing/answers.js +3 -2
  17. package/dist/calendar/testing/memory.js +5 -0
  18. package/dist/calendar/testing/writer.d.ts +1 -1
  19. package/dist/calendar/testing/writer.js +40 -0
  20. package/dist/calendar/writer.d.ts +15 -1
  21. package/dist/calendar/writer.js +2 -0
  22. package/dist/calendar/writes.d.ts +46 -0
  23. package/dist/calendar/writes.js +98 -0
  24. package/dist/cli/list-query.d.ts +3 -2
  25. package/dist/cli/list-query.js +12 -3
  26. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  27. package/dist/entities/contact-context.d.ts +35 -0
  28. package/dist/entities/contact-context.js +3 -3
  29. package/dist/errors/reasons.d.ts +1 -1
  30. package/dist/errors/reasons.js +13 -0
  31. package/dist/mail/server-search.js +20 -4
  32. package/dist/meetings/event-store.d.ts +6 -0
  33. package/dist/meetings/mcp/index.d.ts +15 -15
  34. package/dist/meetings/testing/event-store-contract.js +15 -0
  35. package/dist/services/person-context.d.ts +6 -1
  36. package/dist/services/person-context.js +9 -3
  37. package/dist/store/sqlite/calendar-events.js +15 -0
  38. package/dist/store/sqlite/evidence-findings.js +4 -2
  39. package/dist/store/sqlite/task-board.js +16 -0
  40. package/dist/store/sqlite/work-tasks.d.ts +4 -1
  41. package/dist/store/sqlite/work-tasks.js +6 -2
  42. package/package.json +1 -1
@@ -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";
@@ -0,0 +1,134 @@
1
+ import type { EventDetails, EventStore } from "../meetings/index.js";
2
+ import { type ContextEvent, type ContextMessage } from "../services/person-context.js";
3
+ import { type PersonMeetingContextItem } from "../services/person-meeting-context.js";
4
+ import type { MessageStore } from "../store/store.js";
5
+ import { type PersonOfEmail, type ReadAccount } from "./reads.js";
6
+ export declare const PREPARE_MESSAGES = 5;
7
+ /** Attendees past this many are listed without their messages: a meeting of fifty would not fit any budget. */
8
+ export declare const PREPARE_PEOPLE = 10;
9
+ export declare const PREPARE_BYTES: number;
10
+ /** The account's next meeting that has not started yet, by the rule of `isMeeting`. */
11
+ export declare const nextMeeting: (store: EventStore, accountId: number, now: number) => Promise<EventDetails>;
12
+ export interface PreparationOptions {
13
+ store: EventStore;
14
+ messages: MessageStore;
15
+ account: ReadAccount;
16
+ personOf: PersonOfEmail;
17
+ now: number;
18
+ /** Messages per person, each list. */
19
+ limit?: number;
20
+ /** ms: no message or meeting older. */
21
+ since?: number;
22
+ }
23
+ /**
24
+ * The stored evidence for one meeting: the event, and for each attendee other than the owner, their linked identities,
25
+ * their latest messages and mail, the events and recorded meetings they shared with the owner. Reads the store only,
26
+ * every account that saw each person (WC-ACCT-9), and writes nothing; the brief is the agent's.
27
+ */
28
+ export declare const eventPreparation: (details: EventDetails, options: PreparationOptions) => Promise<{
29
+ event: {
30
+ id: string;
31
+ event_series_id: string | null;
32
+ title: string | null;
33
+ description: string | null;
34
+ location: string | null;
35
+ starts_at: string | null;
36
+ ends_at: string | null;
37
+ timezone: string | null;
38
+ origin: "auto" | "calendar" | "owner";
39
+ status: string | null;
40
+ all_day: boolean;
41
+ source_deleted_at: string | null;
42
+ deleted_at: string | null;
43
+ created_at: string;
44
+ updated_at: string;
45
+ untrusted?: string[] | undefined;
46
+ change?: "cancelled" | "moved" | "new" | undefined;
47
+ changed_at?: string | null | undefined;
48
+ was?: {
49
+ starts_at: string | null;
50
+ ends_at: string | null;
51
+ } | undefined;
52
+ overlapping?: boolean | undefined;
53
+ overlaps?: string[] | undefined;
54
+ ref: string;
55
+ entries: {
56
+ id: string;
57
+ calendar_id: string;
58
+ external_id: string;
59
+ kind: import("../meetings/event-store.js").EntryKind;
60
+ title: string | null;
61
+ status: import("../meetings/event-store.js").EntryStatus;
62
+ event_type: import("../meetings/event-store.js").EntryEventType | null;
63
+ own_response: import("../meetings/event-store.js").ParticipantResponse | null;
64
+ starts_at: string | null;
65
+ ends_at: string | null;
66
+ join_url: string | null;
67
+ url: string | null;
68
+ deleted_at: string | null;
69
+ }[];
70
+ attendees: {
71
+ email: string;
72
+ name: string | null;
73
+ response: import("../meetings/event-store.js").ParticipantResponse | null;
74
+ organizer: boolean;
75
+ optional: boolean;
76
+ resource: boolean;
77
+ self: boolean;
78
+ person: string | null;
79
+ }[];
80
+ attachments: {
81
+ file_id: string;
82
+ title: string | null;
83
+ document: string | null;
84
+ }[];
85
+ history: {
86
+ title: string | null;
87
+ starts_at: string | null;
88
+ ends_at: string | null;
89
+ location: string | null;
90
+ status: string | null;
91
+ changed_at: string;
92
+ }[];
93
+ };
94
+ people: {
95
+ email: string;
96
+ name: string | null;
97
+ response: string | null;
98
+ organizer: boolean;
99
+ optional: boolean;
100
+ person: string | null;
101
+ read: boolean;
102
+ identities: string[];
103
+ recent: {
104
+ direct: ContextMessage[];
105
+ groups: ContextMessage[];
106
+ };
107
+ pastEvents: ContextEvent[];
108
+ meetings: PersonMeetingContextItem[];
109
+ hasMore: boolean;
110
+ }[];
111
+ limits: {
112
+ people: number;
113
+ messages: number;
114
+ bytes: number;
115
+ };
116
+ untrusted: string[];
117
+ meta: Pick<{
118
+ run_id: string;
119
+ duration_ms: number;
120
+ accounts?: string[] | undefined;
121
+ complete: boolean;
122
+ issues?: {
123
+ code: "authentication_error" | "cancelled" | "configuration_error" | "confirmation_required" | "invalid_response" | "network_error" | "not_found" | "outcome_unknown" | "permission_error" | "provider_error" | "provider_unavailable" | "rate_limited" | "timeout" | "validation_error";
124
+ reason?: string | undefined;
125
+ source: string;
126
+ }[] | undefined;
127
+ truncated?: boolean | undefined;
128
+ timezone?: string | undefined;
129
+ window?: {
130
+ since?: string | undefined;
131
+ until?: string | undefined;
132
+ } | undefined;
133
+ }, "accounts" | "complete" | "issues" | "truncated">;
134
+ }>;
@@ -0,0 +1,179 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { parseMeetingReference } from "../meetings/index.js";
3
+ import { readsOf, recordContext } from "../services/person-context.js";
4
+ import { personMeetingContext } from "../services/person-meeting-context.js";
5
+ import { accountsMeta, CALENDAR_UNTRUSTED_FIELDS, eventAnswer, eventOf, } from "./reads.js";
6
+ export const PREPARE_MESSAGES = 5;
7
+ /** Attendees past this many are listed without their messages: a meeting of fifty would not fit any budget. */
8
+ export const PREPARE_PEOPLE = 10;
9
+ export const PREPARE_BYTES = 128 * 1024;
10
+ const PERSON_BYTES = 24 * 1024;
11
+ const MEETINGS = 2;
12
+ const CUES = 5;
13
+ /** How many upcoming events the next-meeting rule looks at before it gives up. */
14
+ const NEXT_SCAN = 200;
15
+ const NEXT_PAGE = 50;
16
+ const nameOf = ({ provider, account }) => `${provider}:${account}`;
17
+ const size = (value) => Buffer.byteLength(JSON.stringify(value), "utf8");
18
+ /**
19
+ * What makes an event the next meeting: timed, not cancelled, not declined by the owner, an ordinary event (no focus
20
+ * time, out-of-office or working location), and someone other than the owner and the rooms invited.
21
+ */
22
+ const isMeeting = (details) => {
23
+ if (details.event.allDay || details.event.status === "cancelled")
24
+ return false;
25
+ const live = details.entries.filter((entry) => entry.deletedAt === null && entry.status !== "cancelled");
26
+ if (!live.length || live.every((entry) => entry.ownResponse === "declined"))
27
+ return false;
28
+ if (live.every((entry) => entry.eventType !== null && entry.eventType !== "default"))
29
+ return false;
30
+ return details.participants.some((one) => !one.self && !one.resource);
31
+ };
32
+ /** The account's next meeting that has not started yet, by the rule of `isMeeting`. */
33
+ export const nextMeeting = async (store, accountId, now) => {
34
+ for (let offset = 0; offset < NEXT_SCAN; offset += NEXT_PAGE) {
35
+ const page = await store.events({ accountId, startsFrom: now, limit: NEXT_PAGE, offset });
36
+ for (const one of page) {
37
+ if (one.allDay || one.status === "cancelled")
38
+ continue;
39
+ const details = await eventOf(store, accountId, one.id).catch(() => undefined);
40
+ if (details && isMeeting(details))
41
+ return details;
42
+ }
43
+ if (page.length < NEXT_PAGE)
44
+ break;
45
+ }
46
+ throw new CliError("not_found", "no upcoming meeting with other attendees in the store; run sync, or name an event", {
47
+ reason: "calendar.no-upcoming",
48
+ });
49
+ };
50
+ const UNTRUSTED = [
51
+ ...CALENDAR_UNTRUSTED_FIELDS.filter((field) => !["candidates.title", "entry.title", "ended.title"].includes(field)).map((field) => `event.${field}`),
52
+ "people.name",
53
+ ...["direct", "groups"].flatMap((list) => ["chat_title", "sender_name", "text"].map((field) => `people.recent.${list}.${field}`)),
54
+ "people.past_events.title",
55
+ "people.past_events.location",
56
+ "people.meetings.cues.speaker_name",
57
+ "people.meetings.cues.text",
58
+ ];
59
+ /**
60
+ * The stored evidence for one meeting: the event, and for each attendee other than the owner, their linked identities,
61
+ * their latest messages and mail, the events and recorded meetings they shared with the owner. Reads the store only,
62
+ * every account that saw each person (WC-ACCT-9), and writes nothing; the brief is the agent's.
63
+ */
64
+ export const eventPreparation = async (details, options) => {
65
+ const { messages: store, account, personOf, now, since } = options;
66
+ const limit = options.limit ?? PREPARE_MESSAGES;
67
+ const shown = await eventAnswer(details, personOf);
68
+ const others = shown.attendees
69
+ .filter((one) => !one.self && !one.resource)
70
+ .sort((a, b) => Number(b.organizer) - Number(a.organizer));
71
+ const read = new Map([[nameOf(account.key), account.key]]);
72
+ const issues = [];
73
+ const stored = await store.accounts.storedAccounts();
74
+ let left = PREPARE_BYTES - size(shown);
75
+ let truncated = others.length > PREPARE_PEOPLE;
76
+ let pending = 0;
77
+ const people = [];
78
+ for (const [index, attendee] of others.entries()) {
79
+ const base = {
80
+ email: attendee.email,
81
+ name: attendee.name,
82
+ response: attendee.response,
83
+ organizer: attendee.organizer,
84
+ optional: attendee.optional,
85
+ person: attendee.person,
86
+ };
87
+ const record = attendee.person ? await store.people.personOf({ provider: "email", id: attendee.email }) : undefined;
88
+ const nothing = {
89
+ ...base,
90
+ read: false,
91
+ identities: record ? record.identities.map(({ provider, id }) => `${provider}:${id}`) : [],
92
+ recent: { direct: [], groups: [] },
93
+ pastEvents: [],
94
+ meetings: [],
95
+ hasMore: false,
96
+ };
97
+ if (!record || index >= PREPARE_PEOPLE || left < 2048) {
98
+ if (record && index < PREPARE_PEOPLE)
99
+ truncated = true;
100
+ people.push(nothing);
101
+ continue;
102
+ }
103
+ const reads = readsOf(record, account.key);
104
+ for (const { key } of reads)
105
+ read.set(nameOf(key), key);
106
+ const context = await recordContext(store, record, reads, {
107
+ messages: limit,
108
+ bytes: Math.min(left, PERSON_BYTES),
109
+ now,
110
+ ...(since === undefined ? {} : { since }),
111
+ });
112
+ for (const chat of context.notRead)
113
+ issues.push({
114
+ code: "not_found",
115
+ reason: "chat.not-read",
116
+ source: `${chat.provider}:${chat.account}/${chat.chatId}`,
117
+ });
118
+ const meetings = await meetingsWith(store, record.uid, stored, since);
119
+ pending = Math.max(pending, meetings.pending);
120
+ for (const id of meetings.accounts) {
121
+ const one = stored.find((held) => held.id === id);
122
+ if (one)
123
+ read.set(nameOf(one), { provider: one.provider, account: one.account });
124
+ }
125
+ const entry = {
126
+ ...nothing,
127
+ read: true,
128
+ recent: context.recent,
129
+ pastEvents: context.events.recent.filter((one) => one.ref !== shown.ref).slice(0, limit),
130
+ meetings: meetings.items,
131
+ hasMore: context.hasMore || meetings.hasMore,
132
+ };
133
+ left -= size(entry);
134
+ people.push(entry);
135
+ }
136
+ if (pending > 0)
137
+ issues.push({ code: "not_found", reason: "meetings.index-pending", source: "meetings" });
138
+ const calendar = await accountsMeta(options.store, [account], [], now);
139
+ const all = [...(calendar.issues ?? []), ...issues];
140
+ const meta = {
141
+ accounts: [...read.keys()],
142
+ complete: all.length === 0,
143
+ ...(all.length ? { issues: all } : {}),
144
+ ...(truncated || left < 0 ? { truncated: true } : {}),
145
+ };
146
+ return {
147
+ event: shown,
148
+ people,
149
+ limits: { people: PREPARE_PEOPLE, messages: limit, bytes: PREPARE_BYTES },
150
+ untrusted: UNTRUSTED,
151
+ meta,
152
+ };
153
+ };
154
+ /** Their latest recorded meetings in every stored account that saw them; none is not an error. */
155
+ const meetingsWith = async (store, uid, stored, since) => {
156
+ const none = { items: [], hasMore: false, accounts: [], pending: 0 };
157
+ if (!stored.length)
158
+ return none;
159
+ try {
160
+ const found = await personMeetingContext(store, `person:${uid}`, {
161
+ accountIds: stored.slice(0, 1000).map(({ id }) => id),
162
+ meetings: MEETINGS,
163
+ cues: CUES,
164
+ bytes: PERSON_BYTES,
165
+ ...(since === undefined ? {} : { since }),
166
+ });
167
+ return {
168
+ items: found.items,
169
+ hasMore: found.coverage.truncatedBy !== null || found.coverage.scanLimitReached,
170
+ accounts: [...new Set(found.items.map((item) => parseMeetingReference(item.reference).accountId))],
171
+ pending: found.coverage.pending,
172
+ };
173
+ }
174
+ catch (error) {
175
+ if (error instanceof CliError && error.code === "not_found")
176
+ return none;
177
+ throw error;
178
+ }
179
+ };