@wirecat/cli-core 0.501.0 → 0.502.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
@@ -5,7 +5,31 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
- ## Unreleased
8
+ ## 0.502.0 — 31.10.2026
9
+
10
+ ### Added
11
+
12
+ - **The event store finds events by their earliest start: `EventFilter.startsFrom`.** It keeps events that start at
13
+ or after an instant, and `startsFromDate` keeps all-day events by their local date, as `toDate` does (WC-TIME-5).
14
+ Both stores (SQLite and `memoryEventStore`) answer it, and the event store contract checks the boundary.
15
+ - **`nativeTermLists`** from `@wirecat/cli-core/cli`: as `nativeTerms`, but named fields may repeat
16
+ (`person:a person:b`), every term kept.
17
+
18
+ ### Changed — may break callers
19
+
20
+ - **The shared calendar commands take the standard query and the paths the other tools use.** A CLI that mounts
21
+ `addCalendarCommands` (gcal) gets:
22
+ - `store status` in place of `status`, and `search events <query…>` in place of `search <words…>`; the permission
23
+ keys are `store.status` and `search.events`, and the sync's hint names `store status`.
24
+ - `events list [query…]` (`EVENTS_QUERY`): `starts_at` either way, now including `starts_at>=…` and
25
+ `starts_at:[a TO b}`, `ends_at>=…`, `changed_at>=…`, `calendar:` and `person:`. The words `today`, `week`,
26
+ `from:`, `to:`, `changed:` and `overlapping` are refused as query errors.
27
+ - `calendars busy [query…]` (`BUSY_QUERY`): the window as `start>=…` and `end<=…`, and `calendar:` and `person:`,
28
+ each repeatable. `today`, `week`, `from:` and `to:` are refused.
29
+ - `eventsFilter(query, …)` takes the query text in place of words, and `busyQuery(query, …)` as well;
30
+ `EVENTS_QUERY_HELP` and `BUSY_QUERY_HELP` are gone (`EVENTS_QUERY_NOTE`, `BUSY_QUERY_NOTE`), and `weekFilter`
31
+ builds `people list`'s week.
32
+ - `calendarTools`: `status` is `store_status`, and `events_list` takes `query` in place of `words`.
9
33
 
10
34
  ## 0.501.0 — 31.10.2026
11
35
 
@@ -1,12 +1,19 @@
1
+ import { type QueryRegistry } from "@wirecat/cli-base/query";
2
+ import { type QueryWords } from "../cli/list-query.js";
1
3
  import type { EventStore, StoredCalendar } from "../meetings/index.js";
2
4
  import type { CalendarSource, SyncWindow } from "./source.js";
3
- export declare const BUSY_QUERY_HELP = "\nWords, any order:\n today | week today, or today and the next six days, in your time zone\n from:<date> to:<date> from the start of one day through the end of another (or a time with a zone)\n calendar:<id> one of the account's calendars, by its id or the provider's id; repeat for more\n person:<email> someone else, by email address; repeat for more\nWithout a time word: this week. Without calendar:, every calendar that syncs.";
4
5
  export interface BusyQuery {
5
6
  window: SyncWindow;
6
7
  calendars: StoredCalendar[];
7
8
  people: string[];
8
9
  }
9
- export declare const busyQuery: (words: readonly string[], context: {
10
+ /**
11
+ * `calendars busy`'s query: the window by the answer's own `start` and `end`, and whom to ask. `calendar` and
12
+ * `person` repeat, and every one named is asked: the free stretches are those they all share.
13
+ */
14
+ export declare const BUSY_QUERY: QueryRegistry;
15
+ export declare const BUSY_QUERY_NOTE: string;
16
+ export declare const busyQuery: (query: QueryWords, context: {
10
17
  store: EventStore;
11
18
  accountId: number;
12
19
  zone: string;
@@ -1,13 +1,8 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
- import { dayBoundary, zonedMoment } from "@wirecat/cli-base/query";
3
- import { calendarIn, plusDays } from "./reads.js";
4
- export const BUSY_QUERY_HELP = `
5
- Words, any order:
6
- today | week today, or today and the next six days, in your time zone
7
- from:<date> to:<date> from the start of one day through the end of another (or a time with a zone)
8
- calendar:<id> one of the account's calendars, by its id or the provider's id; repeat for more
9
- person:<email> someone else, by email address; repeat for more
10
- Without a time word: this week. Without calendar:, every calendar that syncs.`;
2
+ import { dayBoundary, queryError, queryFieldsOf, zonedMoment, } from "@wirecat/cli-base/query";
3
+ import { LIST_QUERY_GUIDE, listQuery, nativeTermLists, typedQuery } from "../cli/list-query.js";
4
+ import { calendarBusy } from "./cli/answers.js";
5
+ import { calendarIn, plusDays, timeWindow } from "./reads.js";
11
6
  /** Google answers for 50 calendars at most (`calendarExpansionMax`, reference/freebusy/query). */
12
7
  const MOST = 50;
13
8
  const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u;
@@ -15,41 +10,68 @@ const HOURS = /^([01]\d|2[0-3]):([0-5]\d)-([01]\d|2[0-4]):([0-5]\d)$/u;
15
10
  const MINUTE = 60_000;
16
11
  const localDate = (at, zone) => new Intl.DateTimeFormat("en-CA", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit" }).format(at);
17
12
  const span = (text) => ({ start: 0, end: text.length });
18
- export const busyQuery = async (words, context) => {
19
- const today = localDate(context.now, context.zone);
20
- const day = (date) => dayBoundary(date, context.zone, false, span(date));
13
+ const BUSY = "calendars busy";
14
+ const bound = (field, side, example) => (node) => {
15
+ const open = node.operator === "range" && (side === "lower" ? node.upper === "*" : node.value === "*");
16
+ if (!open)
17
+ queryError("query.unsupported-operator", node.span, `${field} takes only ${side === "lower" ? "the window's start" : "the window's end"}, such as ${example}`, { command: BUSY, guide: LIST_QUERY_GUIDE });
18
+ };
19
+ /**
20
+ * `calendars busy`'s query: the window by the answer's own `start` and `end`, and whom to ask. `calendar` and
21
+ * `person` repeat, and every one named is asked: the free stretches are those they all share.
22
+ */
23
+ export const BUSY_QUERY = {
24
+ entity: "calendar_busy",
25
+ backend: "native",
26
+ fields: queryFieldsOf(calendarBusy, {
27
+ native: ["start", "end", "calendar", "person"],
28
+ relations: {
29
+ calendar: {
30
+ type: "kw",
31
+ example: "calendar:3",
32
+ relation: "one of the account's calendars, by its id or the provider's id; repeat for more",
33
+ },
34
+ person: { type: "kw", example: "person:ana@example.com", relation: "someone else, by address; repeat for more" },
35
+ },
36
+ examples: { start: "start>=2026-11-02", end: "end<=2026-11-06" },
37
+ validate: {
38
+ start: bound("start", "lower", "start>=2026-11-02"),
39
+ end: bound("end", "upper", "end<=2026-11-06"),
40
+ },
41
+ }).map((field) => field.native
42
+ ? { ...field, operators: field.type === "time" ? ["range"] : ["term", "phrase"] }
43
+ : field),
44
+ };
45
+ export const BUSY_QUERY_NOTE = "\nWithout start: from the start of today, in your time zone. Without end: seven days after the start. " +
46
+ "Without calendar: every calendar that syncs.";
47
+ export const busyQuery = async (query, context) => {
48
+ const { zone, now } = context;
49
+ const today = localDate(now, zone);
21
50
  let from;
22
51
  let to;
23
52
  const calendars = [];
24
53
  const people = new Set();
25
- for (const word of words) {
26
- const [, field, value = ""] = /^([a-z]+):(.+)$/u.exec(word) ?? [];
27
- if (word === "today" || word === "week") {
28
- from = day(today);
29
- to = day(plusDays(today, word === "today" ? 1 : 7));
30
- }
31
- else if (field === "from")
32
- from = zonedMoment(value, context.zone, span(value));
33
- else if (field === "to")
34
- to = zonedMoment(value, context.zone, span(value), { end: true }) + 1;
35
- else if (field === "calendar") {
36
- const calendar = await calendarIn(context.store, context.accountId, value);
37
- if (!calendars.some(({ id }) => id === calendar.id))
38
- calendars.push(calendar);
39
- }
40
- else if (field === "person") {
41
- const email = value.trim().toLowerCase();
42
- if (!EMAIL.test(email))
43
- throw new CliError("validation_error", `${value} is not an email address`);
44
- people.add(email);
54
+ const terms = nativeTermLists(listQuery(typedQuery(query), BUSY_QUERY, BUSY), BUSY, ["calendar", "person"]);
55
+ for (const [field, nodes] of terms)
56
+ for (const node of nodes) {
57
+ if (field === "start")
58
+ from = timeWindow(node, zone, now).lower;
59
+ else if (field === "end")
60
+ to = timeWindow(node, zone, now).upper;
61
+ else if (field === "calendar") {
62
+ const calendar = await calendarIn(context.store, context.accountId, node.value);
63
+ if (!calendars.some(({ id }) => id === calendar.id))
64
+ calendars.push(calendar);
65
+ }
66
+ else if (field === "person") {
67
+ const email = node.value.trim().toLowerCase();
68
+ if (!EMAIL.test(email))
69
+ throw new CliError("validation_error", `${node.value} is not an email address`);
70
+ people.add(email);
71
+ }
45
72
  }
46
- else
47
- throw new CliError("validation_error", `${word} is not a calendars busy word${BUSY_QUERY_HELP}`, {
48
- reason: "query.unknown_word",
49
- });
50
- }
51
- from ??= day(today);
52
- to ??= from === day(today) ? day(plusDays(today, 7)) : from + 7 * 86_400_000;
73
+ from ??= dayBoundary(today, zone, false, span(today));
74
+ to ??= from + 7 * 86_400_000;
53
75
  if (to <= from)
54
76
  throw new CliError("validation_error", "the window ends before it starts");
55
77
  const asked = calendars.length
@@ -192,7 +192,7 @@ const ANSWERS = {
192
192
  "calendars unsync": { name: "calendar", schema: calendar },
193
193
  "calendars busy": { name: "calendar_busy", schema: calendarBusy },
194
194
  sync: { name: "calendar_sync", schema: calendarSync },
195
- status: { name: "calendar_sync_progress", schema: calendarSyncProgress },
195
+ "store status": { name: "calendar_sync_progress", schema: calendarSyncProgress },
196
196
  "events list": { name: "event" },
197
197
  "events show": { name: "calendar_event", schema: calendarEvent },
198
198
  "events create": { name: "calendar_event_write", schema: calendarEventWrite },
@@ -200,7 +200,7 @@ const ANSWERS = {
200
200
  "events delete": { name: "calendar_event_write", schema: calendarEventWrite },
201
201
  "events move": { name: "calendar_event_write", schema: calendarEventWrite },
202
202
  "events respond": { name: "calendar_event_write", schema: calendarEventWrite },
203
- search: { name: "calendar_search_hit", schema: calendarSearchHit },
203
+ "search events": { name: "calendar_search_hit", schema: calendarSearchHit },
204
204
  "people list": { name: "calendar_attendee", schema: calendarAttendee },
205
205
  "people link": { name: "calendar_linked_person", schema: calendarLinkedPerson },
206
206
  "links review": { name: "calendar_link_review", schema: calendarLinkReview },
@@ -9,8 +9,8 @@ import { terminalAsker } from "../../cli/messenger/ask.js";
9
9
  import { lineOf } from "../../cli/steps.js";
10
10
  import { searchEvents } from "../../services/events-search.js";
11
11
  import { linkPeople } from "../../services/people.js";
12
- import { BUSY_QUERY_HELP, busyAnswer, busyQuery, workingHoursOf } from "../busy.js";
13
- import { CALENDAR_UNTRUSTED_FIELDS, calendarIn, calendarItem, EVENTS_QUERY_HELP, eventAnswer, eventIdOf, eventOf, eventsAnswer, eventsFilter, peopleAnswer, resolveAnswer, reviewsAnswer, } from "../reads.js";
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";
14
14
  import { REPEAT_HELP } from "../recurrence.js";
15
15
  import { serverSearchAnswer } from "../server-search.js";
16
16
  import { calendarStatus, syncCalendars } from "../sync.js";
@@ -99,11 +99,9 @@ const addCalendarCommandsDefinition = (program, deps) => {
99
99
  print(calendarItem({ ...calendar, synced }), command);
100
100
  });
101
101
  if (deps.freeBusy)
102
- example(annotate(calendars.command("busy"), { effects: REMOTE_READ, offline: false })
103
- .description("when calendars and people are taken, asked of the provider and not stored; with --duration-minutes, " +
104
- "the free stretches they share")
105
- .argument("[words...]", "today, week, from:<date>, to:<date>, calendar:<id>, person:<email>")
106
- .addHelpText("after", BUSY_QUERY_HELP), "calendars busy week person:ana@example.com --json", "calendars busy from:2026-11-02 to:2026-11-06 person:ana@example.com --duration-minutes 30 --hours 10:00-16:00")
102
+ example(withQuery(annotate(calendars.command("busy"), { effects: REMOTE_READ, offline: false }).description("when calendars and people are taken, asked of the provider and not stored; with --duration-minutes, " +
103
+ "the free stretches they share"), BUSY_QUERY).addHelpText("after", BUSY_QUERY_NOTE), "calendars busy person:ana@example.com --json", "calendars busy start>=2026-11-02 end<=2026-11-06 person:ana@example.com --duration-minutes 30 " +
104
+ "--hours 10:00-16:00")
107
105
  .option("--duration-minutes <minutes>", "find free stretches at least this long, 5–1440", minutes)
108
106
  .option("--hours <from-to>", "working hours in your time zone, Monday to Friday, with --duration-minutes", "09:00-17:00")
109
107
  .action(async (words, opts, command) => {
@@ -162,7 +160,7 @@ const addCalendarCommandsDefinition = (program, deps) => {
162
160
  },
163
161
  ]
164
162
  : undefined;
165
- const progressAt = lineOf(command, "status");
163
+ const progressAt = lineOf(command, "store status");
166
164
  report.hint = stuck
167
165
  ? `this run moved nothing on: raise --timeout or --max-pages, then run sync again; see progress: ${progressAt}`
168
166
  : report.state === "partial"
@@ -193,17 +191,17 @@ const addCalendarCommandsDefinition = (program, deps) => {
193
191
  });
194
192
  print({ ...report, ...(embeddings === undefined ? {} : { embeddings }), ...(next ? { next } : {}) }, command);
195
193
  });
196
- example(annotate(program.command("status"), { effects: LOCAL_READ }).description("how far each synced calendar's sync is: entries done, partial or complete, and when it last moved"), "status --json").action(async (_opts, command) => {
194
+ const store = annotate(program.command("store").description("the local store of calendar events"), {
195
+ effects: GROUP,
196
+ });
197
+ example(annotate(store.command("status"), { effects: LOCAL_READ }).description("how far each synced calendar's sync is: entries done, partial or complete, and when it last moved"), "store status --json").action(async (_opts, command) => {
197
198
  print(await calendarStatus(deps.store, deps.accountId, {
198
199
  profile: deps.profile ?? `account ${deps.accountId}`,
199
200
  now: now(),
200
201
  }), command);
201
202
  });
202
203
  const events = annotate(program.command("events").description("the account's stored events"), { effects: GROUP });
203
- example(annotate(events.command("list"), { effects: LOCAL_READ })
204
- .description("list stored events, earliest first")
205
- .argument("[words...]", "today, week, from:<date>, to:<date>, person:<who>, calendar:<id>")
206
- .addHelpText("after", EVENTS_QUERY_HELP), "events list week --json", "events list from:2026-11-02 to:2026-11-06 person:ana@example.com")
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")
207
205
  .option("--limit <n>", "events at most, 1–1000", integer, 100)
208
206
  .action(async (words, opts, command) => {
209
207
  const filter = await eventsFilter(words, {
@@ -221,12 +219,15 @@ const addCalendarCommandsDefinition = (program, deps) => {
221
219
  const shown = await eventAnswer(await eventOf(deps.store, deps.accountId, eventIdOf(reference)), personOfEmail);
222
220
  print({ ...shown, untrusted: CALENDAR_UNTRUSTED_FIELDS }, command);
223
221
  });
224
- const search = example(annotate(program.command("search"), { effects: deps.serverSearch ? LOCAL_OR_REMOTE_READ : LOCAL_READ })
222
+ const searches = annotate(program.command("search").description("find stored things by text: events"), {
223
+ effects: GROUP,
224
+ });
225
+ const search = example(annotate(searches.command("events"), { effects: deps.serverSearch ? LOCAL_OR_REMOTE_READ : LOCAL_READ })
225
226
  .description(deps.serverSearch
226
227
  ? "find stored events by words, and by meaning when its model is downloaded; with --backend server, ask " +
227
228
  "the provider instead"
228
229
  : "find stored events by words, and by meaning when its model is downloaded")
229
- .argument("<words...>", "what to find in titles, descriptions, places and attendees"), "search roadmap planning --json", ...(deps.serverSearch ? ["search --backend server quarterly review"] : []))
230
+ .argument("<query...>", "what to find in titles, descriptions, places and attendees"), "search events roadmap planning --json", ...(deps.serverSearch ? ["search events --backend server quarterly review"] : []))
230
231
  .option("--limit <n>", "events per page", integer, 20)
231
232
  .option("--cursor <cursor>", "continue from a page's next_offset", (text) => (text === "0" ? 0 : integer(text)), 0)
232
233
  .option("--exact", "every word as written, no word stems")
@@ -238,16 +239,16 @@ const addCalendarCommandsDefinition = (program, deps) => {
238
239
  .default("archive"));
239
240
  search.action(async (words, opts, command) => {
240
241
  if (opts.backend === "both")
241
- throw new CliError("validation_error", "search --backend both is not offered: the store's search and the provider's rank and page events " +
242
+ throw new CliError("validation_error", "search events --backend both is not offered: the store's search and the provider's rank and page events " +
242
243
  "differently, so one merged list would have no single order or next page; run --backend archive and " +
243
244
  "--backend server", { reason: "search.one-backend" });
244
245
  if (opts.backend === "server") {
245
246
  if (command.optsWithGlobals().offline)
246
- throw new CliError("permission_error", "search --backend server asks the provider; leave out --offline", {
247
+ throw new CliError("permission_error", "search events --backend server asks the provider; leave out --offline", {
247
248
  reason: "search.offline",
248
249
  });
249
250
  if (opts.exact || opts.cursor || opts.newest)
250
- throw new CliError("validation_error", "search --backend server takes neither --exact, --cursor nor --newest: the provider ranks and pages it", { reason: "search.server-options" });
251
+ throw new CliError("validation_error", "search events --backend server takes neither --exact, --cursor nor --newest: the provider ranks and pages it", { reason: "search.server-options" });
251
252
  const found = await serverSearchAnswer(source(), deps.store, {
252
253
  accountId: deps.accountId,
253
254
  account: deps.account,
@@ -276,9 +277,7 @@ const addCalendarCommandsDefinition = (program, deps) => {
276
277
  withQuery(example(annotate(people.command("list"), { effects: LOCAL_READ }).description("one event's attendees, or everyone attending this week's events"), "people list --json", "people list event:42"), PEOPLE_QUERY).action(async (words, _opts, command) => {
277
278
  const reference = queryTerms(words, PEOPLE_QUERY, "people list").get("event");
278
279
  const event = reference === undefined ? undefined : eventIdOf(reference);
279
- const filter = event === undefined
280
- ? await eventsFilter(["week"], { store: deps.store, accountId: deps.accountId, zone: zone(), now: now() })
281
- : undefined;
280
+ const filter = event === undefined ? weekFilter(deps.accountId, zone(), now()) : undefined;
282
281
  const people = await peopleAnswer(deps.store, deps.accountId, {
283
282
  ...(event === undefined ? {} : { event }),
284
283
  ...(filter ? { filter } : {}),
@@ -14,7 +14,7 @@ export declare const calendarTools: ({
14
14
  type: string;
15
15
  minimum: number;
16
16
  };
17
- words?: undefined;
17
+ query?: undefined;
18
18
  timezone?: undefined;
19
19
  limit?: undefined;
20
20
  event?: undefined;
@@ -23,7 +23,7 @@ export declare const calendarTools: ({
23
23
  type: string;
24
24
  minimum: number;
25
25
  };
26
- words: {
26
+ query: {
27
27
  type: string;
28
28
  minLength: number;
29
29
  };
@@ -38,7 +38,7 @@ export declare const calendarTools: ({
38
38
  };
39
39
  event?: undefined;
40
40
  } | {
41
- words?: undefined;
41
+ query?: undefined;
42
42
  timezone?: undefined;
43
43
  limit?: undefined;
44
44
  event: {
@@ -50,7 +50,7 @@ export declare const calendarTools: ({
50
50
  minimum: number;
51
51
  };
52
52
  } | {
53
- words?: undefined;
53
+ query?: undefined;
54
54
  timezone?: undefined;
55
55
  limit?: undefined;
56
56
  event?: undefined;
@@ -75,7 +75,7 @@ export declare const calendarTools: ({
75
75
  type: string;
76
76
  minimum: number;
77
77
  };
78
- words?: undefined;
78
+ query?: undefined;
79
79
  timezone?: undefined;
80
80
  limit?: undefined;
81
81
  event?: undefined;
@@ -84,7 +84,7 @@ export declare const calendarTools: ({
84
84
  type: string;
85
85
  minimum: number;
86
86
  };
87
- words: {
87
+ query: {
88
88
  type: string;
89
89
  minLength: number;
90
90
  };
@@ -99,7 +99,7 @@ export declare const calendarTools: ({
99
99
  };
100
100
  event?: undefined;
101
101
  } | {
102
- words?: undefined;
102
+ query?: undefined;
103
103
  timezone?: undefined;
104
104
  limit?: undefined;
105
105
  event: {
@@ -111,7 +111,7 @@ export declare const calendarTools: ({
111
111
  minimum: number;
112
112
  };
113
113
  } | {
114
- words?: undefined;
114
+ query?: undefined;
115
115
  timezone?: undefined;
116
116
  limit?: undefined;
117
117
  event?: undefined;
@@ -136,7 +136,7 @@ export declare const calendarTools: ({
136
136
  type: string;
137
137
  minimum: number;
138
138
  };
139
- words?: undefined;
139
+ query?: undefined;
140
140
  timezone?: undefined;
141
141
  limit?: undefined;
142
142
  event?: undefined;
@@ -145,7 +145,7 @@ export declare const calendarTools: ({
145
145
  type: string;
146
146
  minimum: number;
147
147
  };
148
- words: {
148
+ query: {
149
149
  type: string;
150
150
  minLength: number;
151
151
  };
@@ -160,7 +160,7 @@ export declare const calendarTools: ({
160
160
  };
161
161
  event?: undefined;
162
162
  } | {
163
- words?: undefined;
163
+ query?: undefined;
164
164
  timezone?: undefined;
165
165
  limit?: undefined;
166
166
  event: {
@@ -172,7 +172,7 @@ export declare const calendarTools: ({
172
172
  minimum: number;
173
173
  };
174
174
  } | {
175
- words?: undefined;
175
+ query?: undefined;
176
176
  timezone?: undefined;
177
177
  limit?: undefined;
178
178
  event?: undefined;
@@ -197,7 +197,7 @@ export declare const calendarTools: ({
197
197
  type: string;
198
198
  minimum: number;
199
199
  };
200
- words?: undefined;
200
+ query?: undefined;
201
201
  timezone?: undefined;
202
202
  limit?: undefined;
203
203
  event?: undefined;
@@ -206,7 +206,7 @@ export declare const calendarTools: ({
206
206
  type: string;
207
207
  minimum: number;
208
208
  };
209
- words: {
209
+ query: {
210
210
  type: string;
211
211
  minLength: number;
212
212
  };
@@ -221,7 +221,7 @@ export declare const calendarTools: ({
221
221
  };
222
222
  event?: undefined;
223
223
  } | {
224
- words?: undefined;
224
+ query?: undefined;
225
225
  timezone?: undefined;
226
226
  limit?: undefined;
227
227
  event: {
@@ -233,7 +233,7 @@ export declare const calendarTools: ({
233
233
  minimum: number;
234
234
  };
235
235
  } | {
236
- words?: undefined;
236
+ query?: undefined;
237
237
  timezone?: undefined;
238
238
  limit?: undefined;
239
239
  event?: undefined;
@@ -19,7 +19,7 @@ export const calendarTools = [
19
19
  required: [],
20
20
  },
21
21
  {
22
- name: "status",
22
+ name: "store_status",
23
23
  description: "how far each synced calendar's sync is: done, state partial or complete, and cursor_age_ms, which grows while " +
24
24
  "nothing moves it",
25
25
  properties: { account_id: integer },
@@ -27,10 +27,10 @@ export const calendarTools = [
27
27
  },
28
28
  {
29
29
  name: "events_list",
30
- description: "stored events, earliest first; `words` takes today, week, from:<date>, to:<date>, calendar:<id>, " +
31
- "changed:<date> (events new, moved or cancelled since, each with change and was) and overlapping (events " +
32
- "that clash, each with overlaps); a date in the zone given, UTC when absent",
33
- properties: { account_id: integer, words: text, timezone: text, limit: { ...integer, maximum: 1000 } },
30
+ description: "stored events, earliest first; `query` takes field terms joined by AND: starts_at (>=, <, or " +
31
+ "[a TO b}), ends_at>=, changed_at>= (events new, moved or cancelled since, each with change and was), " +
32
+ "calendar:<id>; a date in the zone given, UTC when absent; without starts_at or ends_at, from today on",
33
+ properties: { account_id: integer, query: text, timezone: text, limit: { ...integer, maximum: 1000 } },
34
34
  required: [],
35
35
  },
36
36
  {
@@ -163,7 +163,7 @@ const argumentsOf = (name, input) => {
163
163
  (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1))
164
164
  throw new CliError("validation_error", `${key} must be a positive integer`);
165
165
  else if ([
166
- "words",
166
+ "query",
167
167
  "timezone",
168
168
  "event",
169
169
  "calendar",
@@ -225,13 +225,12 @@ export const callCalendarTool = async (store, name, input, { accountIds, failure
225
225
  case "calendars_list":
226
226
  answer = listed((await Promise.all(accounts.map((id) => store.calendars(id)))).flat().map(calendarItem));
227
227
  break;
228
- case "status":
228
+ case "store_status":
229
229
  answer = listed((await Promise.all(accounts.map((id) => calendarStatus(store, id, { profile: profileOf(id), now }).then(({ items }) => items)))).flat());
230
230
  break;
231
231
  case "events_list": {
232
- const words = typeof args.words === "string" ? args.words.trim().split(/\s+/u) : [];
233
232
  const limit = args.limit ?? 100;
234
- const lists = await Promise.all(accounts.map(async (accountId) => eventsAnswer(store, await eventsFilter(words, {
233
+ const lists = await Promise.all(accounts.map(async (accountId) => eventsAnswer(store, await eventsFilter(args.query, {
235
234
  store,
236
235
  accountId,
237
236
  zone: args.timezone ?? "UTC",
@@ -1,3 +1,5 @@
1
+ import { type Predicate, type QueryRegistry } from "@wirecat/cli-base/query";
2
+ import { type QueryWords } from "../cli/list-query.js";
1
3
  import { type EventDetails, type EventFilter, type EventStore, type StoredCalendar, type StoredEntry } from "../meetings/index.js";
2
4
  /** Who looks a person up: the shared store's `people.personOf`, for an attendee's `email` contact. */
3
5
  export type PersonOfEmail = (email: string) => Promise<string | null>;
@@ -17,18 +19,27 @@ export declare const calendarItem: (one: StoredCalendar) => {
17
19
  export declare const calendarIn: (store: EventStore, accountId: number, reference: string) => Promise<StoredCalendar>;
18
20
  export declare const eventIdOf: (reference: string | number) => number;
19
21
  export declare const plusDays: (date: string, days: number) => string;
20
- export declare const EVENTS_QUERY_HELP = "\nWords, any order:\n today | week today, or today and the next six days, in your time zone\n from:<date> to:<date> from the start of one day through the end of another (or a time with a zone)\n person:<email|id> events one person attends: an address, or a person id from search\n calendar:<id> one calendar, by its id or the provider's id\n changed:<date> events new, moved or cancelled since then, each with what changed (from the local copy)\n overlapping events that clash with another: timed, not cancelled, not declined, not marked free\nWithout a time word: events from the start of today on.";
22
+ /** `events list`'s query. The store answers `ends_at` and `changed_at` from below only; `starts_at` either way. */
23
+ export declare const EVENTS_QUERY: QueryRegistry;
24
+ export declare const EVENTS_QUERY_NOTE: string;
25
+ /** A time term as epoch ms: from `lower` on, and before `upper`. */
26
+ export declare const timeWindow: (node: Predicate, zone: string, now: number) => {
27
+ lower?: number;
28
+ upper?: number;
29
+ };
21
30
  /**
22
- * `events list` words into a store filter, for one account. A person named by address is found through the shared
23
- * store; one nobody knows matches no event.
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.
24
33
  */
25
- export declare const eventsFilter: (words: readonly string[], context: {
34
+ export declare const eventsFilter: (query: QueryWords, context: {
26
35
  store: EventStore;
27
36
  accountId: number;
28
37
  zone: string;
29
38
  now: number;
30
39
  personOf?: (reference: string) => Promise<number | null>;
31
40
  }) => Promise<EventFilter | null>;
41
+ /** Events that end after the start of today and start within seven days, in `zone`. */
42
+ export declare const weekFilter: (accountId: number, zone: string, now: number) => EventFilter;
32
43
  /** An event the account holds, with only its copies, or `not_found`: another account's event is not shown. */
33
44
  export declare const eventOf: (store: EventStore, accountId: number, id: number) => Promise<EventDetails>;
34
45
  export declare const entryItem: (entry: StoredEntry) => {
@@ -1,6 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
- import { dayBoundary, zonedMoment } from "@wirecat/cli-base/query";
3
- import { eventChange, eventItem, listed, } from "../meetings/index.js";
2
+ import { dateRange, dayBoundary, queryError, queryFieldsOf, } from "@wirecat/cli-base/query";
3
+ import { LIST_QUERY_GUIDE, listQuery, nativeTerms, typedQuery } from "../cli/list-query.js";
4
+ import { event, eventChange, eventItem, listed, } from "../meetings/index.js";
4
5
  const iso = (at) => (at === null ? null : new Date(at).toISOString());
5
6
  /** Fields whose text whoever sent the invitation wrote: an agent reads them as data. */
6
7
  export const CALENDAR_UNTRUSTED_FIELDS = [
@@ -45,65 +46,114 @@ export const plusDays = (date, days) => {
45
46
  const [year, month, day] = date.split("-").map(Number);
46
47
  return new Date(Date.UTC(year, month - 1, day + days)).toISOString().slice(0, 10);
47
48
  };
48
- export const EVENTS_QUERY_HELP = `
49
- Words, any order:
50
- today | week today, or today and the next six days, in your time zone
51
- from:<date> to:<date> from the start of one day through the end of another (or a time with a zone)
52
- person:<email|id> events one person attends: an address, or a person id from search
53
- calendar:<id> one calendar, by its id or the provider's id
54
- changed:<date> events new, moved or cancelled since then, each with what changed (from the local copy)
55
- overlapping events that clash with another: timed, not cancelled, not declined, not marked free
56
- Without a time word: events from the start of today on.`;
49
+ const LIST = "events list";
50
+ const oneSided = (field, example) => (node) => {
51
+ if (node.operator !== "range" || node.upper !== "*")
52
+ queryError("query.unsupported-operator", node.span, `${field} takes only an earliest moment, such as ${example}`, {
53
+ command: LIST,
54
+ guide: LIST_QUERY_GUIDE,
55
+ });
56
+ };
57
+ /** `events list`'s query. The store answers `ends_at` and `changed_at` from below only; `starts_at` either way. */
58
+ export const EVENTS_QUERY = {
59
+ entity: "event",
60
+ backend: "native",
61
+ fields: queryFieldsOf(event, {
62
+ native: ["starts_at", "ends_at", "changed_at", "calendar", "person"],
63
+ relations: {
64
+ calendar: { type: "kw", example: "calendar:3", relation: "one calendar, by its id or the provider's id" },
65
+ person: {
66
+ type: "kw",
67
+ example: "person:ana@example.com",
68
+ relation: "events one person attends: an address, or a person id from search",
69
+ },
70
+ },
71
+ examples: {
72
+ starts_at: "starts_at:[2026-11-02 TO 2026-11-07}",
73
+ ends_at: "ends_at>=2026-11-02",
74
+ changed_at: "changed_at>=2026-10-01",
75
+ },
76
+ validate: {
77
+ ends_at: oneSided("ends_at", "ends_at>=2026-11-02; for the latest, write starts_at<2026-11-07"),
78
+ changed_at: oneSided("changed_at", "changed_at>=2026-10-01"),
79
+ },
80
+ }).map((field) => field.native
81
+ ? { ...field, operators: field.type === "time" ? ["term", "range"] : ["term", "phrase"] }
82
+ : field),
83
+ };
84
+ 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.";
86
+ /** A time term as epoch ms: from `lower` on, and before `upper`. */
87
+ export const timeWindow = (node, zone, now) => {
88
+ const range = node.operator === "range"
89
+ ? dateRange(node.value, node.upper ?? "*", node.lower_inclusive !== false, node.upper_inclusive !== false, zone, node.span, now)
90
+ : dateRange(node.value, node.value, true, true, zone, node.span, now);
91
+ return {
92
+ ...(range.lower === undefined ? {} : { lower: range.lower_inclusive ? range.lower : range.lower + 1 }),
93
+ ...(range.upper === undefined ? {} : { upper: range.upper_inclusive ? range.upper + 1 : range.upper }),
94
+ };
95
+ };
57
96
  /**
58
- * `events list` words into a store filter, for one account. A person named by address is found through the shared
59
- * store; one nobody knows matches no event.
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.
60
99
  */
61
- export const eventsFilter = async (words, context) => {
100
+ export const eventsFilter = async (query, context) => {
62
101
  const filter = { accountId: context.accountId };
63
- const today = localDate(context.now, context.zone);
102
+ const { zone, now } = context;
64
103
  const span = (text) => ({ start: 0, end: text.length });
65
- const day = (date) => dayBoundary(date, context.zone, false, span(date));
66
- let timed = false;
67
- for (const word of words) {
68
- const [, field, value = ""] = /^([a-z]+):(.+)$/u.exec(word) ?? [];
69
- if (word === "today" || word === "week") {
70
- filter.from = day(today);
71
- filter.to = day(plusDays(today, word === "today" ? 1 : 7));
72
- timed = true;
104
+ const day = (date) => dayBoundary(date, zone, false, span(date));
105
+ for (const [field, node] of nativeTerms(listQuery(typedQuery(query), EVENTS_QUERY, LIST), LIST)) {
106
+ if (field === "starts_at") {
107
+ const { lower, upper } = timeWindow(node, zone, now);
108
+ if (lower !== undefined)
109
+ filter.startsFrom = lower;
110
+ if (upper !== undefined)
111
+ filter.to = upper;
73
112
  }
74
- else if (field === "from") {
75
- filter.from = zonedMoment(value, context.zone, span(value));
76
- timed = true;
113
+ else if (field === "ends_at") {
114
+ // Strictly after, as the default window: `ends_at>=today` leaves out yesterday's all-day event, which ends at
115
+ // today's midnight.
116
+ const { lower } = timeWindow(node, zone, now);
117
+ if (lower !== undefined)
118
+ filter.from = lower;
77
119
  }
78
- else if (field === "to") {
79
- filter.to = zonedMoment(value, context.zone, span(value), { end: true }) + 1;
80
- timed = true;
120
+ else if (field === "changed_at") {
121
+ const { lower } = timeWindow(node, zone, now);
122
+ if (lower !== undefined)
123
+ filter.changedSince = lower;
81
124
  }
82
- else if (field === "changed")
83
- filter.changedSince = zonedMoment(value, context.zone, span(value));
84
- else if (word === "overlapping")
85
- filter.overlapping = true;
86
125
  else if (field === "calendar")
87
- filter.calendarId = (await calendarIn(context.store, context.accountId, value)).id;
126
+ filter.calendarId = (await calendarIn(context.store, context.accountId, node.value)).id;
88
127
  else if (field === "person") {
89
128
  if (!context.personOf)
90
129
  throw new CliError("configuration_error", "this tool cannot look people up");
91
- const person = await context.personOf(value);
130
+ const person = await context.personOf(node.value);
92
131
  if (person === null)
93
132
  return null;
94
133
  filter.personId = person;
95
134
  }
96
- else
97
- throw new CliError("validation_error", `${word} is not an events list word${EVENTS_QUERY_HELP}`, {
98
- reason: "query.unknown_word",
99
- });
100
135
  }
101
- if (!timed)
102
- filter.from = day(today);
136
+ if (filter.from === undefined && filter.startsFrom === undefined && filter.to === undefined)
137
+ filter.from = day(localDate(now, zone));
138
+ return datedFilter(filter, zone);
139
+ };
140
+ /** Events that end after the start of today and start within seven days, in `zone`. */
141
+ export const weekFilter = (accountId, zone, now) => {
142
+ const today = localDate(now, zone);
143
+ const day = (date) => dayBoundary(date, zone, false, { start: 0, end: date.length });
144
+ return datedFilter({ accountId, from: day(today), to: day(plusDays(today, 7)) }, zone);
145
+ };
146
+ /** The local days an all-day event is compared by, from the filter's instants. */
147
+ const datedFilter = (filter, zone) => {
103
148
  if (filter.from !== undefined)
104
- filter.fromDate = localDate(filter.from, context.zone);
149
+ filter.fromDate = localDate(filter.from, zone);
105
150
  if (filter.to !== undefined)
106
- filter.toDate = plusDays(localDate(filter.to - 1, context.zone), 1);
151
+ filter.toDate = plusDays(localDate(filter.to - 1, zone), 1);
152
+ if (filter.startsFrom !== undefined) {
153
+ const date = localDate(filter.startsFrom, zone);
154
+ const midnight = dayBoundary(date, zone, false, { start: 0, end: date.length });
155
+ filter.startsFromDate = midnight < filter.startsFrom ? plusDays(date, 1) : date;
156
+ }
107
157
  return filter;
108
158
  };
109
159
  /**
@@ -474,6 +474,10 @@ export const memoryEventStore = () => {
474
474
  (event.allDay && filter.toDate !== undefined
475
475
  ? has(event, (one) => one.startDate !== null && one.startDate < (filter.toDate ?? ""))
476
476
  : event.startsAt !== null && event.startsAt < filter.to)) &&
477
+ (filter.startsFrom === undefined ||
478
+ (event.allDay && filter.startsFromDate !== undefined
479
+ ? has(event, (one) => one.startDate !== null && one.startDate >= (filter.startsFromDate ?? ""))
480
+ : event.startsAt !== null && event.startsAt >= filter.startsFrom)) &&
477
481
  (filter.calendarId === undefined || has(event, (one) => one.calendarId === filter.calendarId)) &&
478
482
  (filter.accountId === undefined ||
479
483
  has(event, (one) => calendarOf(one.calendarId).accountId === filter.accountId)) &&
@@ -77,7 +77,7 @@ export { type FirstScreen, type FirstScreenLine, firstScreen, installHelp, WIREC
77
77
  export { type HelpSection, inSection, SECTIONS } from "./help/sections.js";
78
78
  export { bufferedInput, type InputPolicy, inputPolicy, MAX_BUFFERED_INPUT, MAX_SECRET_INPUT, provideInputPolicy, } from "./input-policy.js";
79
79
  export { answerFields, checkJson, type EntityFields, entityFields, type JsonFieldsResolver, type JsonRequest, liftJson, } from "./json-fields.js";
80
- export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTerms, pagedList, pageOf, type QueryWords, queryArgument, queryHelp, queryTerms, searchText, termField, timeBounds, typedQuery, unfilteredQuery, withQuery, } from "./list-query.js";
80
+ export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTermLists, nativeTerms, pagedList, pageOf, type QueryWords, queryArgument, queryHelp, queryTerms, searchText, termField, timeBounds, typedQuery, unfilteredQuery, withQuery, } from "./list-query.js";
81
81
  export { MCP_HTTP_DEFAULT_PORT, type McpServerOptions, type McpServerTarget, mcpServerCommand, } from "./mcp-server-command.js";
82
82
  export { formatMeetingBatch, prepareMeetingBatch } from "./meeting-batch.js";
83
83
  export { meetingEvidenceItem, meetingPersonContextItem } from "./meeting-evidence-answer.js";
package/dist/cli/index.js CHANGED
@@ -69,7 +69,7 @@ export { firstScreen, installHelp, WIRECAT_TOOLS } from "./help/first-screen.js"
69
69
  export { inSection, SECTIONS } from "./help/sections.js";
70
70
  export { bufferedInput, inputPolicy, MAX_BUFFERED_INPUT, MAX_SECRET_INPUT, provideInputPolicy, } from "./input-policy.js";
71
71
  export { answerFields, checkJson, entityFields, liftJson, } from "./json-fields.js";
72
- export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTerms, pagedList, pageOf, queryArgument, queryHelp, queryTerms, searchText, termField, timeBounds, typedQuery, unfilteredQuery, withQuery, } from "./list-query.js";
72
+ export { commandPath, LIST_QUERY_GUIDE, listQuery, nativeField, nativeTermLists, nativeTerms, pagedList, pageOf, queryArgument, queryHelp, queryTerms, searchText, termField, timeBounds, typedQuery, unfilteredQuery, withQuery, } from "./list-query.js";
73
73
  export { MCP_HTTP_DEFAULT_PORT, mcpServerCommand, } from "./mcp-server-command.js";
74
74
  export { formatMeetingBatch, prepareMeetingBatch } from "./meeting-batch.js";
75
75
  export { meetingEvidenceItem, meetingPersonContextItem } from "./meeting-evidence-answer.js";
@@ -39,6 +39,11 @@ export declare const queryArgument: (registry: QueryRegistry) => v.OptionalSchem
39
39
  * question the source cannot ask, so it is refused rather than answered from a part of the list.
40
40
  */
41
41
  export declare const nativeTerms: (ast: QueryAst | undefined, command: string) => Map<string, Predicate>;
42
+ /**
43
+ * As `nativeTerms`, but each `repeatable` field may be named again, every term kept in order: `person:a person:b`
44
+ * asks for both, as AND reads.
45
+ */
46
+ export declare const nativeTermLists: (ast: QueryAst | undefined, command: string, repeatable?: readonly string[]) => Map<string, Predicate[]>;
42
47
  /**
43
48
  * A store that compares whole values: each field's `field:value` or `field:"two words"`, enum values lowercased
44
49
  * as validation already accepts them. A range or wildcard is refused rather than read as a value.
@@ -91,7 +91,12 @@ export const queryArgument = (registry) => {
91
91
  * The terms of a query a provider answers: field:value joined by AND, each field once. Anything else is a
92
92
  * question the source cannot ask, so it is refused rather than answered from a part of the list.
93
93
  */
94
- export const nativeTerms = (ast, command) => {
94
+ export const nativeTerms = (ast, command) => new Map([...nativeTermLists(ast, command)].map(([field, [node]]) => [field, node]));
95
+ /**
96
+ * As `nativeTerms`, but each `repeatable` field may be named again, every term kept in order: `person:a person:b`
97
+ * asks for both, as AND reads.
98
+ */
99
+ export const nativeTermLists = (ast, command, repeatable = []) => {
95
100
  const terms = new Map();
96
101
  if (!ast)
97
102
  return terms;
@@ -106,9 +111,10 @@ export const nativeTerms = (ast, command) => {
106
111
  }
107
112
  return;
108
113
  }
109
- if (terms.has(node.field))
114
+ const held = terms.get(node.field);
115
+ if (held && !repeatable.includes(node.field))
110
116
  queryError("query.unsupported-placement", node.span, `${node.field} only once here`, context);
111
- terms.set(node.field, node);
117
+ terms.set(node.field, [...(held ?? []), node]);
112
118
  };
113
119
  add(ast.root);
114
120
  return terms;
@@ -185,6 +185,10 @@ export interface EventFilter {
185
185
  from?: number;
186
186
  /** Events that start before this instant. */
187
187
  to?: number;
188
+ /** Events that start at or after this instant. */
189
+ startsFrom?: number;
190
+ /** All-day events starting on or after this local day (`YYYY-MM-DD`); `startsFrom` without it. */
191
+ startsFromDate?: string;
188
192
  /** All-day events, which have dates and no instant, ending after this local day (`YYYY-MM-DD`); `from` without it. */
189
193
  fromDate?: string;
190
194
  /** Events `eventChange` finds new, moved or cancelled at or after this instant. */
@@ -281,6 +281,29 @@ export const eventStoreContract = (make) => {
281
281
  ])
282
282
  assert.deepEqual((await store.events(filter)).map(({ title }) => title).length, 3, JSON.stringify(filter));
283
283
  }),
284
+ test("a list from a start keeps the event that starts exactly then, and an all-day event by its local date", async ({ store, calendar, }) => {
285
+ const day = 24 * HOUR;
286
+ const day1 = new Date(later + day).toISOString().slice(0, 10);
287
+ const day0 = new Date(later).toISOString().slice(0, 10);
288
+ // Midnight of day1 an hour east of UTC; the all-day events are stored at a midnight three hours east.
289
+ const startsFrom = Date.parse(day1) - HOUR;
290
+ const allDay = (id, date, next) => single(id, Date.parse(date) - 3 * HOUR, {
291
+ title: id,
292
+ allDay: true,
293
+ startDate: date,
294
+ endDate: next,
295
+ endsAt: Date.parse(next) - 3 * HOUR,
296
+ });
297
+ await store.saveEntries(calendar, [
298
+ single("at", startsFrom, { title: "at" }),
299
+ single("before", startsFrom - 1, { title: "before" }),
300
+ allDay("on", day1, new Date(later + 2 * day).toISOString().slice(0, 10)),
301
+ allDay("day before", day0, day1),
302
+ ], 2);
303
+ const titles = async (filter) => (await store.events(filter)).map(({ title }) => title).sort();
304
+ assert.deepEqual(await titles({ startsFrom, startsFromDate: day1 }), ["at", "on"]);
305
+ assert.deepEqual(await titles({ startsFrom }), ["at"]);
306
+ }),
284
307
  test("a list of changes since an instant finds events new, moved or cancelled since, and says which", async ({ store, calendar, }) => {
285
308
  await store.saveEntries(calendar, ["same", "moved", "back", "cancelled"].map((id) => single(id, later)), 2);
286
309
  await store.saveEntries(calendar, [
@@ -19,6 +19,7 @@ export const listEvents = (database, filter) => {
19
19
  };
20
20
  dated("end_date > ?", filter.fromDate, "coalesce(e.ends_at, e.starts_at + 1) > ?", filter.from);
21
21
  dated("start_date < ?", filter.toDate, "e.starts_at < ?", filter.to);
22
+ dated("start_date >= ?", filter.startsFromDate, "e.starts_at >= ?", filter.startsFrom);
22
23
  // Bounds on starts_at alone let SQLite seek its index; the CASE above cannot. They are wider than the CASE: by the
23
24
  // longest stored event back, and by a day each way for an all-day event's zone.
24
25
  const longest = () => Number(database.prepare("SELECT coalesce(max(ends_at - starts_at), 0) AS ms FROM events WHERE deleted_at IS NULL").get()
@@ -31,6 +32,10 @@ export const listEvents = (database, filter) => {
31
32
  clauses.push("e.starts_at < ?");
32
33
  params.push(filter.to + DAY);
33
34
  }
35
+ if (filter.startsFrom !== undefined && filter.startsFromDate !== undefined) {
36
+ clauses.push("e.starts_at >= ?");
37
+ params.push(filter.startsFrom - DAY);
38
+ }
34
39
  if (filter.calendarId !== undefined) {
35
40
  clauses.push(entries("ce.calendar_id = ?"));
36
41
  params.push(filter.calendarId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.501.0",
3
+ "version": "0.502.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",