@wirecat/cli-core 0.525.0 → 0.527.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 CHANGED
@@ -7,6 +7,48 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.527.0 — 01.11.2026
11
+
12
+ ### Added
13
+
14
+ - **`events list --source <accounts>`: events of several calendar accounts as one list.** The shared calendar
15
+ `events list` takes `--source` as `search messages` does (`all`, a provider or its alias, `provider:account`,
16
+ `group:<name>`; WC-ACCT-8) and reads the accounts in one store query (`EventFilter.accountIds`), so a meeting two
17
+ accounts hold is one item and pages stay exact. Every answer names the accounts it read in `meta.accounts`
18
+ (WC-ACCT-9); `meta.complete` is false, with an issue per account, when one has no synced calendar
19
+ (`calendar.unsynced`) or a sync that never finished or is mid-round (`calendar.sync-incomplete`); the command
20
+ declares `partial`, so such an answer exits 0. `CalendarCommandDeps.locked` keeps a process under a profile lock to
21
+ its own account (`profile.locked`).
22
+ - **`overlapping:true` in `events list`: meetings that overlap.** A field of the event entity (`overlapping`, with
23
+ `overlaps` listing the others), filtered in the store with a self-join. Timed events only, not cancelled, not
24
+ declined, not free and not a working-location block; back-to-back events do not overlap. `overlapping:false` keeps
25
+ the rest. Across `--source` it finds a work meeting that clashes with a personal one.
26
+
27
+ ### Changed — may break callers
28
+
29
+ - `EventStoreFixture` (the event store contract) takes `otherAccountId`, a second calendar account.
30
+ - `EventFilter.overlapping: false` now keeps the events that do not overlap; before, it filtered nothing.
31
+ - `EventFilter.overlapping` also leaves out working-location blocks.
32
+
33
+ ## 0.526.0 — 01.11.2026
34
+
35
+ ### Added
36
+
37
+ - **`mailIds` from `@wirecat/cli-core/mail`: one email id for mail and memo.** On every server but Gmail the id is
38
+ the hash of the Message-ID (the UID with its folder and UIDVALIDITY when there is none) and the thread is the first
39
+ id of References, else In-Reply-To. OBJECTID's EMAILID is not used, so a server that turns OBJECTID on keeps its
40
+ ids. Gmail keeps X-GM-MSGID and X-GM-THRID.
41
+ - **`decodedLabelName` for a Gmail label already decoded.** imapflow decodes X-GM-LABELS; `labelName` decodes the
42
+ modified UTF-7 again and turned `R&D-team` into `Rteam`. `labelName` stays for the wire form.
43
+ - **`storedMailRuleSet`: the stored rules this build cannot read, with why.** A sync reports them instead of dropping
44
+ them silently; `storedMailRules` still returns only the rules.
45
+
46
+ ### Fixed
47
+
48
+ - **`conversations list` and `conversations show` no longer read a failing store as "nothing stored".** Only a
49
+ missing account answers empty or not found; a locked or broken store fails the command.
50
+ - **The messenger `search messages` help sends mail searches to `mail <profile> search messages`**, not to memo.
51
+
10
52
  ## 0.525.0 — 01.11.2026
11
53
 
12
54
  ### 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 starts_at>=2026-11-02 --limit 50 --page 2")
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(eachMarked(await eventsAnswer(deps.store, filter && { ...filter, limit: opts.limit }, opts.page), EVENT_ITEM_UNTRUSTED), command);
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, UTC when absent; without starts_at or ends_at, from today on",
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,
@@ -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 address is found through the
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">>;
@@ -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
- ? { ...field, operators: field.type === "time" ? ["term", "range"] : ["term", "phrase"] }
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 address is found through the
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 filter = { accountId: context.accountId };
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 calendarIn(context.store, context.accountId, node.value)).id;
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.accountId === undefined ? [] : [...(await calendarIds(store, filter.accountId))];
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.accountId === undefined ? {} : { accountId: filter.accountId }),
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
- const copiesOf = (event) => entries.filter((one) => one.eventId === event.id &&
457
- (filter.accountId === undefined || calendarOf(one.calendarId).accountId === filter.accountId));
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
- (filter.accountId === undefined ||
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
- (!filter.overlapping ||
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 }));
@@ -49,7 +49,7 @@ offer \`${messenger.app.command} messages sync <chat>\` and run it only once the
49
49
  : `Search reads the local archive: what \`${messenger.app.command} serve\` has kept since the account was linked.
50
50
  Keep serve running so new messages are searchable.`} Notes are
51
51
  \`${messenger.app.command} search notes\`, messages and notes at once \`${messenger.app.command} search all\`; for mail,
52
- the separate memo CLI: \`memo search mail\`.
52
+ the mail CLI: \`mail <profile> search messages\`.
53
53
  A bare word looks in message text, voice transcripts, file names and file text. text:, transcript:,
54
54
  content: (the text of files) and filename: (their names) narrow it to one —
55
55
  \`${messenger.app.command} search messages 'content:invoice'\`. chat: narrows it to one chat; has:voice, has:file and
@@ -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<{
@@ -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"],
@@ -18,6 +18,12 @@ import { searchMailServer } from "../../mail/server-search.js";
18
18
  import { atDetail } from "../../output-detail.js";
19
19
  import { sourceKey } from "../../services/server-search.js";
20
20
  import { queryFilter, THREAD_COLUMNS } from "../../store/store.js";
21
+ /** A locked or broken store must fail the command, not read as "nothing stored yet". */
22
+ const absentAccount = (error) => {
23
+ if (error instanceof CliError && error.code === "not_found")
24
+ return undefined;
25
+ throw error;
26
+ };
21
27
  const DEFAULT_LIMIT = 20;
22
28
  const MAX_LIMIT = 100;
23
29
  const limitOf = (value) => {
@@ -252,7 +258,7 @@ const conversationsCommand = (mail) => {
252
258
  const typed = typedQuery(words);
253
259
  const query = listQuery(typed, THREAD_QUERY, "conversations list");
254
260
  const { threads, mailbox } = await mail.read(this, async (store, mailbox) => {
255
- const account = await store.accounts.storedAccount(mailbox).catch(() => undefined);
261
+ const account = await store.accounts.storedAccount(mailbox).catch(absentAccount);
256
262
  if (!account)
257
263
  return { threads: [], mailbox };
258
264
  const where = query ? queryFilter(query, THREAD_COLUMNS, { command: "conversations list" }) : undefined;
@@ -296,7 +302,7 @@ const conversationsCommand = (mail) => {
296
302
  const full = detailOf(this) === "full";
297
303
  const emails = await mail.read(this, async (store, mailbox) => {
298
304
  const thread = threadOf(mailbox, given);
299
- const account = await store.accounts.storedAccount(mailbox).catch(() => undefined);
305
+ const account = await store.accounts.storedAccount(mailbox).catch(absentAccount);
300
306
  const hits = account ? await store.mail.threadHits(account.id, thread) : [];
301
307
  if (!account || hits.length === 0)
302
308
  throw new CliError("not_found", `no stored thread ${given}`);
@@ -0,0 +1,20 @@
1
+ export interface MailIdsInput {
2
+ /** As the server's envelope gives it; read from `head` when absent. */
3
+ messageId?: string | undefined;
4
+ /** Header text holding at least References and In-Reply-To. */
5
+ head: string;
6
+ folder: string;
7
+ validity: string;
8
+ uid: string;
9
+ }
10
+ /**
11
+ * The ids of an email on any server but Gmail, whose X-GM-MSGID and X-GM-THRID are used as they are. OBJECTID's
12
+ * EMAILID is not used: mail and memo must give one email one id, and a server that turns OBJECTID on later must not
13
+ * store its mail again. The Message-ID is the identity; without one, the UID, which means nothing without
14
+ * UIDVALIDITY. The thread is the first Message-ID of References, the thread's first email (RFC 5322 §3.6.4), else
15
+ * In-Reply-To's parent, so a server without thread ids (Yandex, Mail.ru) still keeps a conversation together.
16
+ */
17
+ export declare const mailIds: ({ messageId, head, folder, validity, uid }: MailIdsInput) => {
18
+ messageId: string;
19
+ threadId: string;
20
+ };
@@ -0,0 +1,17 @@
1
+ import { createHash } from "node:crypto";
2
+ import { field, unfold } from "./headers.js";
3
+ const digest = (id) => `imap:${createHash("sha256").update(id).digest("hex")}`;
4
+ const firstId = (value) => /<[^<>\s]+>/.exec(value ?? "")?.[0];
5
+ /**
6
+ * The ids of an email on any server but Gmail, whose X-GM-MSGID and X-GM-THRID are used as they are. OBJECTID's
7
+ * EMAILID is not used: mail and memo must give one email one id, and a server that turns OBJECTID on later must not
8
+ * store its mail again. The Message-ID is the identity; without one, the UID, which means nothing without
9
+ * UIDVALIDITY. The thread is the first Message-ID of References, the thread's first email (RFC 5322 §3.6.4), else
10
+ * In-Reply-To's parent, so a server without thread ids (Yandex, Mail.ru) still keeps a conversation together.
11
+ */
12
+ export const mailIds = ({ messageId, head, folder, validity, uid }) => {
13
+ const unfolded = unfold(head);
14
+ const identity = (messageId ?? field(unfolded, "message-id"))?.trim() || `uid:${folder}:${validity}:${uid}`;
15
+ const root = firstId(field(unfolded, "references")) ?? firstId(field(unfolded, "in-reply-to"));
16
+ return { messageId: digest(identity), threadId: digest(root ?? identity) };
17
+ };
@@ -3,7 +3,8 @@ export { factsOf } from "./facts.js";
3
3
  export { GMAIL_ALL_MAIL, mailFolders } from "./folders.js";
4
4
  export { dropMissing, type Gone, tooManyGone } from "./gone.js";
5
5
  export { type AuthVerdicts, HEADER_FIELDS, headersOf, type MailHeaders, mailHeadersOf } from "./headers.js";
6
- export { decodeMailboxName, labelName, systemLabel } from "./labels.js";
6
+ export { type MailIdsInput, mailIds } from "./ids.js";
7
+ export { decodedLabelName, decodeMailboxName, labelName, systemLabel } from "./labels.js";
7
8
  export { type Address, htmlToText, looksLikeHtml, MAX_BODY_CHARS, type ParsedMail, parseMail, } from "./parse.js";
8
9
  export { PRESET_NAMES, PRESETS, type PresetName, presetMatches } from "./presets.js";
9
10
  export { domainsToAge, groupKey, type Probe, type ProbeGroup, probeMail, SUGGEST_RULE } from "./probe.js";
@@ -11,7 +12,7 @@ export { type MailFolderProgress, mailProgress } from "./progress.js";
11
12
  export { ATTACHMENT_CHARS, attachmentsText, hitText, lowerSuspicious, type MailSearch, type ShownMail, searchMail, showMail, withAttachmentText, } from "./read.js";
12
13
  export { type FileStamp, FULL_PASS_MS, MBOX_RESUME, type MboxPoint, mergeRanges, parsePoint, type Ranges, type ResumePoint, rangesOf, resumeKey, rulesKey, within, } from "./resume.js";
13
14
  export { ruleInput, STORE_ACTION } from "./rule-input.js";
14
- export { storedMailRule, storedMailRules } from "./rule-stored.js";
15
+ export { type MailRuleSet, storedMailRule, storedMailRuleSet, storedMailRules } from "./rule-stored.js";
15
16
  export { mapProposedTags, ruleTagger, settleRuleTags, type TagOutcome } from "./rule-tags.js";
16
17
  export { conditionKeys, decide, describeMatch, ENVELOPE, envelopeBox, envelopeBoxOf, type MailAction, type MailFacts, type MailRule, type MailRuleMatch, matches, sameMatch, tagsOf, } from "./rules.js";
17
18
  export { type DomainAge, FRAUD_SIGNALS, FREE_MAIL, registrableOf, SCORE_RULE, type Scored, type Signal, scoreGroup, WEIGHTS, } from "./score.js";
@@ -3,7 +3,8 @@ export { factsOf } from "./facts.js";
3
3
  export { GMAIL_ALL_MAIL, mailFolders } from "./folders.js";
4
4
  export { dropMissing, tooManyGone } from "./gone.js";
5
5
  export { HEADER_FIELDS, headersOf, mailHeadersOf } from "./headers.js";
6
- export { decodeMailboxName, labelName, systemLabel } from "./labels.js";
6
+ export { mailIds } from "./ids.js";
7
+ export { decodedLabelName, decodeMailboxName, labelName, systemLabel } from "./labels.js";
7
8
  export { htmlToText, looksLikeHtml, MAX_BODY_CHARS, parseMail, } from "./parse.js";
8
9
  export { PRESET_NAMES, PRESETS, presetMatches } from "./presets.js";
9
10
  export { domainsToAge, groupKey, probeMail, SUGGEST_RULE } from "./probe.js";
@@ -11,7 +12,7 @@ export { mailProgress } from "./progress.js";
11
12
  export { ATTACHMENT_CHARS, attachmentsText, hitText, lowerSuspicious, searchMail, showMail, withAttachmentText, } from "./read.js";
12
13
  export { FULL_PASS_MS, MBOX_RESUME, mergeRanges, parsePoint, rangesOf, resumeKey, rulesKey, within, } from "./resume.js";
13
14
  export { ruleInput, STORE_ACTION } from "./rule-input.js";
14
- export { storedMailRule, storedMailRules } from "./rule-stored.js";
15
+ export { storedMailRule, storedMailRuleSet, storedMailRules } from "./rule-stored.js";
15
16
  export { mapProposedTags, ruleTagger, settleRuleTags } from "./rule-tags.js";
16
17
  export { conditionKeys, decide, describeMatch, ENVELOPE, envelopeBox, envelopeBoxOf, matches, sameMatch, tagsOf, } from "./rules.js";
17
18
  export { FRAUD_SIGNALS, FREE_MAIL, registrableOf, SCORE_RULE, scoreGroup, WEIGHTS, } from "./score.js";
@@ -1,5 +1,10 @@
1
1
  /** IMAP's modified UTF-7 (RFC 3501 §5.1.3): `&` opens base64 of UTF-16BE, `,` stands for `/`, `-` closes. */
2
2
  export declare const decodeMailboxName: (name: string) => string;
3
3
  export declare const systemLabel: (name: string) => string;
4
- /** A label as people read it: `\\Important` and `Important` are one label, and names are decoded. */
4
+ /**
5
+ * A label as people read it, from a name already decoded (imapflow decodes X-GM-LABELS): `\\Important` and
6
+ * `Important` are one label. Decoding twice would turn `R&D-team` into `Rteam`.
7
+ */
8
+ export declare const decodedLabelName: (name: string) => string;
9
+ /** The same from X-GM-LABELS as the server sends it, in modified UTF-7. */
5
10
  export declare const labelName: (raw: string) => string;
@@ -20,8 +20,13 @@ export const decodeMailboxName = (name) => name.replace(/&([^-]*)-/g, (_, run) =
20
20
  return text;
21
21
  });
22
22
  export const systemLabel = (name) => SYSTEM_LABELS[name.toLowerCase()] ?? name;
23
- /** A label as people read it: `\\Important` and `Important` are one label, and names are decoded. */
24
- export const labelName = (raw) => {
25
- const system = raw.startsWith("\\") ? raw.slice(1) : raw;
26
- return SYSTEM_LABELS[system.toLowerCase()] ?? decodeMailboxName(system);
23
+ /**
24
+ * A label as people read it, from a name already decoded (imapflow decodes X-GM-LABELS): `\\Important` and
25
+ * `Important` are one label. Decoding twice would turn `R&D-team` into `Rteam`.
26
+ */
27
+ export const decodedLabelName = (name) => {
28
+ const system = name.startsWith("\\") ? name.slice(1) : name;
29
+ return SYSTEM_LABELS[system.toLowerCase()] ?? system;
27
30
  };
31
+ /** The same from X-GM-LABELS as the server sends it, in modified UTF-7. */
32
+ export const labelName = (raw) => decodedLabelName(decodeMailboxName(raw));
@@ -3,7 +3,21 @@ import type { MessageStore, MailRule as StoredRule } from "../store/index.js";
3
3
  import type { MailRule } from "./rules.js";
4
4
  /** A stored rule as the sync reads it; `undefined` for one this build cannot read, so it is left to the build that can. */
5
5
  export declare const storedMailRule: (stored: StoredRule) => MailRule | undefined;
6
+ export interface MailRuleSet {
7
+ rules: MailRule[];
8
+ /**
9
+ * Stored rules this build cannot read, each with what it does not know. The sync leaves them out, and a build that
10
+ * reads them decides mail differently, so a sync reports them rather than drop them silently.
11
+ */
12
+ unreadable: {
13
+ id: number;
14
+ why: string;
15
+ }[];
16
+ }
6
17
  /** One account's rules from the store, for `account.rules` of the sync; none when the store has not seen the account. */
18
+ export declare const storedMailRuleSet: (store: MessageStore, account: {
19
+ address: string;
20
+ }, query?: QueryAst) => Promise<MailRuleSet>;
7
21
  export declare const storedMailRules: (store: MessageStore, account: {
8
22
  address: string;
9
23
  }, query?: QueryAst) => Promise<MailRule[]>;
@@ -17,15 +17,24 @@ const KINDS = {
17
17
  score_at_least: "number",
18
18
  preset: "string",
19
19
  };
20
+ /** Why this build cannot read a stored rule, or undefined when it can. */
21
+ const unreadable = (stored) => {
22
+ if (ACTION_OF[String(stored.actions.store)] === undefined)
23
+ return `action ${String(stored.actions.store)}`;
24
+ const entries = Object.entries(stored.conditions);
25
+ if (entries.length === 0)
26
+ return "no condition";
27
+ const unknown = entries.find(([key, value]) => !(key in KINDS) ||
28
+ typeof value !== KINDS[key] ||
29
+ (key === "preset" && !PRESET_NAMES.includes(value)));
30
+ return unknown && `condition ${unknown[0]}`;
31
+ };
20
32
  /** A stored rule as the sync reads it; `undefined` for one this build cannot read, so it is left to the build that can. */
21
33
  export const storedMailRule = (stored) => {
34
+ if (unreadable(stored) !== undefined)
35
+ return undefined;
22
36
  const action = ACTION_OF[String(stored.actions.store)];
23
37
  const entries = Object.entries(stored.conditions);
24
- const known = entries.every(([key, value]) => key in KINDS &&
25
- typeof value === KINDS[key] &&
26
- !(key === "preset" && !PRESET_NAMES.includes(value)));
27
- if (action === undefined || !known || entries.length === 0)
28
- return undefined;
29
38
  const tags = Array.isArray(stored.actions.tags) ? stored.actions.tags.filter((tag) => typeof tag === "string") : [];
30
39
  const skipped = stored.actions.tags_skipped;
31
40
  const tagsSkipped = skipped !== null && typeof skipped === "object" && !Array.isArray(skipped)
@@ -44,14 +53,25 @@ export const storedMailRule = (stored) => {
44
53
  };
45
54
  };
46
55
  /** One account's rules from the store, for `account.rules` of the sync; none when the store has not seen the account. */
47
- export const storedMailRules = async (store, account, query) => {
56
+ export const storedMailRuleSet = async (store, account, query) => {
57
+ let stored;
48
58
  try {
49
59
  const { id } = await store.accounts.storedAccount({ provider: "email", account: account.address.toLowerCase() });
50
- return (await store.mail.rules.list(id, query)).flatMap((stored) => storedMailRule(stored) ?? []);
60
+ stored = await store.mail.rules.list(id, query);
51
61
  }
52
62
  catch (error) {
53
63
  if (error instanceof CliError && error.code === "not_found")
54
- return [];
64
+ return { rules: [], unreadable: [] };
55
65
  throw error;
56
66
  }
67
+ const set = { rules: [], unreadable: [] };
68
+ for (const one of stored) {
69
+ const why = unreadable(one);
70
+ if (why === undefined)
71
+ set.rules.push(storedMailRule(one));
72
+ else
73
+ set.unreadable.push({ id: one.id, why });
74
+ }
75
+ return set;
57
76
  };
77
+ export const storedMailRules = async (store, account, query) => (await storedMailRuleSet(store, account, query)).rules;
@@ -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
- * Events whose time overlaps another's. Only timed events that take the owner's time count: not cancelled, and with
198
- * a live copy the owner did not decline or mark free.
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
- /** On a calendar CLI's `events list overlapping`: the events it clashes with, as `event:<id>`. */
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
- /** On a calendar CLI's `events list overlapping`: the events it clashes with, as `event:<id>`. */
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 one calendar account in it. */
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, [
@@ -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
- if (filter.accountId !== undefined) {
44
- clauses.push(entries("c.account_id = ?"));
45
- params.push(filter.accountId);
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
- clauses.push(`${own} AND EXISTS (SELECT 1 FROM events e2 WHERE e2.id <> e.id AND e2.starts_at < e.ends_at
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.525.0",
3
+ "version": "0.527.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",