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