@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.
- package/CHANGELOG.md +49 -0
- package/dist/calendar/busy.js +1 -1
- package/dist/calendar/cli/answers.d.ts +36 -0
- package/dist/calendar/cli/answers.js +17 -0
- package/dist/calendar/cli/index.d.ts +4 -0
- package/dist/calendar/cli/index.js +77 -12
- package/dist/calendar/ics.d.ts +55 -0
- package/dist/calendar/ics.js +314 -0
- package/dist/calendar/index.d.ts +1 -0
- package/dist/calendar/index.js +1 -0
- package/dist/calendar/mcp/index.js +2 -1
- package/dist/calendar/prepare.d.ts +2 -0
- package/dist/calendar/reads.d.ts +17 -4
- package/dist/calendar/reads.js +116 -27
- package/dist/calendar/testing/memory.js +5 -0
- package/dist/calendar/testing/writer.d.ts +1 -1
- package/dist/calendar/testing/writer.js +40 -0
- package/dist/calendar/writer.d.ts +15 -1
- package/dist/calendar/writer.js +2 -0
- package/dist/calendar/writes.d.ts +46 -0
- package/dist/calendar/writes.js +98 -0
- package/dist/cli/bot/mcp.js +2 -2
- package/dist/cli/effects.d.ts +14 -0
- package/dist/cli/effects.js +33 -2
- package/dist/cli/envelope.d.ts +4 -0
- package/dist/cli/index.d.ts +2 -2
- package/dist/cli/index.js +2 -2
- package/dist/cli/list-query.d.ts +8 -3
- package/dist/cli/list-query.js +21 -15
- package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
- package/dist/errors/reasons.js +7 -0
- package/dist/mcp/agent.js +12 -3
- package/dist/mcp/app-server.js +2 -2
- package/dist/mcp/server.js +2 -2
- package/dist/meetings/event-store.d.ts +6 -0
- package/dist/meetings/testing/event-store-contract.js +15 -0
- package/dist/store/sqlite/calendar-events.js +15 -0
- 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
|
+
};
|
package/dist/calendar/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/calendar/index.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/calendar/reads.d.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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:
|
|
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
|
+
}>;
|
package/dist/calendar/reads.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
112
|
-
"or ends_at: events that end after the start of today, in your time zone.\noverlapping:true keeps timed
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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;
|