@wirecat/cli-core 0.454.0 → 0.456.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/cli/index.d.ts +24 -0
- package/dist/calendar/cli/index.js +173 -0
- package/dist/calendar/index.d.ts +3 -0
- package/dist/calendar/index.js +1 -0
- package/dist/calendar/mcp/index.d.ts +307 -0
- package/dist/calendar/mcp/index.js +132 -0
- package/dist/calendar/reads.d.ts +149 -0
- package/dist/calendar/reads.js +214 -0
- package/dist/calendar/source.d.ts +54 -0
- package/dist/calendar/source.js +1 -0
- package/dist/calendar/sync.d.ts +65 -0
- package/dist/calendar/sync.js +167 -0
- package/dist/calendar/testing/index.d.ts +5 -0
- package/dist/calendar/testing/index.js +5 -0
- package/dist/calendar/testing/memory.d.ts +6 -0
- package/dist/calendar/testing/memory.js +478 -0
- package/dist/calendar/testing/source-contract.d.ts +31 -0
- package/dist/calendar/testing/source-contract.js +171 -0
- package/dist/calendar/testing/source.d.ts +38 -0
- package/dist/calendar/testing/source.js +110 -0
- package/dist/calendar/testing/sync-contract.d.ts +15 -0
- package/dist/calendar/testing/sync-contract.js +211 -0
- package/dist/cli/index.d.ts +2 -1
- package/dist/cli/index.js +2 -1
- package/dist/cli/messenger/ask.js +1 -1
- package/dist/errors/reasons.js +1 -0
- package/dist/meetings/calendar-text.d.ts +1 -1
- package/dist/meetings/calendar-text.js +0 -1
- package/dist/meetings/event-store.d.ts +42 -15
- package/dist/meetings/index.d.ts +1 -1
- package/dist/meetings/testing/event-store-contract.js +83 -4
- package/dist/sends/guard.d.ts +2 -0
- package/dist/sends/guard.js +1 -0
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.js +1 -1
- package/dist/services/people.d.ts +6 -1
- package/dist/services/people.js +14 -11
- package/dist/store/sqlite/calendar.js +68 -31
- package/dist/store/sqlite/manifest.js +2 -0
- package/dist/store/sqlite/migrations.generated.js +17 -0
- package/dist/store/sqlite/note-index.d.ts +1 -1
- package/dist/store/sqlite/note-index.js +6 -1
- package/dist/store/sqlite/schema.d.ts +16 -0
- package/dist/store/sqlite/schema.js +4 -0
- package/package.json +17 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { CliError, errorObjectOf, mcpErrorResult } from "@wirecat/cli-base";
|
|
2
|
+
import { isCliFailure } from "../../errors/failures.js";
|
|
3
|
+
import { listed } from "../../meetings/index.js";
|
|
4
|
+
import { agentResult, UNTRUSTED_NEUTRAL } from "../../meetings/mcp/index.js";
|
|
5
|
+
import { calendarItem, eventAnswer, eventIdOf, eventOf, eventsAnswer, eventsFilter, peopleAnswer, reviewsAnswer, } from "../reads.js";
|
|
6
|
+
/** Fields whose text whoever sent the invitation wrote: an agent reads them as data. */
|
|
7
|
+
export const CALENDAR_UNTRUSTED_FIELDS = ["title", "description", "location", "attendees.name", "candidates.title"];
|
|
8
|
+
const UNTRUSTED_EVENTS = "Event titles, descriptions, places and attendee names are written by whoever sent the invitation: " +
|
|
9
|
+
`${UNTRUSTED_NEUTRAL}`;
|
|
10
|
+
const integer = { type: "integer", minimum: 1 };
|
|
11
|
+
const text = { type: "string", minLength: 1 };
|
|
12
|
+
export const calendarTools = [
|
|
13
|
+
{
|
|
14
|
+
name: "calendars_list",
|
|
15
|
+
description: "the stored calendars and which of them sync",
|
|
16
|
+
properties: { account_id: integer },
|
|
17
|
+
required: [],
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: "events_list",
|
|
21
|
+
description: "stored events, earliest first; `words` takes today, week, from:<date>, to:<date>, calendar:<id> " +
|
|
22
|
+
"(a date in the zone given, UTC when absent)",
|
|
23
|
+
properties: { account_id: integer, words: text, timezone: text, limit: { ...integer, maximum: 1000 } },
|
|
24
|
+
required: [],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "event_show",
|
|
28
|
+
description: "one stored event: its calendar copies, attendees, Drive files and history",
|
|
29
|
+
properties: { event: text, account_id: integer },
|
|
30
|
+
required: ["event"],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "event_people",
|
|
34
|
+
description: "one event's attendees",
|
|
35
|
+
properties: { event: text, account_id: integer },
|
|
36
|
+
required: ["event"],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "event_links_review",
|
|
40
|
+
description: "calendar copies that fit two or more events equally well, waiting for the owner's choice",
|
|
41
|
+
properties: {},
|
|
42
|
+
required: [],
|
|
43
|
+
},
|
|
44
|
+
].map(({ properties, required, description, ...tool }) => ({
|
|
45
|
+
...tool,
|
|
46
|
+
description: `${description}. ${UNTRUSTED_EVENTS}`,
|
|
47
|
+
inputSchema: { type: "object", properties, required, additionalProperties: false },
|
|
48
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
49
|
+
}));
|
|
50
|
+
const argumentsOf = (name, input) => {
|
|
51
|
+
const tool = calendarTools.find((one) => one.name === name);
|
|
52
|
+
if (!tool)
|
|
53
|
+
throw new CliError("validation_error", "unknown calendar tool");
|
|
54
|
+
if (typeof input !== "object" || input === null || Array.isArray(input))
|
|
55
|
+
throw new CliError("validation_error", "expected argument object");
|
|
56
|
+
const args = input;
|
|
57
|
+
for (const key of Object.keys(args))
|
|
58
|
+
if (!Object.hasOwn(tool.inputSchema.properties, key))
|
|
59
|
+
throw new CliError("validation_error", `unknown argument: ${key}`);
|
|
60
|
+
for (const key of tool.inputSchema.required)
|
|
61
|
+
if (!Object.hasOwn(args, key))
|
|
62
|
+
throw new CliError("validation_error", `missing argument: ${key}`);
|
|
63
|
+
for (const [key, value] of Object.entries(args))
|
|
64
|
+
if (["account_id", "limit"].includes(key) &&
|
|
65
|
+
(typeof value !== "number" || !Number.isSafeInteger(value) || value < 1))
|
|
66
|
+
throw new CliError("validation_error", `${key} must be a positive integer`);
|
|
67
|
+
else if (["words", "timezone", "event"].includes(key) && (typeof value !== "string" || !value.trim()))
|
|
68
|
+
throw new CliError("validation_error", `${key} must not be empty`);
|
|
69
|
+
return args;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Runs one calendar tool over the accounts the host serves. Without `account_id` a tool reads every one of them and
|
|
73
|
+
* names them in `accounts`; with one it must be among them.
|
|
74
|
+
*/
|
|
75
|
+
export const callCalendarTool = async (store, name, input, { accountIds, failure = {}, now = Date.now(), }) => {
|
|
76
|
+
try {
|
|
77
|
+
const args = argumentsOf(name, input);
|
|
78
|
+
const asked = args.account_id;
|
|
79
|
+
if (asked !== undefined && !accountIds.includes(asked))
|
|
80
|
+
throw new CliError("not_found", `no calendar account ${asked} here`);
|
|
81
|
+
const accounts = asked === undefined ? [...accountIds] : [asked];
|
|
82
|
+
const event = () => eventIdOf(args.event);
|
|
83
|
+
const eventIn = async () => {
|
|
84
|
+
for (const accountId of accounts)
|
|
85
|
+
try {
|
|
86
|
+
return { accountId, details: await eventOf(store, accountId, event()) };
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (!isCliFailure(error) || error.code !== "not_found")
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
throw new CliError("not_found", `no event ${args.event} in ${asked === undefined ? "these accounts" : "this account"}`);
|
|
93
|
+
};
|
|
94
|
+
let answer;
|
|
95
|
+
switch (name) {
|
|
96
|
+
case "calendars_list":
|
|
97
|
+
answer = listed((await Promise.all(accounts.map((id) => store.calendars(id)))).flat().map(calendarItem));
|
|
98
|
+
break;
|
|
99
|
+
case "events_list": {
|
|
100
|
+
const words = typeof args.words === "string" ? args.words.trim().split(/\s+/u) : [];
|
|
101
|
+
const lists = await Promise.all(accounts.map(async (accountId) => (await eventsAnswer(store, await eventsFilter(words, {
|
|
102
|
+
store,
|
|
103
|
+
accountId,
|
|
104
|
+
zone: args.timezone ?? "UTC",
|
|
105
|
+
now,
|
|
106
|
+
}).then((filter) => filter && { ...filter, limit: args.limit ?? 100 }))).items));
|
|
107
|
+
const items = lists.flat().sort((a, b) => (a.starts_at ?? "").localeCompare(b.starts_at ?? ""));
|
|
108
|
+
answer = listed(items.slice(0, args.limit ?? 100));
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
case "event_show":
|
|
112
|
+
answer = await eventAnswer((await eventIn()).details);
|
|
113
|
+
break;
|
|
114
|
+
case "event_people": {
|
|
115
|
+
const { accountId } = await eventIn();
|
|
116
|
+
answer = await peopleAnswer(store, accountId, { event: event() });
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
default:
|
|
120
|
+
answer = await reviewsAnswer(store);
|
|
121
|
+
}
|
|
122
|
+
const result = { accounts, ...answer, untrusted: CALENDAR_UNTRUSTED_FIELDS };
|
|
123
|
+
return agentResult({
|
|
124
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
125
|
+
structuredContent: result,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
const { content, structuredContent } = agentResult(mcpErrorResult(errorObjectOf(error, { ...failure, effects: { remote: "none", local: "read" } })));
|
|
130
|
+
return { content, structuredContent, isError: true };
|
|
131
|
+
}
|
|
132
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { type EventDetails, type EventFilter, type EventStore, type StoredCalendar } from "../meetings/index.js";
|
|
2
|
+
/** Who looks a person up: the shared store's `people.personOf`, for an attendee's `email` contact. */
|
|
3
|
+
export type PersonOfEmail = (email: string) => Promise<string | null>;
|
|
4
|
+
export declare const calendarItem: (one: StoredCalendar) => {
|
|
5
|
+
id: string;
|
|
6
|
+
external_id: string;
|
|
7
|
+
title: string | null;
|
|
8
|
+
timezone: string | null;
|
|
9
|
+
access_role: import("../meetings/event-store.js").CalendarAccessRole | null;
|
|
10
|
+
primary: boolean;
|
|
11
|
+
synced: boolean;
|
|
12
|
+
seen_at: string | null;
|
|
13
|
+
};
|
|
14
|
+
/** A stored calendar by its id or the provider's id, among the account's calendars. */
|
|
15
|
+
export declare const calendarIn: (store: EventStore, accountId: number, reference: string) => Promise<StoredCalendar>;
|
|
16
|
+
export declare const eventIdOf: (reference: string | number) => number;
|
|
17
|
+
export declare const EVENTS_QUERY_HELP = "\nWords, any order:\n today | week today, or today and the next six days, in your time zone\n from:<date> to:<date> from the start of one day through the end of another (or a time with a zone)\n person:<email|id> events one person attends: an address, or a person id from search\n calendar:<id> one calendar, by its id or the provider's id\nWithout a time word: events from the start of today on.";
|
|
18
|
+
/**
|
|
19
|
+
* `events list` words into a store filter, for one account. A person named by address is found through the shared
|
|
20
|
+
* store; one nobody knows matches no event.
|
|
21
|
+
*/
|
|
22
|
+
export declare const eventsFilter: (words: readonly string[], context: {
|
|
23
|
+
store: EventStore;
|
|
24
|
+
accountId: number;
|
|
25
|
+
zone: string;
|
|
26
|
+
now: number;
|
|
27
|
+
personOf?: (reference: string) => Promise<number | null>;
|
|
28
|
+
}) => Promise<EventFilter | null>;
|
|
29
|
+
/** An event the account holds, with only its copies, or `not_found`: another account's event is not shown. */
|
|
30
|
+
export declare const eventOf: (store: EventStore, accountId: number, id: number) => Promise<EventDetails>;
|
|
31
|
+
export declare const eventAnswer: (details: EventDetails, personOf?: PersonOfEmail) => Promise<{
|
|
32
|
+
id: string;
|
|
33
|
+
event_series_id: string | null;
|
|
34
|
+
title: string | null;
|
|
35
|
+
description: string | null;
|
|
36
|
+
location: string | null;
|
|
37
|
+
starts_at: string | null;
|
|
38
|
+
ends_at: string | null;
|
|
39
|
+
timezone: string | null;
|
|
40
|
+
origin: "auto" | "calendar" | "owner";
|
|
41
|
+
status: string | null;
|
|
42
|
+
all_day: boolean;
|
|
43
|
+
source_deleted_at: string | null;
|
|
44
|
+
deleted_at: string | null;
|
|
45
|
+
created_at: string;
|
|
46
|
+
updated_at: string;
|
|
47
|
+
ref: string;
|
|
48
|
+
entries: {
|
|
49
|
+
id: string;
|
|
50
|
+
calendar_id: string;
|
|
51
|
+
external_id: string;
|
|
52
|
+
kind: import("../meetings/event-store.js").EntryKind;
|
|
53
|
+
status: import("../meetings/event-store.js").EntryStatus;
|
|
54
|
+
event_type: import("../meetings/event-store.js").EntryEventType | null;
|
|
55
|
+
own_response: import("../meetings/event-store.js").ParticipantResponse | null;
|
|
56
|
+
starts_at: string | null;
|
|
57
|
+
ends_at: string | null;
|
|
58
|
+
join_url: string | null;
|
|
59
|
+
url: string | null;
|
|
60
|
+
deleted_at: string | null;
|
|
61
|
+
}[];
|
|
62
|
+
attendees: {
|
|
63
|
+
email: string;
|
|
64
|
+
name: string | null;
|
|
65
|
+
response: import("../meetings/event-store.js").ParticipantResponse | null;
|
|
66
|
+
organizer: boolean;
|
|
67
|
+
optional: boolean;
|
|
68
|
+
resource: boolean;
|
|
69
|
+
self: boolean;
|
|
70
|
+
person: string | null;
|
|
71
|
+
}[];
|
|
72
|
+
attachments: {
|
|
73
|
+
file_id: string;
|
|
74
|
+
title: string | null;
|
|
75
|
+
document: string | null;
|
|
76
|
+
}[];
|
|
77
|
+
history: {
|
|
78
|
+
title: string | null;
|
|
79
|
+
starts_at: string | null;
|
|
80
|
+
ends_at: string | null;
|
|
81
|
+
location: string | null;
|
|
82
|
+
status: string | null;
|
|
83
|
+
changed_at: string;
|
|
84
|
+
}[];
|
|
85
|
+
}>;
|
|
86
|
+
/** One event's attendees, or everyone attending the events of the filter, with how many of them each attends. */
|
|
87
|
+
export declare const peopleAnswer: (store: EventStore, accountId: number, options: {
|
|
88
|
+
event?: number;
|
|
89
|
+
filter?: EventFilter;
|
|
90
|
+
personOf?: PersonOfEmail;
|
|
91
|
+
}) => Promise<{
|
|
92
|
+
items: {
|
|
93
|
+
email: string;
|
|
94
|
+
name: string | null;
|
|
95
|
+
response: import("../meetings/event-store.js").ParticipantResponse | null;
|
|
96
|
+
organizer: boolean;
|
|
97
|
+
optional: boolean;
|
|
98
|
+
resource: boolean;
|
|
99
|
+
self: boolean;
|
|
100
|
+
person: string | null;
|
|
101
|
+
}[];
|
|
102
|
+
page: number;
|
|
103
|
+
limit: number;
|
|
104
|
+
hasMore: boolean;
|
|
105
|
+
}>;
|
|
106
|
+
/** Open link choices, with each candidate event's title and time so the owner can pick. */
|
|
107
|
+
export declare const reviewsAnswer: (store: EventStore) => Promise<{
|
|
108
|
+
items: {
|
|
109
|
+
id: string;
|
|
110
|
+
entry_id: string;
|
|
111
|
+
reason: string;
|
|
112
|
+
candidates: {
|
|
113
|
+
ref: string;
|
|
114
|
+
title: string | null;
|
|
115
|
+
starts_at: string | null;
|
|
116
|
+
}[];
|
|
117
|
+
}[];
|
|
118
|
+
page: number;
|
|
119
|
+
limit: number;
|
|
120
|
+
hasMore: boolean;
|
|
121
|
+
accounts: string;
|
|
122
|
+
}>;
|
|
123
|
+
export declare const resolveAnswer: (store: EventStore, review: string, choice: string, now: number) => Promise<{
|
|
124
|
+
id: string;
|
|
125
|
+
status: "apart" | "linked" | "open";
|
|
126
|
+
chosen: string | null;
|
|
127
|
+
}>;
|
|
128
|
+
export declare const eventsAnswer: (store: EventStore, filter: EventFilter | null) => Promise<{
|
|
129
|
+
items: {
|
|
130
|
+
id: string;
|
|
131
|
+
event_series_id: string | null;
|
|
132
|
+
title: string | null;
|
|
133
|
+
description: string | null;
|
|
134
|
+
location: string | null;
|
|
135
|
+
starts_at: string | null;
|
|
136
|
+
ends_at: string | null;
|
|
137
|
+
timezone: string | null;
|
|
138
|
+
origin: "auto" | "calendar" | "owner";
|
|
139
|
+
status: string | null;
|
|
140
|
+
all_day: boolean;
|
|
141
|
+
source_deleted_at: string | null;
|
|
142
|
+
deleted_at: string | null;
|
|
143
|
+
created_at: string;
|
|
144
|
+
updated_at: string;
|
|
145
|
+
}[];
|
|
146
|
+
page: number;
|
|
147
|
+
limit: number;
|
|
148
|
+
hasMore: boolean;
|
|
149
|
+
}>;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { dayBoundary, zonedMoment } from "@wirecat/cli-base/query";
|
|
3
|
+
import { eventItem, listed, } from "../meetings/index.js";
|
|
4
|
+
const iso = (at) => (at === null ? null : new Date(at).toISOString());
|
|
5
|
+
export const calendarItem = (one) => ({
|
|
6
|
+
id: String(one.id),
|
|
7
|
+
external_id: one.externalId,
|
|
8
|
+
title: one.title,
|
|
9
|
+
timezone: one.timezone,
|
|
10
|
+
access_role: one.accessRole,
|
|
11
|
+
primary: one.primary,
|
|
12
|
+
synced: one.synced,
|
|
13
|
+
seen_at: iso(one.seenAt),
|
|
14
|
+
});
|
|
15
|
+
/** A stored calendar by its id or the provider's id, among the account's calendars. */
|
|
16
|
+
export const calendarIn = async (store, accountId, reference) => {
|
|
17
|
+
const calendars = await store.calendars(accountId);
|
|
18
|
+
const found = calendars.find((one) => String(one.id) === reference) ?? calendars.find((one) => one.externalId === reference);
|
|
19
|
+
if (!found)
|
|
20
|
+
throw new CliError("not_found", `no calendar ${reference} in this account; calendars list shows them`);
|
|
21
|
+
return found;
|
|
22
|
+
};
|
|
23
|
+
export const eventIdOf = (reference) => {
|
|
24
|
+
const text = String(reference).replace(/^event:/u, "");
|
|
25
|
+
if (!/^[1-9]\d*$/u.test(text) || !Number.isSafeInteger(Number(text)))
|
|
26
|
+
throw new CliError("validation_error", "an event is its id or event:<id>");
|
|
27
|
+
return Number(text);
|
|
28
|
+
};
|
|
29
|
+
const localDate = (at, zone) => new Intl.DateTimeFormat("en-CA", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit" }).format(at);
|
|
30
|
+
const plusDays = (date, days) => {
|
|
31
|
+
const [year, month, day] = date.split("-").map(Number);
|
|
32
|
+
return new Date(Date.UTC(year, month - 1, day + days)).toISOString().slice(0, 10);
|
|
33
|
+
};
|
|
34
|
+
export const EVENTS_QUERY_HELP = `
|
|
35
|
+
Words, any order:
|
|
36
|
+
today | week today, or today and the next six days, in your time zone
|
|
37
|
+
from:<date> to:<date> from the start of one day through the end of another (or a time with a zone)
|
|
38
|
+
person:<email|id> events one person attends: an address, or a person id from search
|
|
39
|
+
calendar:<id> one calendar, by its id or the provider's id
|
|
40
|
+
Without a time word: events from the start of today on.`;
|
|
41
|
+
/**
|
|
42
|
+
* `events list` words into a store filter, for one account. A person named by address is found through the shared
|
|
43
|
+
* store; one nobody knows matches no event.
|
|
44
|
+
*/
|
|
45
|
+
export const eventsFilter = async (words, context) => {
|
|
46
|
+
const filter = { accountId: context.accountId };
|
|
47
|
+
const today = localDate(context.now, context.zone);
|
|
48
|
+
const span = (text) => ({ start: 0, end: text.length });
|
|
49
|
+
const day = (date) => dayBoundary(date, context.zone, false, span(date));
|
|
50
|
+
let timed = false;
|
|
51
|
+
for (const word of words) {
|
|
52
|
+
const [, field, value = ""] = /^([a-z]+):(.+)$/u.exec(word) ?? [];
|
|
53
|
+
if (word === "today" || word === "week") {
|
|
54
|
+
filter.from = day(today);
|
|
55
|
+
filter.to = day(plusDays(today, word === "today" ? 1 : 7));
|
|
56
|
+
timed = true;
|
|
57
|
+
}
|
|
58
|
+
else if (field === "from") {
|
|
59
|
+
filter.from = zonedMoment(value, context.zone, span(value));
|
|
60
|
+
timed = true;
|
|
61
|
+
}
|
|
62
|
+
else if (field === "to") {
|
|
63
|
+
filter.to = zonedMoment(value, context.zone, span(value), { end: true }) + 1;
|
|
64
|
+
timed = true;
|
|
65
|
+
}
|
|
66
|
+
else if (field === "calendar")
|
|
67
|
+
filter.calendarId = (await calendarIn(context.store, context.accountId, value)).id;
|
|
68
|
+
else if (field === "person") {
|
|
69
|
+
if (!context.personOf)
|
|
70
|
+
throw new CliError("configuration_error", "this tool cannot look people up");
|
|
71
|
+
const person = await context.personOf(value);
|
|
72
|
+
if (person === null)
|
|
73
|
+
return null;
|
|
74
|
+
filter.personId = person;
|
|
75
|
+
}
|
|
76
|
+
else
|
|
77
|
+
throw new CliError("validation_error", `${word} is not an events list word${EVENTS_QUERY_HELP}`, {
|
|
78
|
+
reason: "query.unknown_word",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if (!timed)
|
|
82
|
+
filter.from = day(today);
|
|
83
|
+
return filter;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Only the account's own copies of an event, with their attendees, files and history. Rule 3 joins copies of one
|
|
87
|
+
* meeting across accounts into one event; the event's own fields (title, time) are shared, its copies are not.
|
|
88
|
+
*/
|
|
89
|
+
const ownCopies = (details, calendars) => {
|
|
90
|
+
const entries = details.entries.filter(({ calendarId }) => calendars.has(calendarId));
|
|
91
|
+
const ids = new Set(entries.map(({ id }) => id));
|
|
92
|
+
return {
|
|
93
|
+
event: details.event,
|
|
94
|
+
entries,
|
|
95
|
+
participants: details.participants.filter(({ entryId }) => ids.has(entryId)),
|
|
96
|
+
revisions: details.revisions.filter(({ entryId }) => ids.has(entryId)),
|
|
97
|
+
attachments: details.attachments.filter(({ entryId }) => ids.has(entryId)),
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
const calendarIds = async (store, accountId) => new Set((await store.calendars(accountId)).map(({ id }) => id));
|
|
101
|
+
/** An event the account holds, with only its copies, or `not_found`: another account's event is not shown. */
|
|
102
|
+
export const eventOf = async (store, accountId, id) => {
|
|
103
|
+
const details = await store.event(id);
|
|
104
|
+
const own = details && ownCopies(details, await calendarIds(store, accountId));
|
|
105
|
+
if (!own?.entries.length)
|
|
106
|
+
throw new CliError("not_found", `no event ${id} in this account`);
|
|
107
|
+
return own;
|
|
108
|
+
};
|
|
109
|
+
const attendees = async (participants, personOf) => {
|
|
110
|
+
const byEmail = new Map();
|
|
111
|
+
for (const one of participants)
|
|
112
|
+
if (!byEmail.has(one.email))
|
|
113
|
+
byEmail.set(one.email, one);
|
|
114
|
+
return Promise.all([...byEmail.values()].map(async (one) => ({
|
|
115
|
+
email: one.email,
|
|
116
|
+
name: one.name,
|
|
117
|
+
response: one.response,
|
|
118
|
+
organizer: one.organizer,
|
|
119
|
+
optional: one.optional,
|
|
120
|
+
resource: one.resource,
|
|
121
|
+
self: one.self,
|
|
122
|
+
person: one.resource || !personOf ? null : await personOf(one.email),
|
|
123
|
+
})));
|
|
124
|
+
};
|
|
125
|
+
export const eventAnswer = async (details, personOf) => ({
|
|
126
|
+
...eventItem(details.event),
|
|
127
|
+
ref: `event:${details.event.id}`,
|
|
128
|
+
entries: details.entries.map((entry) => ({
|
|
129
|
+
id: String(entry.id),
|
|
130
|
+
calendar_id: String(entry.calendarId),
|
|
131
|
+
external_id: entry.externalId,
|
|
132
|
+
kind: entry.kind,
|
|
133
|
+
status: entry.status,
|
|
134
|
+
event_type: entry.eventType,
|
|
135
|
+
own_response: entry.ownResponse,
|
|
136
|
+
starts_at: iso(entry.startsAt),
|
|
137
|
+
ends_at: iso(entry.endsAt),
|
|
138
|
+
join_url: entry.joinUrl,
|
|
139
|
+
url: entry.url,
|
|
140
|
+
deleted_at: iso(entry.deletedAt),
|
|
141
|
+
})),
|
|
142
|
+
attendees: await attendees(details.participants, personOf),
|
|
143
|
+
attachments: details.attachments.map(({ fileId, title, documentId }) => ({
|
|
144
|
+
file_id: fileId,
|
|
145
|
+
title,
|
|
146
|
+
document: documentId === null ? null : `doc:${documentId}`,
|
|
147
|
+
})),
|
|
148
|
+
history: details.revisions.map((one) => ({
|
|
149
|
+
title: one.title,
|
|
150
|
+
starts_at: iso(one.startsAt),
|
|
151
|
+
ends_at: iso(one.endsAt),
|
|
152
|
+
location: one.location,
|
|
153
|
+
status: one.status,
|
|
154
|
+
changed_at: new Date(one.createdAt).toISOString(),
|
|
155
|
+
})),
|
|
156
|
+
});
|
|
157
|
+
/** One event's attendees, or everyone attending the events of the filter, with how many of them each attends. */
|
|
158
|
+
export const peopleAnswer = async (store, accountId, options) => {
|
|
159
|
+
if (options.event !== undefined)
|
|
160
|
+
return listed(await attendees((await eventOf(store, accountId, options.event)).participants, options.personOf));
|
|
161
|
+
const counted = new Map();
|
|
162
|
+
const mine = await calendarIds(store, accountId);
|
|
163
|
+
for (const event of await store.events({ ...options.filter, accountId, limit: 1000 })) {
|
|
164
|
+
const details = await store.event(event.id);
|
|
165
|
+
const seen = new Set();
|
|
166
|
+
for (const one of details ? ownCopies(details, mine).participants : []) {
|
|
167
|
+
if (one.resource || one.self || seen.has(one.email))
|
|
168
|
+
continue;
|
|
169
|
+
seen.add(one.email);
|
|
170
|
+
const held = counted.get(one.email);
|
|
171
|
+
if (held)
|
|
172
|
+
held.events++;
|
|
173
|
+
else
|
|
174
|
+
counted.set(one.email, { participant: one, events: 1 });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const people = await attendees([...counted.values()].map(({ participant }) => participant), options.personOf);
|
|
178
|
+
return listed(people
|
|
179
|
+
.map((one) => ({ ...one, events: counted.get(one.email)?.events ?? 0 }))
|
|
180
|
+
.sort((a, b) => b.events - a.events || a.email.localeCompare(b.email)));
|
|
181
|
+
};
|
|
182
|
+
/** Open link choices, with each candidate event's title and time so the owner can pick. */
|
|
183
|
+
export const reviewsAnswer = async (store) => {
|
|
184
|
+
const open = (await store.reviews({ status: "open" })).filter(({ linkableType }) => linkableType === "calendar_entry");
|
|
185
|
+
return {
|
|
186
|
+
...listed(await Promise.all(open.map(async (review) => ({
|
|
187
|
+
id: String(review.id),
|
|
188
|
+
entry_id: String(review.linkableId),
|
|
189
|
+
reason: review.reason,
|
|
190
|
+
candidates: await Promise.all(review.candidates.map(async (id) => {
|
|
191
|
+
const event = review.entityType === "event" ? (await store.event(id))?.event : undefined;
|
|
192
|
+
return {
|
|
193
|
+
ref: `event:${id}`,
|
|
194
|
+
title: event?.title ?? null,
|
|
195
|
+
starts_at: iso(event?.startsAt ?? null),
|
|
196
|
+
};
|
|
197
|
+
})),
|
|
198
|
+
})))),
|
|
199
|
+
// Link choices are kept per store, not per account, so every calendar account's are listed.
|
|
200
|
+
accounts: "all",
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
export const resolveAnswer = async (store, review, choice, now) => {
|
|
204
|
+
if (!/^[1-9]\d*$/u.test(review))
|
|
205
|
+
throw new CliError("validation_error", "a review is its id from links review");
|
|
206
|
+
const decision = choice === "apart" ? { apart: true } : { choose: eventIdOf(choice) };
|
|
207
|
+
const resolved = await store.resolveReview(Number(review), decision, now);
|
|
208
|
+
return {
|
|
209
|
+
id: String(resolved.id),
|
|
210
|
+
status: resolved.status,
|
|
211
|
+
chosen: resolved.chosenId === null ? null : `event:${resolved.chosenId}`,
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
export const eventsAnswer = async (store, filter) => listed(filter === null ? [] : (await store.events(filter)).map(eventItem));
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { CalendarInput, EntryInput, StoredCalendar } from "../meetings/index.js";
|
|
2
|
+
/** Epoch milliseconds; `to` exclusive. */
|
|
3
|
+
export interface SyncWindow {
|
|
4
|
+
from: number;
|
|
5
|
+
to: number;
|
|
6
|
+
}
|
|
7
|
+
export interface CalendarSourceTraits {
|
|
8
|
+
/**
|
|
9
|
+
* `on-request`: the engine asks `occurrences()` for each series (Google `events.instances`).
|
|
10
|
+
* `in-changes`: occurrences arrive in `changes()` (Graph `calendarView/delta`); no extra calls.
|
|
11
|
+
* `local`: the engine expands the RRULE lines with the expander it was given (ICS, CalDAV).
|
|
12
|
+
*/
|
|
13
|
+
expands: "on-request" | "in-changes" | "local";
|
|
14
|
+
/** The cursor encodes the window (Graph delta): a new window means a new full round. */
|
|
15
|
+
cursorHoldsWindow: boolean;
|
|
16
|
+
/** No incremental cursor: every run is a full list (an ICS feed). */
|
|
17
|
+
fullListOnly: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface ChangePage {
|
|
20
|
+
entries: EntryInput[];
|
|
21
|
+
/**
|
|
22
|
+
* Entries the provider confirmed deleted, known only by `externalId` (CalDAV 404; Graph `@removed` that a GET by id
|
|
23
|
+
* answered 404). Never an entry that only left the window.
|
|
24
|
+
*/
|
|
25
|
+
removed: string[];
|
|
26
|
+
/** More pages in this round. */
|
|
27
|
+
nextPage?: string;
|
|
28
|
+
/** On the last page: the cursor for the next run; `null` when the source has none. */
|
|
29
|
+
cursor?: string | null;
|
|
30
|
+
}
|
|
31
|
+
/** `reset`: the cursor is dead (Google 410, Graph `syncStateNotFound`, CalDAV `valid-sync-token`). */
|
|
32
|
+
export type ChangeResult = ChangePage | {
|
|
33
|
+
reset: true;
|
|
34
|
+
};
|
|
35
|
+
/** One provider's calendars, as an adapter reads them. The engine never sees a provider word. */
|
|
36
|
+
export interface CalendarSource {
|
|
37
|
+
/** The registry name: `google-calendar`, `outlook-calendar`, `caldav`, `ics`. */
|
|
38
|
+
readonly provider: string;
|
|
39
|
+
readonly traits: CalendarSourceTraits;
|
|
40
|
+
calendars(): Promise<CalendarInput[]>;
|
|
41
|
+
/** `cursor: null` asks for a full list of the window. */
|
|
42
|
+
changes(calendar: StoredCalendar, request: {
|
|
43
|
+
cursor: string | null;
|
|
44
|
+
page?: string;
|
|
45
|
+
window: SyncWindow;
|
|
46
|
+
}): Promise<ChangeResult>;
|
|
47
|
+
/** Required when `traits.expands` is `on-request`: the series' slots inside the window. */
|
|
48
|
+
occurrences?(calendar: StoredCalendar, seriesExternalId: string, window: SyncWindow): Promise<EntryInput[]>;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Core expansion for `expands: "local"`: a series' slots inside the window, as occurrences. A slot its EXDATE lines
|
|
52
|
+
* remove is returned `cancelled`, so a stored slot is cancelled rather than left behind.
|
|
53
|
+
*/
|
|
54
|
+
export type CalendarExpander = (series: EntryInput, window: SyncWindow) => EntryInput[] | Promise<EntryInput[]>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { EventStore } from "../meetings/index.js";
|
|
2
|
+
import type { CalendarExpander, CalendarSource, SyncWindow } from "./source.js";
|
|
3
|
+
/** What the engine keeps in `EventStore.cursor`: the provider's token, the window it covers, the last full round. */
|
|
4
|
+
export interface CalendarCursor {
|
|
5
|
+
v: 1;
|
|
6
|
+
token: string | null;
|
|
7
|
+
window: SyncWindow;
|
|
8
|
+
fullAt: number;
|
|
9
|
+
}
|
|
10
|
+
export declare const parseCalendarCursor: (value: string | null) => CalendarCursor | null;
|
|
11
|
+
/**
|
|
12
|
+
* One year back and three months ahead (NEED-1706), from the start of today in UTC. Whole days, so the window moves
|
|
13
|
+
* once a day: a source whose cursor holds the window lists everything again once a day, not on every run.
|
|
14
|
+
*/
|
|
15
|
+
export declare const calendarSyncWindow: (now: number) => SyncWindow;
|
|
16
|
+
export interface CalendarSyncOptions {
|
|
17
|
+
/** The calendar account; never a default. */
|
|
18
|
+
accountId: number;
|
|
19
|
+
now?: number;
|
|
20
|
+
/** List every synced calendar's window again instead of reading changes. */
|
|
21
|
+
full?: boolean;
|
|
22
|
+
/** Instead of `calendarSyncWindow(now)`. */
|
|
23
|
+
window?: SyncWindow;
|
|
24
|
+
/** Required for a source with `expands: "local"`. */
|
|
25
|
+
expand?: CalendarExpander;
|
|
26
|
+
}
|
|
27
|
+
export interface CalendarSyncResult {
|
|
28
|
+
calendarId: number;
|
|
29
|
+
externalId: string;
|
|
30
|
+
title: string | null;
|
|
31
|
+
/** `full`: the whole window was listed and entries it did not return were marked; `changes`: only what changed. */
|
|
32
|
+
round: "full" | "changes";
|
|
33
|
+
/** The cursor was dead, so the round became a full one. */
|
|
34
|
+
reset: boolean;
|
|
35
|
+
saved: number;
|
|
36
|
+
removed: number;
|
|
37
|
+
occurrences: number;
|
|
38
|
+
/** Entries a full round did not return, now marked deleted. */
|
|
39
|
+
unseen: number;
|
|
40
|
+
/** Set when the calendar failed; its cursor stays where it was, so the next run retries the same changes. */
|
|
41
|
+
error?: string;
|
|
42
|
+
}
|
|
43
|
+
export interface CalendarSyncReport {
|
|
44
|
+
provider: string;
|
|
45
|
+
accountId: number;
|
|
46
|
+
window: SyncWindow;
|
|
47
|
+
/** Calendars the provider listed. */
|
|
48
|
+
calendars: number;
|
|
49
|
+
synced: CalendarSyncResult[];
|
|
50
|
+
/** Listed calendars the owner does not sync (`calendars sync <id>` turns one on). */
|
|
51
|
+
notSynced: {
|
|
52
|
+
calendarId: number;
|
|
53
|
+
externalId: string;
|
|
54
|
+
title: string | null;
|
|
55
|
+
}[];
|
|
56
|
+
/** Every synced calendar finished. */
|
|
57
|
+
complete: boolean;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Brings one calendar account's synced calendars up to date (foundation §6.2): the calendar list first, then for
|
|
61
|
+
* each synced calendar its changes since the cursor, or a full list of the window, then the slots of the series that
|
|
62
|
+
* changed. Each calendar's cursor is saved last, only when all of it saved; a calendar that fails keeps its old one
|
|
63
|
+
* and the others go on.
|
|
64
|
+
*/
|
|
65
|
+
export declare const syncCalendars: (source: CalendarSource, store: EventStore, options: CalendarSyncOptions) => Promise<CalendarSyncReport>;
|