@wirecat/cli-core 0.526.0 → 0.528.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 +35 -0
- package/dist/calendar/cli/answers.d.ts +1 -0
- package/dist/calendar/cli/index.d.ts +2 -0
- package/dist/calendar/cli/index.js +15 -3
- package/dist/calendar/mcp/index.js +2 -1
- package/dist/calendar/reads.d.ts +27 -2
- package/dist/calendar/reads.js +93 -10
- package/dist/calendar/testing/memory.js +16 -11
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +1 -0
- package/dist/cli/messenger/doctor-command.js +7 -1
- package/dist/entities/index.d.ts +1 -0
- package/dist/errors/reasons.js +3 -0
- package/dist/meetings/event-store.d.ts +5 -2
- package/dist/meetings/read-answers.d.ts +6 -1
- package/dist/meetings/read-answers.js +6 -1
- package/dist/meetings/testing/event-store-contract.d.ts +2 -1
- package/dist/meetings/testing/event-store-contract.js +28 -0
- package/dist/runs/provider-log.d.ts +22 -0
- package/dist/runs/provider-log.js +55 -0
- package/dist/store/sqlite/calendar-events.js +24 -14
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,41 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.528.0 — 01.11.2026
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Every profile keeps its messenger library's warnings, and `doctor` counts the last day's.** A message the
|
|
15
|
+
library could not decrypt used to leave no trace unless the CLI's own log variable was set. `providerLogPathFor(app,
|
|
16
|
+
profile, env)` names `<state>/provider-log/<profile>.jsonl`; `openProviderLog(path)` makes it private (mode 600)
|
|
17
|
+
and keeps it under 1 MB with one older file, so an engine in another process can append to the path it is given;
|
|
18
|
+
`writeProviderWarning(path, kind, message)` appends from Node. A line is `{"time","level","kind"?,"message"}`, the
|
|
19
|
+
shape zerolog writes; `kind` is the adapter's tag. The adapter masks phone numbers, ids of people and text before
|
|
20
|
+
writing. `doctor` answers `provider_log: { path, last_day: { total, kinds } }` and checks the file is private.
|
|
21
|
+
|
|
22
|
+
## 0.527.0 — 01.11.2026
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **`events list --source <accounts>`: events of several calendar accounts as one list.** The shared calendar
|
|
27
|
+
`events list` takes `--source` as `search messages` does (`all`, a provider or its alias, `provider:account`,
|
|
28
|
+
`group:<name>`; WC-ACCT-8) and reads the accounts in one store query (`EventFilter.accountIds`), so a meeting two
|
|
29
|
+
accounts hold is one item and pages stay exact. Every answer names the accounts it read in `meta.accounts`
|
|
30
|
+
(WC-ACCT-9); `meta.complete` is false, with an issue per account, when one has no synced calendar
|
|
31
|
+
(`calendar.unsynced`) or a sync that never finished or is mid-round (`calendar.sync-incomplete`); the command
|
|
32
|
+
declares `partial`, so such an answer exits 0. `CalendarCommandDeps.locked` keeps a process under a profile lock to
|
|
33
|
+
its own account (`profile.locked`).
|
|
34
|
+
- **`overlapping:true` in `events list`: meetings that overlap.** A field of the event entity (`overlapping`, with
|
|
35
|
+
`overlaps` listing the others), filtered in the store with a self-join. Timed events only, not cancelled, not
|
|
36
|
+
declined, not free and not a working-location block; back-to-back events do not overlap. `overlapping:false` keeps
|
|
37
|
+
the rest. Across `--source` it finds a work meeting that clashes with a personal one.
|
|
38
|
+
|
|
39
|
+
### Changed — may break callers
|
|
40
|
+
|
|
41
|
+
- `EventStoreFixture` (the event store contract) takes `otherAccountId`, a second calendar account.
|
|
42
|
+
- `EventFilter.overlapping: false` now keeps the events that do not overlap; before, it filtered nothing.
|
|
43
|
+
- `EventFilter.overlapping` also leaves out working-location blocks.
|
|
44
|
+
|
|
10
45
|
## 0.526.0 — 01.11.2026
|
|
11
46
|
|
|
12
47
|
### Added
|
|
@@ -113,6 +113,7 @@ export declare const calendarEvent: v.StrictObjectSchema<{
|
|
|
113
113
|
readonly starts_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
|
|
114
114
|
readonly ends_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
|
|
115
115
|
}, undefined>, undefined>;
|
|
116
|
+
readonly overlapping: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
|
|
116
117
|
readonly overlaps: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
|
|
117
118
|
readonly ref: v.StringSchema<undefined>;
|
|
118
119
|
readonly entries: v.ArraySchema<v.StrictObjectSchema<{
|
|
@@ -39,5 +39,7 @@ export interface CalendarCommandDeps {
|
|
|
39
39
|
timezone?: string;
|
|
40
40
|
/** The provider's writer and the profile's guard: `events create|update|delete|respond` exist only with it. */
|
|
41
41
|
writes?: CalendarWrites;
|
|
42
|
+
/** A profile lock pins the process (WC-ACCT-5): `events list --source` may then read only the profile's account. */
|
|
43
|
+
locked?: boolean;
|
|
42
44
|
}
|
|
43
45
|
export declare const addCalendarCommands: (program: Command, deps: CalendarCommandDeps) => void;
|
|
@@ -10,7 +10,7 @@ import { lineOf } from "../../cli/steps.js";
|
|
|
10
10
|
import { searchEvents } from "../../services/events-search.js";
|
|
11
11
|
import { linkPeople } from "../../services/people.js";
|
|
12
12
|
import { BUSY_QUERY, BUSY_QUERY_NOTE, busyAnswer, busyQuery, workingHoursOf } from "../busy.js";
|
|
13
|
-
import { CALENDAR_UNTRUSTED_FIELDS, calendarIn, calendarItem, EVENTS_QUERY, EVENTS_QUERY_NOTE, eventAnswer, eventIdOf, eventOf, eventsAnswer, eventsFilter, peopleAnswer, resolveAnswer, reviewsAnswer, weekFilter, } from "../reads.js";
|
|
13
|
+
import { accountsMeta, CALENDAR_UNTRUSTED_FIELDS, calendarIn, calendarItem, EVENTS_QUERY, EVENTS_QUERY_NOTE, eventAnswer, eventIdOf, eventOf, eventsAnswer, eventsFilter, peopleAnswer, resolveAnswer, reviewsAnswer, sourceAccounts, weekFilter, } from "../reads.js";
|
|
14
14
|
import { REPEAT_HELP } from "../recurrence.js";
|
|
15
15
|
import { bothSearchAnswer, serverSearchAnswer } from "../server-search.js";
|
|
16
16
|
import { calendarStatus, syncCalendars } from "../sync.js";
|
|
@@ -201,18 +201,30 @@ const addCalendarCommandsDefinition = (program, deps) => {
|
|
|
201
201
|
}), command);
|
|
202
202
|
});
|
|
203
203
|
const events = annotate(program.command("events").description("the account's stored events"), { effects: GROUP });
|
|
204
|
-
example(withQuery(annotate(events.command("list"), { effects: LOCAL_READ }).description("list stored events, earliest first"), EVENTS_QUERY).addHelpText("after", EVENTS_QUERY_NOTE), "events list starts_at>=2026-11-02 starts_at<2026-11-07 --json", "events list ends_at>=today person:ana@example.com", "events list
|
|
204
|
+
example(withQuery(annotate(events.command("list"), { effects: LOCAL_READ, partial: true }).description("list stored events, earliest first; --source reads several accounts as one list"), EVENTS_QUERY).addHelpText("after", EVENTS_QUERY_NOTE), "events list starts_at>=2026-11-02 starts_at<2026-11-07 --json", "events list ends_at>=today person:ana@example.com", "events list overlapping:true --source all")
|
|
205
205
|
.option("--limit <n>", "events per page, 1–1000", integer, 100)
|
|
206
206
|
.option("--page <n>", "which page, starting at 1", integer, 1)
|
|
207
|
+
.option("--source <accounts>", "calendar accounts held in the store to read as one list: all, a provider, or a list such as " +
|
|
208
|
+
`${program.name()}:work,${program.name()}:personal or group:<name>; the profile's account when absent`)
|
|
207
209
|
.action(async (words, opts, command) => {
|
|
210
|
+
const own = { id: deps.accountId, key: deps.account };
|
|
211
|
+
const { accounts, skipped } = opts.source === undefined
|
|
212
|
+
? { accounts: [own], skipped: [] }
|
|
213
|
+
: await sourceAccounts(messages("--source"), deps.account, opts.source, program.name());
|
|
214
|
+
if (deps.locked && accounts.some(({ id }) => id !== own.id))
|
|
215
|
+
throw new CliError("permission_error", "this process is locked to one profile; --source may name only its own account", { reason: "profile.locked" });
|
|
208
216
|
const filter = await eventsFilter(words, {
|
|
209
217
|
store: deps.store,
|
|
210
218
|
accountId: deps.accountId,
|
|
219
|
+
accountIds: accounts.map(({ id }) => id),
|
|
211
220
|
zone: zone(),
|
|
212
221
|
now: now(),
|
|
213
222
|
personOf: personId,
|
|
214
223
|
});
|
|
215
|
-
print(
|
|
224
|
+
print({
|
|
225
|
+
...eachMarked(await eventsAnswer(deps.store, filter && { ...filter, limit: opts.limit }, opts.page), EVENT_ITEM_UNTRUSTED),
|
|
226
|
+
meta: await accountsMeta(deps.store, accounts, skipped, now()),
|
|
227
|
+
}, command);
|
|
216
228
|
});
|
|
217
229
|
example(annotate(events.command("show"), { effects: LOCAL_READ })
|
|
218
230
|
.description("one event: its calendar copies, attendees with their person, Drive files and history")
|
|
@@ -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
|
-
"calendar:<id>; a date in the zone given,
|
|
33
|
+
"overlapping:true (timed events that clash, each with overlaps), calendar:<id>; a date in the zone given, " +
|
|
34
|
+
"UTC when absent; without starts_at or ends_at, from today on",
|
|
34
35
|
properties: {
|
|
35
36
|
account_id: integer,
|
|
36
37
|
query: text,
|
package/dist/calendar/reads.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type Predicate, type QueryRegistry } from "@wirecat/cli-base/query";
|
|
2
|
+
import type { Issue, Meta } from "@wirecat/cli-base/standards";
|
|
2
3
|
import { type QueryWords } from "../cli/list-query.js";
|
|
3
4
|
import { type EventDetails, type EventFilter, type EventStore, type StoredCalendar, type StoredEntry } from "../meetings/index.js";
|
|
5
|
+
import type { AccountKey, MessageStore } from "../store/store.js";
|
|
4
6
|
/** Who looks a person up: the shared store's `people.personOf`, for an attendee's `email` contact. */
|
|
5
7
|
export type PersonOfEmail = (email: string) => Promise<string | null>;
|
|
6
8
|
/** Fields whose text whoever sent the invitation wrote: an agent reads them as data. */
|
|
@@ -28,12 +30,14 @@ export declare const timeWindow: (node: Predicate, zone: string, now: number) =>
|
|
|
28
30
|
upper?: number;
|
|
29
31
|
};
|
|
30
32
|
/**
|
|
31
|
-
* `events list`'s query into a store filter, for one account. A person named by
|
|
32
|
-
* shared store; one nobody knows matches no event.
|
|
33
|
+
* `events list`'s query into a store filter, for one account or, with `accountIds`, several. A person named by
|
|
34
|
+
* address is found through the shared store; one nobody knows matches no event.
|
|
33
35
|
*/
|
|
34
36
|
export declare const eventsFilter: (query: QueryWords, context: {
|
|
35
37
|
store: EventStore;
|
|
36
38
|
accountId: number;
|
|
39
|
+
/** These accounts in place of `accountId`. */
|
|
40
|
+
accountIds?: readonly number[];
|
|
37
41
|
zone: string;
|
|
38
42
|
now: number;
|
|
39
43
|
personOf?: (reference: string) => Promise<number | null>;
|
|
@@ -80,6 +84,7 @@ export declare const eventAnswer: (details: EventDetails, personOf?: PersonOfEma
|
|
|
80
84
|
starts_at: string | null;
|
|
81
85
|
ends_at: string | null;
|
|
82
86
|
} | undefined;
|
|
87
|
+
overlapping?: boolean | undefined;
|
|
83
88
|
overlaps?: string[] | undefined;
|
|
84
89
|
ref: string;
|
|
85
90
|
entries: {
|
|
@@ -209,7 +214,27 @@ export declare const eventsAnswer: (store: EventStore, filter: EventFilter | nul
|
|
|
209
214
|
starts_at: string | null;
|
|
210
215
|
ends_at: string | null;
|
|
211
216
|
} | undefined;
|
|
217
|
+
overlapping?: boolean | undefined;
|
|
212
218
|
overlaps?: string[] | undefined;
|
|
213
219
|
}[];
|
|
214
220
|
page: number;
|
|
215
221
|
}>;
|
|
222
|
+
/** A calendar account a list reads: the store's id, and its name as `meta.accounts` gives it. */
|
|
223
|
+
export interface ReadAccount {
|
|
224
|
+
id: number;
|
|
225
|
+
key: AccountKey;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* The calendar accounts `--source` names, as `search messages --source` reads them (WC-ACCT-8): `all`, a provider,
|
|
229
|
+
* `provider:account` with an account or a profile, or `group:<name>`. An entry that names no calendar account is in
|
|
230
|
+
* `skipped`; nothing at all is an error.
|
|
231
|
+
*/
|
|
232
|
+
export declare const sourceAccounts: (messages: MessageStore, running: AccountKey, source: string, command: string) => Promise<{
|
|
233
|
+
accounts: ReadAccount[];
|
|
234
|
+
skipped: Issue[];
|
|
235
|
+
}>;
|
|
236
|
+
/**
|
|
237
|
+
* `meta` of a list over calendar accounts (WC-ACCT-9): which it read, and `complete: false` when one has no synced
|
|
238
|
+
* calendar, or a calendar whose sync never finished or is in the middle of a round, so its events may be missing.
|
|
239
|
+
*/
|
|
240
|
+
export declare const accountsMeta: (store: EventStore, accounts: readonly ReadAccount[], skipped: readonly Issue[], now: number) => Promise<Pick<Meta, "accounts" | "complete" | "issues">>;
|
package/dist/calendar/reads.js
CHANGED
|
@@ -2,6 +2,9 @@ import { CliError } from "@wirecat/cli-base";
|
|
|
2
2
|
import { dateRange, dayBoundary, queryError, queryFieldsOf, } from "@wirecat/cli-base/query";
|
|
3
3
|
import { LIST_QUERY_GUIDE, listQuery, nativeTerms, typedQuery } from "../cli/list-query.js";
|
|
4
4
|
import { event, eventChange, eventItem, listed, } from "../meetings/index.js";
|
|
5
|
+
import { entriesOfList, fitting } from "../search/account-list.js";
|
|
6
|
+
import { listContextOf } from "../services/search-kind.js";
|
|
7
|
+
import { calendarStatus } from "./sync.js";
|
|
5
8
|
const iso = (at) => (at === null ? null : new Date(at).toISOString());
|
|
6
9
|
/** Fields whose text whoever sent the invitation wrote: an agent reads them as data. */
|
|
7
10
|
export const CALENDAR_UNTRUSTED_FIELDS = [
|
|
@@ -27,6 +30,22 @@ export const calendarItem = (one) => ({
|
|
|
27
30
|
synced: one.synced,
|
|
28
31
|
seen_at: iso(one.seenAt),
|
|
29
32
|
});
|
|
33
|
+
/** A stored calendar by its id or the provider's id, among the accounts' calendars; one the id names twice is refused. */
|
|
34
|
+
const calendarAmong = async (store, accountIds, reference) => {
|
|
35
|
+
if (accountIds.length === 1)
|
|
36
|
+
return calendarIn(store, accountIds[0], reference);
|
|
37
|
+
const calendars = (await Promise.all(accountIds.map((id) => store.calendars(id)))).flat();
|
|
38
|
+
const byId = calendars.find((one) => String(one.id) === reference);
|
|
39
|
+
if (byId)
|
|
40
|
+
return byId;
|
|
41
|
+
const byExternal = calendars.filter((one) => one.externalId === reference);
|
|
42
|
+
if (byExternal.length > 1)
|
|
43
|
+
throw new CliError("validation_error", `calendar ${reference} is in more than one of these accounts; name it by its id: ` +
|
|
44
|
+
byExternal.map(({ id }) => id).join(", "));
|
|
45
|
+
if (!byExternal[0])
|
|
46
|
+
throw new CliError("not_found", `no calendar ${reference} in these accounts`);
|
|
47
|
+
return byExternal[0];
|
|
48
|
+
};
|
|
30
49
|
/** A stored calendar by its id or the provider's id, among the account's calendars. */
|
|
31
50
|
export const calendarIn = async (store, accountId, reference) => {
|
|
32
51
|
const calendars = await store.calendars(accountId);
|
|
@@ -59,7 +78,7 @@ export const EVENTS_QUERY = {
|
|
|
59
78
|
entity: "event",
|
|
60
79
|
backend: "native",
|
|
61
80
|
fields: queryFieldsOf(event, {
|
|
62
|
-
native: ["starts_at", "ends_at", "changed_at", "calendar", "person"],
|
|
81
|
+
native: ["starts_at", "ends_at", "changed_at", "overlapping", "calendar", "person"],
|
|
63
82
|
relations: {
|
|
64
83
|
calendar: { type: "kw", example: "calendar:3", relation: "one calendar, by its id or the provider's id" },
|
|
65
84
|
person: {
|
|
@@ -72,17 +91,27 @@ export const EVENTS_QUERY = {
|
|
|
72
91
|
starts_at: "starts_at:[2026-11-02 TO 2026-11-07}",
|
|
73
92
|
ends_at: "ends_at>=2026-11-02",
|
|
74
93
|
changed_at: "changed_at>=2026-10-01",
|
|
94
|
+
overlapping: "overlapping:true",
|
|
75
95
|
},
|
|
76
96
|
validate: {
|
|
77
97
|
ends_at: oneSided("ends_at", "ends_at>=2026-11-02; for the latest, write starts_at<2026-11-07"),
|
|
78
98
|
changed_at: oneSided("changed_at", "changed_at>=2026-10-01"),
|
|
79
99
|
},
|
|
80
100
|
}).map((field) => field.native
|
|
81
|
-
? {
|
|
101
|
+
? {
|
|
102
|
+
...field,
|
|
103
|
+
operators: field.type === "time"
|
|
104
|
+
? ["term", "range"]
|
|
105
|
+
: field.type === "bool"
|
|
106
|
+
? ["term"]
|
|
107
|
+
: ["term", "phrase"],
|
|
108
|
+
}
|
|
82
109
|
: field),
|
|
83
110
|
};
|
|
84
111
|
export const EVENTS_QUERY_NOTE = "\nends_at keeps events that end after its moment, so one that ends exactly then is left out. Without starts_at " +
|
|
85
|
-
"or ends_at: events that end after the start of today, in your time zone
|
|
112
|
+
"or ends_at: events that end after the start of today, in your time zone.\noverlapping:true keeps timed events " +
|
|
113
|
+
"that overlap another in the accounts read, each with overlaps: the others. Left out: all-day, cancelled, " +
|
|
114
|
+
"declined and free events and working-location blocks; back-to-back events do not overlap.";
|
|
86
115
|
/** A time term as epoch ms: from `lower` on, and before `upper`. */
|
|
87
116
|
export const timeWindow = (node, zone, now) => {
|
|
88
117
|
const range = node.operator === "range"
|
|
@@ -94,11 +123,12 @@ export const timeWindow = (node, zone, now) => {
|
|
|
94
123
|
};
|
|
95
124
|
};
|
|
96
125
|
/**
|
|
97
|
-
* `events list`'s query into a store filter, for one account. A person named by
|
|
98
|
-
* shared store; one nobody knows matches no event.
|
|
126
|
+
* `events list`'s query into a store filter, for one account or, with `accountIds`, several. A person named by
|
|
127
|
+
* address is found through the shared store; one nobody knows matches no event.
|
|
99
128
|
*/
|
|
100
129
|
export const eventsFilter = async (query, context) => {
|
|
101
|
-
const
|
|
130
|
+
const accounts = context.accountIds ?? [context.accountId];
|
|
131
|
+
const filter = accounts.length === 1 ? { accountId: accounts[0] } : { accountIds: accounts };
|
|
102
132
|
const { zone, now } = context;
|
|
103
133
|
const span = (text) => ({ start: 0, end: text.length });
|
|
104
134
|
const day = (date) => dayBoundary(date, zone, false, span(date));
|
|
@@ -122,8 +152,10 @@ export const eventsFilter = async (query, context) => {
|
|
|
122
152
|
if (lower !== undefined)
|
|
123
153
|
filter.changedSince = lower;
|
|
124
154
|
}
|
|
155
|
+
else if (field === "overlapping")
|
|
156
|
+
filter.overlapping = node.value.toLowerCase() === "true";
|
|
125
157
|
else if (field === "calendar")
|
|
126
|
-
filter.calendarId = (await
|
|
158
|
+
filter.calendarId = (await calendarAmong(context.store, accounts, node.value)).id;
|
|
127
159
|
else if (field === "person") {
|
|
128
160
|
if (!context.personOf)
|
|
129
161
|
throw new CliError("configuration_error", "this tool cannot look people up");
|
|
@@ -172,6 +204,12 @@ const ownCopies = (details, calendars) => {
|
|
|
172
204
|
};
|
|
173
205
|
};
|
|
174
206
|
const calendarIds = async (store, accountId) => new Set((await store.calendars(accountId)).map(({ id }) => id));
|
|
207
|
+
const accountsOf = (filter) => filter.accountIds ?? (filter.accountId === undefined ? [] : [filter.accountId]);
|
|
208
|
+
const scopeOf = (filter) => filter.accountIds !== undefined
|
|
209
|
+
? { accountIds: filter.accountIds }
|
|
210
|
+
: filter.accountId === undefined
|
|
211
|
+
? {}
|
|
212
|
+
: { accountId: filter.accountId };
|
|
175
213
|
/** An event the account holds, with only its copies, or `not_found`: another account's event is not shown. */
|
|
176
214
|
export const eventOf = async (store, accountId, id) => {
|
|
177
215
|
const details = await store.event(id);
|
|
@@ -305,7 +343,7 @@ export const resolveAnswer = async (store, accountId, review, choice, now) => {
|
|
|
305
343
|
};
|
|
306
344
|
/** What changed about each listed event since the filter's instant, from its own copies. */
|
|
307
345
|
const changesOf = async (store, rows, filter, since) => {
|
|
308
|
-
const mine = filter.
|
|
346
|
+
const mine = (await Promise.all(accountsOf(filter).map(async (id) => [...(await calendarIds(store, id))]))).flat();
|
|
309
347
|
const { entries, revisions } = await store.historyOf(rows.map(({ id }) => id), mine, since);
|
|
310
348
|
return new Map(rows.map((event) => [
|
|
311
349
|
event.id,
|
|
@@ -318,7 +356,7 @@ const overlapsOf = async (store, rows, filter) => {
|
|
|
318
356
|
if (!timed.length)
|
|
319
357
|
return new Map();
|
|
320
358
|
const others = await store.events({
|
|
321
|
-
...(filter
|
|
359
|
+
...scopeOf(filter),
|
|
322
360
|
from: Math.min(...timed.map(({ startsAt }) => startsAt)),
|
|
323
361
|
to: Math.max(...timed.map(({ endsAt }) => endsAt)),
|
|
324
362
|
overlapping: true,
|
|
@@ -337,7 +375,7 @@ export const eventsAnswer = async (store, filter, number = 1) => {
|
|
|
337
375
|
return listed([]);
|
|
338
376
|
const { rows, limit, hasMore } = await page(store, { ...filter, offset: (number - 1) * (filter.limit ?? 100) });
|
|
339
377
|
const changes = filter.changedSince === undefined ? null : await changesOf(store, rows, filter, filter.changedSince);
|
|
340
|
-
const overlaps = filter.overlapping ? await overlapsOf(store, rows, filter) : null;
|
|
378
|
+
const overlaps = filter.overlapping === true ? await overlapsOf(store, rows, filter) : null;
|
|
341
379
|
return listed(rows.map((event) => {
|
|
342
380
|
const change = changes?.get(event.id);
|
|
343
381
|
return {
|
|
@@ -349,7 +387,52 @@ export const eventsAnswer = async (store, filter, number = 1) => {
|
|
|
349
387
|
...(change.was ? { was: { starts_at: iso(change.was.startsAt), ends_at: iso(change.was.endsAt) } } : {}),
|
|
350
388
|
}
|
|
351
389
|
: {}),
|
|
390
|
+
...(filter.overlapping === undefined ? {} : { overlapping: filter.overlapping }),
|
|
352
391
|
...(overlaps ? { overlaps: (overlaps.get(event.id) ?? []).map(({ id }) => `event:${id}`) } : {}),
|
|
353
392
|
};
|
|
354
393
|
}), { limit, hasMore, page: number });
|
|
355
394
|
};
|
|
395
|
+
const nameOf = ({ provider, account }) => `${provider}:${account}`;
|
|
396
|
+
/**
|
|
397
|
+
* The calendar accounts `--source` names, as `search messages --source` reads them (WC-ACCT-8): `all`, a provider,
|
|
398
|
+
* `provider:account` with an account or a profile, or `group:<name>`. An entry that names no calendar account is in
|
|
399
|
+
* `skipped`; nothing at all is an error.
|
|
400
|
+
*/
|
|
401
|
+
export const sourceAccounts = async (messages, running, source, command) => {
|
|
402
|
+
const context = await listContextOf(messages, running, undefined);
|
|
403
|
+
const { accounts, skipped } = fitting(entriesOfList(source, { ...context, running: { provider: running.provider, command } }), ["events"], `${command} events list`);
|
|
404
|
+
const stored = await messages.accounts.storedAccounts();
|
|
405
|
+
const read = [];
|
|
406
|
+
const issues = skipped.map(({ code, source }) => ({ code, source }));
|
|
407
|
+
for (const key of accounts) {
|
|
408
|
+
const held = stored.find((one) => one.provider === key.provider && one.account === key.account);
|
|
409
|
+
if (held)
|
|
410
|
+
read.push({ id: held.id, key });
|
|
411
|
+
else
|
|
412
|
+
issues.push({ code: "not_found", reason: "calendar.unsynced", source: nameOf(key) });
|
|
413
|
+
}
|
|
414
|
+
if (read.length === 0)
|
|
415
|
+
throw new CliError("not_found", `--source ${source} names no calendar account this store holds`, {
|
|
416
|
+
reason: "calendar.unsynced",
|
|
417
|
+
});
|
|
418
|
+
return { accounts: read, skipped: issues };
|
|
419
|
+
};
|
|
420
|
+
/**
|
|
421
|
+
* `meta` of a list over calendar accounts (WC-ACCT-9): which it read, and `complete: false` when one has no synced
|
|
422
|
+
* calendar, or a calendar whose sync never finished or is in the middle of a round, so its events may be missing.
|
|
423
|
+
*/
|
|
424
|
+
export const accountsMeta = async (store, accounts, skipped, now) => {
|
|
425
|
+
const issues = [...skipped];
|
|
426
|
+
for (const { id, key } of accounts) {
|
|
427
|
+
const { items } = await calendarStatus(store, id, { profile: nameOf(key), now });
|
|
428
|
+
if (items.length === 0)
|
|
429
|
+
issues.push({ code: "not_found", reason: "calendar.unsynced", source: nameOf(key) });
|
|
430
|
+
else if (items.some(({ state }) => state !== "complete"))
|
|
431
|
+
issues.push({ code: "not_found", reason: "calendar.sync-incomplete", source: nameOf(key) });
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
accounts: accounts.map(({ key }) => nameOf(key)),
|
|
435
|
+
complete: issues.length === 0,
|
|
436
|
+
...(issues.length ? { issues } : {}),
|
|
437
|
+
};
|
|
438
|
+
};
|
|
@@ -453,8 +453,13 @@ export const memoryEventStore = () => {
|
|
|
453
453
|
if (!Number.isInteger(limit) || limit < 1 || limit > 1000 || !Number.isInteger(offset) || offset < 0)
|
|
454
454
|
throw new CliError("validation_error", "limit takes 1–1000 and offset 0 or more");
|
|
455
455
|
const has = (event, check) => entries.some((one) => one.eventId === event.id && check(one));
|
|
456
|
-
|
|
457
|
-
(
|
|
456
|
+
if (filter.accountIds !== undefined && filter.accountId !== undefined)
|
|
457
|
+
throw new CliError("validation_error", "an event filter takes accountId or accountIds, not both");
|
|
458
|
+
if (filter.accountIds?.length === 0)
|
|
459
|
+
throw new CliError("validation_error", "accountIds names no account");
|
|
460
|
+
const accounts = filter.accountIds ?? (filter.accountId === undefined ? undefined : [filter.accountId]);
|
|
461
|
+
const inAccounts = (one) => accounts === undefined || accounts.includes(calendarOf(one.calendarId).accountId);
|
|
462
|
+
const copiesOf = (event) => entries.filter((one) => one.eventId === event.id && inAccounts(one));
|
|
458
463
|
const busy = (event) => event.deletedAt === null &&
|
|
459
464
|
event.status !== "cancelled" &&
|
|
460
465
|
!event.allDay &&
|
|
@@ -463,7 +468,13 @@ export const memoryEventStore = () => {
|
|
|
463
468
|
copiesOf(event).some((one) => one.deletedAt === null &&
|
|
464
469
|
one.status !== "cancelled" &&
|
|
465
470
|
one.ownResponse !== "declined" &&
|
|
466
|
-
one.transparency !== "transparent"
|
|
471
|
+
one.transparency !== "transparent" &&
|
|
472
|
+
one.eventType !== "working-location");
|
|
473
|
+
const clashes = (event) => busy(event) &&
|
|
474
|
+
[...events.values()].some((other) => other.id !== event.id &&
|
|
475
|
+
busy(other) &&
|
|
476
|
+
other.startsAt < event.endsAt &&
|
|
477
|
+
other.endsAt > event.startsAt);
|
|
467
478
|
return [...events.values()]
|
|
468
479
|
.filter((event) => event.deletedAt === null &&
|
|
469
480
|
(filter.from === undefined ||
|
|
@@ -479,18 +490,12 @@ export const memoryEventStore = () => {
|
|
|
479
490
|
? has(event, (one) => one.startDate !== null && one.startDate >= (filter.startsFromDate ?? ""))
|
|
480
491
|
: event.startsAt !== null && event.startsAt >= filter.startsFrom)) &&
|
|
481
492
|
(filter.calendarId === undefined || has(event, (one) => one.calendarId === filter.calendarId)) &&
|
|
482
|
-
(
|
|
483
|
-
has(event, (one) => calendarOf(one.calendarId).accountId === filter.accountId)) &&
|
|
493
|
+
(accounts === undefined || has(event, inAccounts)) &&
|
|
484
494
|
(filter.personId === undefined ||
|
|
485
495
|
has(event, (one) => participants.some((p) => p.entryId === one.id && p.identityId === filter.personId))) &&
|
|
486
496
|
(filter.changedSince === undefined ||
|
|
487
497
|
eventChange(event, copiesOf(event), revisions, filter.changedSince) !== null) &&
|
|
488
|
-
(
|
|
489
|
-
(busy(event) &&
|
|
490
|
-
[...events.values()].some((other) => other.id !== event.id &&
|
|
491
|
-
busy(other) &&
|
|
492
|
-
other.startsAt < event.endsAt &&
|
|
493
|
-
other.endsAt > event.startsAt))))
|
|
498
|
+
(filter.overlapping === undefined || clashes(event) === filter.overlapping))
|
|
494
499
|
.sort((a, b) => ((a.startsAt ?? -1) - (b.startsAt ?? -1) || a.id - b.id) * (filter.newest ? -1 : 1))
|
|
495
500
|
.slice(offset, offset + limit)
|
|
496
501
|
.map((event) => ({ ...event }));
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export { NO_RULES, NO_TASKS, NOT_ALLOWED, ONLY_TASK, type Replied, type Replier,
|
|
|
23
23
|
export { repliesStatePathFor } from "../replies/state.js";
|
|
24
24
|
export { type CacheEvent, type DiagnosticEvent, type EventSink, providerErrorKey, type RequestEvent, renderEvent, type WarningEvent, } from "../runs/events.js";
|
|
25
25
|
export { CORE_LOG_EVENTS } from "../runs/log-events.js";
|
|
26
|
+
export { openProviderLog, PROVIDER_LOG_BYTES, type ProviderWarnings, providerLogPathFor, providerWarnings, writeProviderWarning, } from "../runs/provider-log.js";
|
|
26
27
|
export { crashOf, type Recording, type RecordingOptions, recorded, startRecording, wasSettled, } from "../runs/recording.js";
|
|
27
28
|
export { findRun, KEEP_RUNS_FOR_DAYS, listRuns, pruneRuns, type RunContext, type RunMetadata, readEvents, runsDirFor, runtime, startRun, } from "../runs/run.js";
|
|
28
29
|
export { runContextOf, secretInput } from "../runs/run-context.js";
|
package/dist/cli/index.js
CHANGED
|
@@ -19,6 +19,7 @@ export { NO_RULES, NO_TASKS, NOT_ALLOWED, ONLY_TASK, replyTo } from "../replies/
|
|
|
19
19
|
export { repliesStatePathFor } from "../replies/state.js";
|
|
20
20
|
export { providerErrorKey, renderEvent, } from "../runs/events.js";
|
|
21
21
|
export { CORE_LOG_EVENTS } from "../runs/log-events.js";
|
|
22
|
+
export { openProviderLog, PROVIDER_LOG_BYTES, providerLogPathFor, providerWarnings, writeProviderWarning, } from "../runs/provider-log.js";
|
|
22
23
|
export { crashOf, recorded, startRecording, wasSettled, } from "../runs/recording.js";
|
|
23
24
|
export { findRun, KEEP_RUNS_FOR_DAYS, listRuns, pruneRuns, readEvents, runsDirFor, runtime, startRun, } from "../runs/run.js";
|
|
24
25
|
export { runContextOf, secretInput } from "../runs/run-context.js";
|
|
@@ -5,6 +5,7 @@ import { annotate } from "@wirecat/cli-base/commands";
|
|
|
5
5
|
import { Command } from "commander";
|
|
6
6
|
import { engineStates } from "../../attachments/extract.js";
|
|
7
7
|
import { providerErrorKey } from "../../runs/events.js";
|
|
8
|
+
import { providerLogPathFor, providerWarnings } from "../../runs/provider-log.js";
|
|
8
9
|
import { listRuns, runsDirFor, runtime } from "../../runs/run.js";
|
|
9
10
|
import { FloodMemory, floodPathFor } from "../../sends/flood.js";
|
|
10
11
|
import { SendJournal, sendsPathFor } from "../../sends/journal.js";
|
|
@@ -20,6 +21,7 @@ import { recalledAccount } from "./accounts.js";
|
|
|
20
21
|
import { messengerContext } from "./context.js";
|
|
21
22
|
import { privateFiles, withSqliteSidecars } from "./private-files.js";
|
|
22
23
|
import { buildReport, reportFileName } from "./report.js";
|
|
24
|
+
import { DAY_MS } from "./serve-members.js";
|
|
23
25
|
import { storeSummary } from "./store-maintenance-command.js";
|
|
24
26
|
/**
|
|
25
27
|
* The state this installation is in, read from disk and **never from the messenger unless
|
|
@@ -121,6 +123,7 @@ const rememberedOf = (report) => report.account?.remembered ?? undefined;
|
|
|
121
123
|
* What `doctor` answers, as data. A configuration that will not load is a field, not an exception:
|
|
122
124
|
* then there is no renderer from the settings, and the caller renders with the defaults.
|
|
123
125
|
*/
|
|
126
|
+
const providerLogState = ({ path, total, kinds }) => ({ path, last_day: { total, kinds } });
|
|
124
127
|
const diagnose = async (command, messenger) => {
|
|
125
128
|
const { app, provider } = messenger;
|
|
126
129
|
const env = environmentOf(command).env ?? process.env;
|
|
@@ -183,8 +186,9 @@ const diagnose = async (command, messenger) => {
|
|
|
183
186
|
const store = storePath(env);
|
|
184
187
|
const sends = sendsPathFor(app, profile, env);
|
|
185
188
|
const flood = floodPathFor(app, profile, env);
|
|
189
|
+
const providerLog = providerLogPathFor(app, profile, env);
|
|
186
190
|
const files = privateFiles({
|
|
187
|
-
files: [...own.files, ...withSqliteSidecars(store), sends, flood],
|
|
191
|
+
files: [...own.files, ...withSqliteSidecars(store), sends, flood, providerLog],
|
|
188
192
|
// A folder the owner chose with MESSAGING_STORE may rightly be shared, like the home folder itself.
|
|
189
193
|
dirs: [
|
|
190
194
|
...own.dirs,
|
|
@@ -192,6 +196,7 @@ const diagnose = async (command, messenger) => {
|
|
|
192
196
|
dirname(sends),
|
|
193
197
|
dirname(flood),
|
|
194
198
|
runsDirFor(app, env),
|
|
199
|
+
dirname(providerLog),
|
|
195
200
|
],
|
|
196
201
|
});
|
|
197
202
|
return {
|
|
@@ -209,6 +214,7 @@ const diagnose = async (command, messenger) => {
|
|
|
209
214
|
readers: engineStates(),
|
|
210
215
|
sends: sendsState(new SendJournal(sendsPathFor(app, profile, env))),
|
|
211
216
|
flood: floodState(new FloodMemory(flood)),
|
|
217
|
+
provider_log: providerLogState(providerWarnings(providerLog, new Date(Date.now() - DAY_MS))),
|
|
212
218
|
runs: { directory: runsDirFor(app, env), kept: listRuns(runsDirFor(app, env)).length },
|
|
213
219
|
// The messenger's own block keeps the keys it gives: some of them are file paths.
|
|
214
220
|
files: filesReport(files),
|
package/dist/entities/index.d.ts
CHANGED
|
@@ -6362,6 +6362,7 @@ export declare const ENTITIES: {
|
|
|
6362
6362
|
readonly starts_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
|
|
6363
6363
|
readonly ends_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
|
|
6364
6364
|
}, undefined>, undefined>;
|
|
6365
|
+
readonly overlapping: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
|
|
6365
6366
|
readonly overlaps: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
|
|
6366
6367
|
}, undefined>;
|
|
6367
6368
|
readonly meeting_review: v.StrictObjectSchema<{
|
package/dist/errors/reasons.js
CHANGED
|
@@ -63,6 +63,9 @@ const OTHERS = [
|
|
|
63
63
|
validation("event.repeat-count", "a COUNT series is split only with a rule for its new part"),
|
|
64
64
|
validation("event.unmovable", "the provider keeps this kind of event on its calendar"),
|
|
65
65
|
["calendar.read-only", "permission_error", "the calendar takes no new events from this account"],
|
|
66
|
+
["calendar.unsynced", "not_found", "the calendar account has no synced calendar in the store"],
|
|
67
|
+
["calendar.sync-incomplete", "not_found", "a synced calendar's sync never finished or is mid-round"],
|
|
68
|
+
["profile.locked", "permission_error", "a profile lock pins this process to its own account"],
|
|
66
69
|
["busy.offline", "permission_error", "calendars busy asks the provider; --offline was given"],
|
|
67
70
|
validation("busy.no-calendars", "no calendar syncs and no person was named, so there is nobody to ask"),
|
|
68
71
|
["search.offline", "permission_error", "search --backend server asks the provider; --offline was given"],
|
|
@@ -194,13 +194,16 @@ export interface EventFilter {
|
|
|
194
194
|
/** Events `eventChange` finds new, moved or cancelled at or after this instant. */
|
|
195
195
|
changedSince?: number;
|
|
196
196
|
/**
|
|
197
|
-
*
|
|
198
|
-
* a live copy the owner did not decline or mark
|
|
197
|
+
* `true`: events whose time overlaps another's; `false`: every other event. Only timed events that take the owner's
|
|
198
|
+
* time count: not cancelled, and with a live copy, in the accounts read, that the owner did not decline or mark
|
|
199
|
+
* free and that is no working-location marker. Back-to-back events do not overlap.
|
|
199
200
|
*/
|
|
200
201
|
overlapping?: boolean;
|
|
201
202
|
/** All-day events starting before this local day (exclusive); `to` without it. */
|
|
202
203
|
toDate?: string;
|
|
203
204
|
accountId?: number;
|
|
205
|
+
/** Several accounts' events at once, in place of `accountId`. */
|
|
206
|
+
accountIds?: readonly number[];
|
|
204
207
|
calendarId?: number;
|
|
205
208
|
/** Events whose entries list one of this person's contacts. */
|
|
206
209
|
personId?: number;
|
|
@@ -195,7 +195,12 @@ export declare const event: v.StrictObjectSchema<{
|
|
|
195
195
|
readonly starts_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
|
|
196
196
|
readonly ends_at: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
|
|
197
197
|
}, undefined>, undefined>;
|
|
198
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* Whether its time overlaps another event's, in the accounts read: a timed event, not cancelled, not declined, not
|
|
200
|
+
* free and no working-location block. On a calendar CLI's `events list` when the query names it.
|
|
201
|
+
*/
|
|
202
|
+
readonly overlapping: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
|
|
203
|
+
/** With `overlapping:true`: the events it overlaps, as `event:<id>`. */
|
|
199
204
|
readonly overlaps: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>;
|
|
200
205
|
}, undefined>;
|
|
201
206
|
export declare const eventItem: (one: Event) => v.InferOutput<typeof event>;
|
|
@@ -207,7 +207,12 @@ export const event = v.strictObject({
|
|
|
207
207
|
changed_at: v.optional(v.nullable(isoTime)),
|
|
208
208
|
/** The times a moved event had at that date. */
|
|
209
209
|
was: v.optional(v.strictObject({ starts_at: v.nullable(isoTime), ends_at: v.nullable(isoTime) })),
|
|
210
|
-
/**
|
|
210
|
+
/**
|
|
211
|
+
* Whether its time overlaps another event's, in the accounts read: a timed event, not cancelled, not declined, not
|
|
212
|
+
* free and no working-location block. On a calendar CLI's `events list` when the query names it.
|
|
213
|
+
*/
|
|
214
|
+
overlapping: v.optional(v.boolean()),
|
|
215
|
+
/** With `overlapping:true`: the events it overlaps, as `event:<id>`. */
|
|
211
216
|
overlaps: v.optional(v.array(text)),
|
|
212
217
|
});
|
|
213
218
|
export const eventItem = (one) => ({
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { type EventStore } from "../event-store.js";
|
|
2
2
|
import type { ContractCase } from "../store.js";
|
|
3
|
-
/** A fresh, empty store and
|
|
3
|
+
/** A fresh, empty store and two calendar accounts in it. */
|
|
4
4
|
export interface EventStoreFixture {
|
|
5
5
|
store: EventStore;
|
|
6
6
|
accountId: number;
|
|
7
|
+
otherAccountId: number;
|
|
7
8
|
}
|
|
8
9
|
/** "Now" in every case: entries before it have happened. */
|
|
9
10
|
export declare const CONTRACT_NOW: number;
|
|
@@ -354,6 +354,34 @@ export const eventStoreContract = (make) => {
|
|
|
354
354
|
const ids = new Set(found.map(({ id }) => id));
|
|
355
355
|
for (const externalId of ["first", "second"])
|
|
356
356
|
assert.ok(ids.has((await store.entry(calendar, externalId))?.eventId), externalId);
|
|
357
|
+
const all = await store.events({ from: at - HOUR, to: at + 8 * HOUR });
|
|
358
|
+
const rest = await store.events({ overlapping: false, from: at - HOUR, to: at + 8 * HOUR });
|
|
359
|
+
assert.deepEqual(rest.map(({ id }) => id), all.filter(({ id }) => !ids.has(id)).map(({ id }) => id));
|
|
360
|
+
}),
|
|
361
|
+
test("a working-location block overlaps nothing; a meeting in another account overlaps when both are read", async ({ store, calendar, ...fixture }) => {
|
|
362
|
+
const at = later;
|
|
363
|
+
const [other] = await store.saveCalendars(fixture.otherAccountId, [
|
|
364
|
+
{
|
|
365
|
+
externalId: "other-primary",
|
|
366
|
+
title: "Home",
|
|
367
|
+
timezone: "UTC",
|
|
368
|
+
accessRole: "owner",
|
|
369
|
+
primary: true,
|
|
370
|
+
syncByDefault: true,
|
|
371
|
+
},
|
|
372
|
+
], 1);
|
|
373
|
+
await store.saveEntries(calendar, [single("office", at, { eventType: "working-location", endsAt: at + 8 * HOUR }), single("work", at + HOUR)], 2);
|
|
374
|
+
await store.saveEntries(other.id, [single("dentist", at + HOUR + HOUR / 2)], 2);
|
|
375
|
+
const window = { from: at - HOUR, to: at + 8 * HOUR };
|
|
376
|
+
assert.equal((await store.events({ ...window, overlapping: true, accountId: fixture.accountId })).length, 0);
|
|
377
|
+
const both = await store.events({
|
|
378
|
+
...window,
|
|
379
|
+
overlapping: true,
|
|
380
|
+
accountIds: [fixture.accountId, fixture.otherAccountId],
|
|
381
|
+
});
|
|
382
|
+
assert.equal(both.length, 2);
|
|
383
|
+
assert.equal((await store.events({ ...window, accountIds: [fixture.accountId, fixture.otherAccountId] })).length, 3);
|
|
384
|
+
assert.equal((await store.events({ ...window, accountIds: [fixture.otherAccountId] })).length, 1);
|
|
357
385
|
}),
|
|
358
386
|
test("participants are kept by address; a room is listed and is no contact", async ({ store, calendar }) => {
|
|
359
387
|
const [entry] = await store.saveEntries(calendar, [
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AppIdentity } from "../ports/app.js";
|
|
2
|
+
/**
|
|
3
|
+
* The messenger library's warnings for one profile, always kept: a message it failed to decrypt is lost without a
|
|
4
|
+
* trace otherwise. One JSON object per line, `{"time","level","kind"?,"message"}` — the shape zerolog writes, so an
|
|
5
|
+
* engine in another process appends to the file directly. **No phone number, id of a person or message text**: the
|
|
6
|
+
* adapter or its engine masks before writing. `kind` is the adapter's tag (`decrypt`), counted by `doctor`.
|
|
7
|
+
*/
|
|
8
|
+
export declare const providerLogPathFor: (app: AppIdentity, profile: string, env?: NodeJS.ProcessEnv) => string;
|
|
9
|
+
export declare const PROVIDER_LOG_BYTES: number;
|
|
10
|
+
/**
|
|
11
|
+
* The file, private and under its cap, ready for an engine to append to. The cap is kept here rather than by the
|
|
12
|
+
* writer, because an engine in another language appends without knowing it: one older file is kept.
|
|
13
|
+
*/
|
|
14
|
+
export declare const openProviderLog: (path: string, maxBytes?: number) => string;
|
|
15
|
+
export declare const writeProviderWarning: (path: string, kind: string, message: string, now?: Date) => void;
|
|
16
|
+
export interface ProviderWarnings {
|
|
17
|
+
path: string;
|
|
18
|
+
total: number;
|
|
19
|
+
kinds: Record<string, number>;
|
|
20
|
+
}
|
|
21
|
+
/** Warnings since `since`, by kind; a line without one counts as `other`, a line that is not JSON is skipped. */
|
|
22
|
+
export declare const providerWarnings: (path: string, since: Date) => ProviderWarnings;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { resolvePaths } from "@wirecat/cli-base";
|
|
4
|
+
/**
|
|
5
|
+
* The messenger library's warnings for one profile, always kept: a message it failed to decrypt is lost without a
|
|
6
|
+
* trace otherwise. One JSON object per line, `{"time","level","kind"?,"message"}` — the shape zerolog writes, so an
|
|
7
|
+
* engine in another process appends to the file directly. **No phone number, id of a person or message text**: the
|
|
8
|
+
* adapter or its engine masks before writing. `kind` is the adapter's tag (`decrypt`), counted by `doctor`.
|
|
9
|
+
*/
|
|
10
|
+
export const providerLogPathFor = (app, profile, env = process.env) => join(resolvePaths({ appName: app.appName, prefix: app.envPrefix, env }).state, "provider-log", `${profile}.jsonl`);
|
|
11
|
+
export const PROVIDER_LOG_BYTES = 1024 * 1024;
|
|
12
|
+
const previousOf = (path) => path.replace(/\.jsonl$/, ".1.jsonl");
|
|
13
|
+
/**
|
|
14
|
+
* The file, private and under its cap, ready for an engine to append to. The cap is kept here rather than by the
|
|
15
|
+
* writer, because an engine in another language appends without knowing it: one older file is kept.
|
|
16
|
+
*/
|
|
17
|
+
export const openProviderLog = (path, maxBytes = PROVIDER_LOG_BYTES) => {
|
|
18
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
19
|
+
if (existsSync(path) && statSync(path).size >= maxBytes)
|
|
20
|
+
renameSync(path, previousOf(path));
|
|
21
|
+
closeSync(openSync(path, "a", 0o600));
|
|
22
|
+
chmodSync(path, 0o600);
|
|
23
|
+
return path;
|
|
24
|
+
};
|
|
25
|
+
export const writeProviderWarning = (path, kind, message, now = new Date()) => {
|
|
26
|
+
openProviderLog(path);
|
|
27
|
+
appendFileSync(path, `${JSON.stringify({ time: now.toISOString(), level: "warn", kind, message })}\n`);
|
|
28
|
+
};
|
|
29
|
+
/** Warnings since `since`, by kind; a line without one counts as `other`, a line that is not JSON is skipped. */
|
|
30
|
+
export const providerWarnings = (path, since) => {
|
|
31
|
+
const kinds = {};
|
|
32
|
+
let total = 0;
|
|
33
|
+
for (const file of [previousOf(path), path]) {
|
|
34
|
+
if (!existsSync(file))
|
|
35
|
+
continue;
|
|
36
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
37
|
+
if (!line)
|
|
38
|
+
continue;
|
|
39
|
+
let entry;
|
|
40
|
+
try {
|
|
41
|
+
entry = JSON.parse(line);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const at = typeof entry.time === "string" ? Date.parse(entry.time) : Number.NaN;
|
|
47
|
+
if (!(at >= since.getTime()))
|
|
48
|
+
continue;
|
|
49
|
+
const kind = typeof entry.kind === "string" && entry.kind ? entry.kind : "other";
|
|
50
|
+
kinds[kind] = (kinds[kind] ?? 0) + 1;
|
|
51
|
+
total++;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { path, total, kinds };
|
|
55
|
+
};
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { eventOf } from "./events.js";
|
|
3
3
|
const DAY = 86_400_000;
|
|
4
|
+
const accountsOf = (filter) => {
|
|
5
|
+
if (filter.accountIds !== undefined && filter.accountId !== undefined)
|
|
6
|
+
throw new CliError("validation_error", "an event filter takes accountId or accountIds, not both");
|
|
7
|
+
if (filter.accountIds?.length === 0)
|
|
8
|
+
throw new CliError("validation_error", "accountIds names no account");
|
|
9
|
+
return filter.accountIds ?? (filter.accountId === undefined ? undefined : [filter.accountId]);
|
|
10
|
+
};
|
|
4
11
|
/** The events of a filter, earliest first (`EventStore.events`). */
|
|
5
12
|
export const listEvents = (database, filter) => {
|
|
6
13
|
const clauses = ["e.deleted_at IS NULL"];
|
|
@@ -40,21 +47,22 @@ export const listEvents = (database, filter) => {
|
|
|
40
47
|
clauses.push(entries("ce.calendar_id = ?"));
|
|
41
48
|
params.push(filter.calendarId);
|
|
42
49
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
const accounts = accountsOf(filter);
|
|
51
|
+
const inAccounts = (column) => {
|
|
52
|
+
if (accounts === undefined)
|
|
53
|
+
return "";
|
|
54
|
+
for (const id of accounts)
|
|
55
|
+
params.push(id);
|
|
56
|
+
return `${column} IN (${accounts.map(() => "?").join(", ")})`;
|
|
57
|
+
};
|
|
58
|
+
if (accounts !== undefined)
|
|
59
|
+
clauses.push(entries(inAccounts("c.account_id")));
|
|
47
60
|
if (filter.personId !== undefined) {
|
|
48
61
|
clauses.push(entries("EXISTS (SELECT 1 FROM calendar_entry_participants p JOIN identity_links il ON il.identity_id = p.identity_id " +
|
|
49
62
|
"WHERE p.entry_id = ce.id AND il.person_id = ?)"));
|
|
50
63
|
params.push(filter.personId);
|
|
51
64
|
}
|
|
52
|
-
const account = (alias) => {
|
|
53
|
-
if (filter.accountId === undefined)
|
|
54
|
-
return "";
|
|
55
|
-
params.push(filter.accountId);
|
|
56
|
-
return ` AND ${alias}.account_id = ?`;
|
|
57
|
-
};
|
|
65
|
+
const account = (alias) => (accounts === undefined ? "" : ` AND ${inAccounts(`${alias}.account_id`)}`);
|
|
58
66
|
if (filter.changedSince !== undefined) {
|
|
59
67
|
// The SQL form of eventChange: first stored since, cancelled since, or a live copy's time changed since.
|
|
60
68
|
const since = filter.changedSince;
|
|
@@ -72,16 +80,18 @@ export const listEvents = (database, filter) => {
|
|
|
72
80
|
OR r.ends_at IS NOT ce.ends_at FROM calendar_entry_revisions r WHERE r.entry_id = ce.id AND r.created_at >= ?
|
|
73
81
|
ORDER BY r.created_at, r.id LIMIT 1) = 1)))`);
|
|
74
82
|
}
|
|
75
|
-
if (filter.overlapping) {
|
|
83
|
+
if (filter.overlapping !== undefined) {
|
|
76
84
|
const busy = (event) => `${event}.deleted_at IS NULL AND ${event}.status <> 'cancelled' AND ${event}.all_day = 0
|
|
77
85
|
AND ${event}.starts_at IS NOT NULL AND ${event}.ends_at IS NOT NULL AND EXISTS (SELECT 1 FROM calendar_entries
|
|
78
86
|
bce JOIN calendars bc ON bc.id = bce.calendar_id WHERE bce.event_id = ${event}.id${account("bc")}
|
|
79
87
|
AND bce.deleted_at IS NULL AND bce.status <> 'cancelled' AND coalesce(bce.own_response, '') <> 'declined'
|
|
80
|
-
AND coalesce(bce.transparency, '') <> 'transparent'
|
|
88
|
+
AND coalesce(bce.transparency, '') <> 'transparent'
|
|
89
|
+
AND coalesce(bce.event_type, '') <> 'working-location')`;
|
|
81
90
|
const own = busy("e");
|
|
82
91
|
params.push(longest());
|
|
83
|
-
|
|
84
|
-
AND e2.ends_at > e.starts_at AND e2.starts_at >= e.starts_at - ? AND ${busy("e2")})
|
|
92
|
+
const clash = `${own} AND EXISTS (SELECT 1 FROM events e2 WHERE e2.id <> e.id AND e2.starts_at < e.ends_at
|
|
93
|
+
AND e2.ends_at > e.starts_at AND e2.starts_at >= e.starts_at - ? AND ${busy("e2")})`;
|
|
94
|
+
clauses.push(filter.overlapping ? clash : `NOT (${clash})`);
|
|
85
95
|
}
|
|
86
96
|
const limit = filter.limit ?? 100;
|
|
87
97
|
const offset = filter.offset ?? 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.528.0",
|
|
4
4
|
"description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Viacheslav Ptsarev",
|