@wirecat/cli-core 0.547.0 → 0.549.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/dist/calendar/cli/answers.d.ts +7 -2
  3. package/dist/calendar/cli/answers.js +12 -4
  4. package/dist/calendar/cli/index.d.ts +10 -1
  5. package/dist/calendar/cli/index.js +18 -11
  6. package/dist/calendar/cli/search-all.d.ts +17 -0
  7. package/dist/calendar/cli/search-all.js +72 -0
  8. package/dist/calendar/feeds.d.ts +19 -0
  9. package/dist/calendar/feeds.js +52 -9
  10. package/dist/calendar/source.d.ts +10 -2
  11. package/dist/calendar/sync.d.ts +2 -0
  12. package/dist/calendar/sync.js +10 -6
  13. package/dist/calendar/testing/memory.js +39 -0
  14. package/dist/cli/answer-entity.d.ts +4 -0
  15. package/dist/cli/answer-entity.js +5 -0
  16. package/dist/cli/bot/context.d.ts +2 -0
  17. package/dist/cli/compact.d.ts +41 -0
  18. package/dist/cli/compact.js +313 -0
  19. package/dist/cli/context.d.ts +11 -2
  20. package/dist/cli/context.js +15 -6
  21. package/dist/cli/envelope.d.ts +5 -1
  22. package/dist/cli/envelope.js +15 -4
  23. package/dist/cli/index.d.ts +2 -0
  24. package/dist/cli/index.js +2 -0
  25. package/dist/cli/messenger/search-command.d.ts +10 -0
  26. package/dist/cli/messenger/search-command.js +19 -13
  27. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  28. package/dist/cli/program.d.ts +3 -0
  29. package/dist/cli/program.js +7 -1
  30. package/dist/domain/providers.d.ts +3 -0
  31. package/dist/domain/providers.js +20 -4
  32. package/dist/domain/ref-arguments.js +3 -7
  33. package/dist/domain/ref-summaries.d.ts +56 -0
  34. package/dist/domain/ref-summaries.js +63 -0
  35. package/dist/entities/index.d.ts +11 -0
  36. package/dist/entities/relationship.d.ts +2 -0
  37. package/dist/entities/relationship.js +2 -0
  38. package/dist/entities/task-results.d.ts +10 -0
  39. package/dist/entities/task-results.js +1 -0
  40. package/dist/errors/reasons.js +1 -0
  41. package/dist/meetings/event-store.d.ts +10 -0
  42. package/dist/meetings/testing/event-store-contract.js +36 -0
  43. package/dist/output-detail.js +9 -5
  44. package/dist/output.d.ts +5 -1
  45. package/dist/output.js +29 -4
  46. package/dist/services/proposals/kinds/relationship-update.d.ts +4 -0
  47. package/dist/services/proposals/kinds/relationship-update.js +53 -0
  48. package/dist/services/proposals/registry.js +2 -0
  49. package/dist/store/decisions.d.ts +3 -1
  50. package/dist/store/decisions.js +7 -1
  51. package/dist/store/index.d.ts +2 -1
  52. package/dist/store/sqlite/accounts.d.ts +8 -0
  53. package/dist/store/sqlite/accounts.js +2 -2
  54. package/dist/store/sqlite/calendar-forget.d.ts +17 -0
  55. package/dist/store/sqlite/calendar-forget.js +68 -0
  56. package/dist/store/sqlite/calendar.js +8 -0
  57. package/dist/store/sqlite/evidence-sources.d.ts +2 -2
  58. package/dist/store/sqlite/evidence-sources.js +41 -77
  59. package/dist/store/sqlite/knowledge.d.ts +17 -23
  60. package/dist/store/sqlite/knowledge.js +13 -13
  61. package/dist/store/sqlite/manifest.js +2 -0
  62. package/dist/store/sqlite/migrations.generated.js +12 -0
  63. package/dist/store/sqlite/notes.d.ts +5 -0
  64. package/dist/store/sqlite/notes.js +6 -2
  65. package/dist/store/sqlite/open-store.js +2 -0
  66. package/dist/store/sqlite/proposed-actions.d.ts +1 -1
  67. package/dist/store/sqlite/proposed-actions.js +10 -2
  68. package/dist/store/sqlite/ref-summaries.d.ts +17 -0
  69. package/dist/store/sqlite/ref-summaries.js +378 -0
  70. package/dist/store/sqlite/relation-proposals.d.ts +40 -0
  71. package/dist/store/sqlite/relation-proposals.js +88 -1
  72. package/dist/store/sqlite/schema.d.ts +16 -0
  73. package/dist/store/sqlite/schema.js +1 -0
  74. package/dist/store/sqlite/suggested-links.d.ts +10 -0
  75. package/dist/store/sqlite/suggested-links.js +68 -0
  76. package/dist/store/store.d.ts +3 -0
  77. package/dist/store/store.js +5 -0
  78. package/package.json +3 -3
  79. package/parity.json +4 -0
@@ -26,6 +26,8 @@ export const feedAddress = (text) => {
26
26
  throw invalidAddress("must not hold a user name or a password");
27
27
  return url;
28
28
  };
29
+ /** An `http://` address: allowed, but the feed and any token in its address travel unencrypted. */
30
+ export const isPlainHttp = (text) => feedAddress(text).protocol === "http:";
29
31
  const sameAddress = (a, b) => feedAddress(a).href === feedAddress(b).href;
30
32
  const readBody = async (response, name, maxBytes) => {
31
33
  const reader = response.body?.getReader();
@@ -58,8 +60,17 @@ const STATUS_ERRORS = {
58
60
  * The feed's text: http or https, each redirect followed by hand and only to https, the body cut off at its limit and
59
61
  * the request at its time. Errors name the feed and the status, never the address, which may hold a token.
60
62
  */
61
- export const fetchFeed = async (address, options) => {
62
- const { name, limits = FEED_LIMITS } = options;
63
+ export const fetchFeed = async (address, options) => (await fetchFeedIfChanged(address, options)).text;
64
+ /**
65
+ * `fetchFeed` that sends `since` as `If-None-Match` and `If-Modified-Since`: `"unchanged"` when the server answers 304,
66
+ * otherwise the text and the validators to send next time.
67
+ */
68
+ export const fetchFeedIfChanged = async (address, options) => {
69
+ const { name, limits = FEED_LIMITS, since } = options;
70
+ const conditional = {
71
+ ...(since?.etag ? { "if-none-match": since.etag } : {}),
72
+ ...(since?.lastModified ? { "if-modified-since": since.lastModified } : {}),
73
+ };
63
74
  const run = options.fetch ?? fetch;
64
75
  const timeout = AbortSignal.timeout(limits.timeoutMs);
65
76
  const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
@@ -69,8 +80,12 @@ export const fetchFeed = async (address, options) => {
69
80
  const response = await run(url, {
70
81
  redirect: "manual",
71
82
  signal,
72
- headers: { accept: "text/calendar, text/plain;q=0.5, */*;q=0.1" },
83
+ headers: { accept: "text/calendar, text/plain;q=0.5, */*;q=0.1", ...conditional },
73
84
  });
85
+ if (response.status === 304 && Object.keys(conditional).length) {
86
+ await response.body?.cancel();
87
+ return "unchanged";
88
+ }
74
89
  const location = response.headers.get("location");
75
90
  if (response.status >= 300 && response.status < 400 && location) {
76
91
  await response.body?.cancel();
@@ -95,7 +110,11 @@ export const fetchFeed = async (address, options) => {
95
110
  retryable: response.status >= 500 || response.status === 429,
96
111
  });
97
112
  }
98
- return await readBody(response, name, limits.maxBytes);
113
+ const text = await readBody(response, name, limits.maxBytes);
114
+ return {
115
+ text,
116
+ validators: { etag: response.headers.get("etag"), lastModified: response.headers.get("last-modified") },
117
+ };
99
118
  }
100
119
  }
101
120
  catch (error) {
@@ -213,6 +232,19 @@ export const readFeed = (text, zone, window) => {
213
232
  skipped,
214
233
  };
215
234
  };
235
+ const lastRead = (token, window) => {
236
+ if (token === null)
237
+ return null;
238
+ try {
239
+ const held = JSON.parse(token);
240
+ if (held?.v !== 1 || held.window?.from !== window.from || held.window.to !== window.to || !held.validators)
241
+ return null;
242
+ return held;
243
+ }
244
+ catch {
245
+ return null;
246
+ }
247
+ };
216
248
  const calendarOf = (feed, timezone = null) => ({
217
249
  externalId: `${FEED_PREFIX}${feed.id}`,
218
250
  title: feed.title,
@@ -236,8 +268,9 @@ export const icsFeeds = (options) => {
236
268
  });
237
269
  return feed;
238
270
  };
239
- const read = (url, name) => fetchFeed(url, {
271
+ const read = (url, name, since) => fetchFeedIfChanged(url, {
240
272
  name,
273
+ ...(since ? { since } : {}),
241
274
  ...(options.fetch ? { fetch: options.fetch } : {}),
242
275
  ...(options.signal ? { signal: options.signal } : {}),
243
276
  ...(options.limits ? { limits: options.limits } : {}),
@@ -248,9 +281,16 @@ export const icsFeeds = (options) => {
248
281
  async calendars() {
249
282
  return (await options.registry.read()).map((feed) => calendarOf(feed));
250
283
  },
251
- async changes(calendar, { window }) {
284
+ async changes(calendar, { cursor, window }) {
252
285
  const feed = await feedOf(calendar.externalId);
253
- const text = await read(feed.url, `feed ${calendar.id}`);
286
+ const held = lastRead(cursor, window);
287
+ const got = await read(feed.url, `feed ${calendar.id}`, held?.validators);
288
+ if (got === "unchanged") {
289
+ const [events, skipped] = [held?.events ?? 0, held?.skipped ?? 0];
290
+ stats.set(calendar.id, { calendarId: calendar.id, events, skipped, unchanged: true });
291
+ return { entries: [], removed: [], cursor, unchanged: true };
292
+ }
293
+ const { text, validators } = got;
254
294
  const found = readFeed(text, calendar.timezone ?? options.zone, window);
255
295
  for (const entry of found.entries) {
256
296
  const changed = found.overrides.get(entry.externalId);
@@ -258,7 +298,10 @@ export const icsFeeds = (options) => {
258
298
  overridesOf.set(entry, changed);
259
299
  }
260
300
  stats.set(calendar.id, { calendarId: calendar.id, events: found.events, skipped: found.skipped });
261
- return { entries: found.entries, removed: [], cursor: null };
301
+ const next = validators.etag || validators.lastModified
302
+ ? { v: 1, validators, window, events: found.events, skipped: found.skipped }
303
+ : null;
304
+ return { entries: found.entries, removed: [], cursor: next && JSON.stringify(next) };
262
305
  },
263
306
  };
264
307
  return {
@@ -271,7 +314,7 @@ export const icsFeeds = (options) => {
271
314
  throw new CliError("validation_error", "this profile follows a feed at this address already", {
272
315
  reason: "feed.duplicate",
273
316
  });
274
- const found = readFeed(await read(address, "the feed"), options.zone);
317
+ const found = readFeed((await read(address, "the feed")).text, options.zone);
275
318
  const feed = {
276
319
  id: randomBytes(6).toString("hex"),
277
320
  url: address,
@@ -13,7 +13,10 @@ export interface CalendarSourceTraits {
13
13
  expands: "on-request" | "in-changes" | "local";
14
14
  /** The cursor encodes the window (Graph delta): a new window means a new full round. */
15
15
  cursorHoldsWindow: boolean;
16
- /** No incremental cursor: every run is a full list (an ICS feed). */
16
+ /**
17
+ * No incremental cursor: every run is a full list (an ICS feed). The token the last list gave still reaches
18
+ * `changes()`, so the source can ask whether anything changed since.
19
+ */
17
20
  fullListOnly: boolean;
18
21
  }
19
22
  export interface ChangePage {
@@ -27,6 +30,11 @@ export interface ChangePage {
27
30
  nextPage?: string;
28
31
  /** On the last page: the cursor for the next run; `null` when the source has none. */
29
32
  cursor?: string | null;
33
+ /**
34
+ * A full-list source's list is the one its token was given with (an ICS feed's 304): nothing was read, so nothing
35
+ * is saved and nothing stored is marked unseen. Only on a single page with no entries.
36
+ */
37
+ unchanged?: true;
30
38
  }
31
39
  /** `reset`: the cursor is dead (Google 410, Graph `syncStateNotFound`, CalDAV `valid-sync-token`). */
32
40
  export type ChangeResult = ChangePage | {
@@ -61,7 +69,7 @@ export interface CalendarSource {
61
69
  readonly provider: string;
62
70
  readonly traits: CalendarSourceTraits;
63
71
  calendars(): Promise<CalendarInput[]>;
64
- /** `cursor: null` asks for a full list of the window. */
72
+ /** `cursor: null` asks for a full list of the window; a `fullListOnly` source lists in full whatever it is given. */
65
73
  changes(calendar: StoredCalendar, request: {
66
74
  cursor: string | null;
67
75
  page?: string;
@@ -23,6 +23,8 @@ export interface PendingRound {
23
23
  series: string[];
24
24
  /** A full round asks every series it lists; otherwise only those that changed. Absent reads as true. */
25
25
  everySeries?: boolean;
26
+ /** The source said its list is unchanged since `from`: the round marks nothing unseen. */
27
+ unchanged?: true;
26
28
  }
27
29
  /** What the engine keeps in `EventStore.cursor`. Version 1 had no `round`, `updatedAt` or `done`. */
28
30
  export interface CalendarCursor {
@@ -29,7 +29,7 @@ const roundOf = (value) => {
29
29
  round.series.some((id) => typeof id !== "string") ||
30
30
  (round.everySeries !== undefined && typeof round.everySeries !== "boolean"))
31
31
  return undefined;
32
- const { full, from, window, startedAt, reset, page, listed, next, series, everySeries } = round;
32
+ const { full, from, window, startedAt, reset, page, listed, next, series, everySeries, unchanged } = round;
33
33
  return {
34
34
  full,
35
35
  from,
@@ -41,6 +41,7 @@ const roundOf = (value) => {
41
41
  next,
42
42
  series,
43
43
  ...(everySeries === false ? { everySeries } : {}),
44
+ ...(unchanged === true ? { unchanged } : {}),
44
45
  };
45
46
  };
46
47
  export const parseCalendarCursor = (value) => {
@@ -87,9 +88,9 @@ export const calendarSyncWindow = (now) => {
87
88
  return { from: Date.UTC(year - 1, month, date), to: Date.UTC(year, month + 3, date) };
88
89
  };
89
90
  const CONTINUE = "run sync again to continue";
90
- const freshRound = (full, from, window, now, reset = false, everySeries = true) => ({
91
+ const freshRound = (full, from, window, now, reset = false, everySeries = true, keepToken = false) => ({
91
92
  full,
92
- from: full ? null : from,
93
+ from: full && !keepToken ? null : from,
93
94
  window,
94
95
  startedAt: now,
95
96
  reset,
@@ -112,7 +113,7 @@ const roundFor = (source, stored, { window, now }) => {
112
113
  // The daily full round asks only the series that changed, and every series once the window's end moved on far
113
114
  // enough that their new slots matter (foundation §6.2 step 7).
114
115
  const everySeries = !full || stored?.slotsTo === undefined || window.to - stored.slotsTo >= SLOTS_EVERY;
115
- return freshRound(full, stored?.token ?? null, window, now, false, everySeries);
116
+ return freshRound(full, stored?.token ?? null, window, now, false, everySeries, source.traits.fullListOnly);
116
117
  };
117
118
  const deadList = (provider) => new CliError("invalid_response", `${provider} refused a full list as a dead cursor`);
118
119
  /**
@@ -218,7 +219,9 @@ const syncOne = async ({ source, store, calendar, window, now, expand, budget, s
218
219
  }
219
220
  if (page.removed.length)
220
221
  result.removed += await store.removeEntries(calendar.id, page.removed, now);
221
- if (page.nextPage === undefined)
222
+ if (page.unchanged && source.traits.fullListOnly && page.entries.length === 0 && page.nextPage === undefined)
223
+ round = { ...round, page: null, listed: true, next: page.cursor ?? null, unchanged: true };
224
+ else if (page.nextPage === undefined)
222
225
  round = { ...round, page: null, listed: true, next: page.cursor ?? null };
223
226
  else {
224
227
  if (seen.has(page.nextPage))
@@ -260,7 +263,8 @@ const syncOne = async ({ source, store, calendar, window, now, expand, budget, s
260
263
  }
261
264
  if (signal?.aborted)
262
265
  return "cancelled";
263
- result.unseen = round.full ? await store.markUnseen(calendar.id, round.startedAt, round.window, now) : 0;
266
+ result.unseen =
267
+ round.full && !round.unchanged ? await store.markUnseen(calendar.id, round.startedAt, round.window, now) : 0;
264
268
  await save({
265
269
  token: round.next,
266
270
  window: round.window,
@@ -343,6 +343,45 @@ export const memoryEventStore = () => {
343
343
  async setCalendarSynced(calendarId, synced) {
344
344
  calendarOf(calendarId).synced = synced;
345
345
  },
346
+ async forgetCalendar(calendarId, now) {
347
+ const calendar = calendarOf(calendarId);
348
+ const mine = entries.filter((one) => one.calendarId === calendarId);
349
+ const ids = new Set(mine.map(({ id }) => id));
350
+ const eventIds = new Set(mine.flatMap(({ eventId }) => (eventId === null ? [] : [eventId])));
351
+ const seriesIds = new Set(mine.flatMap(({ eventSeriesId }) => (eventSeriesId === null ? [] : [eventSeriesId])));
352
+ for (let at = entries.length - 1; at >= 0; at--)
353
+ if (ids.has(entries[at].id))
354
+ entries.splice(at, 1);
355
+ for (let at = revisions.length - 1; at >= 0; at--)
356
+ if (ids.has(revisions[at].entryId))
357
+ revisions.splice(at, 1);
358
+ for (let at = reviews.length - 1; at >= 0; at--)
359
+ if (reviews[at].linkableType === "calendar_entry" &&
360
+ ids.has(reviews[at].linkableId))
361
+ reviews.splice(at, 1);
362
+ participants = participants.filter((one) => !ids.has(one.entryId));
363
+ attachments = attachments.filter((one) => !ids.has(one.entryId));
364
+ for (const id of ids)
365
+ unlinked.delete(id);
366
+ let gone = 0;
367
+ for (const id of eventIds)
368
+ if (entries.some((one) => one.eventId === id))
369
+ refreshEvent(id, now);
370
+ else if (events.delete(id))
371
+ gone++;
372
+ for (const id of seriesIds)
373
+ if (entries.some((one) => one.eventSeriesId === id))
374
+ refreshSeries(id);
375
+ else {
376
+ series.delete(id);
377
+ for (const event of events.values())
378
+ if (event.eventSeriesId === id)
379
+ event.eventSeriesId = null;
380
+ }
381
+ cursors.delete(calendarId);
382
+ calendars.splice(calendars.indexOf(calendar), 1);
383
+ return { entries: ids.size, events: gone };
384
+ },
346
385
  async saveEntries(calendarId, list, now) {
347
386
  calendarOf(calendarId);
348
387
  for (const input of list)
@@ -1,8 +1,12 @@
1
1
  import type { Command } from "commander";
2
2
  import type * as v from "valibot";
3
+ import type { RowShape } from "./compact.js";
3
4
  /** Provider-specific wire fields must not redefine an entity shared by every CLI. */
4
5
  export declare const answerEntity: (command: Command, name: string, schema: v.GenericSchema, options?: {
5
6
  project?: v.GenericSchema;
7
+ row?: RowShape;
6
8
  }) => Command;
7
9
  export declare const answerEntitySchema: (command: Command | undefined) => v.GenericSchema | undefined;
8
10
  export declare const answerProjectionSchema: (command: Command | undefined) => v.GenericSchema | undefined;
11
+ /** The row a command declared with `answerEntity(…, { row })`, while it still answers that entity. */
12
+ export declare const answerRow: (command: Command | undefined) => RowShape | undefined;
@@ -29,3 +29,8 @@ export const answerProjectionSchema = (command) => {
29
29
  const own = command && schemas.get(command);
30
30
  return own && metaOf(command).output?.entity === own.name ? own.project : undefined;
31
31
  };
32
+ /** The row a command declared with `answerEntity(…, { row })`, while it still answers that entity. */
33
+ export const answerRow = (command) => {
34
+ const own = command && schemas.get(command);
35
+ return own && metaOf(command).output?.entity === own.name ? own.row : undefined;
36
+ };
@@ -15,6 +15,8 @@ export declare const botContext: (command: Command, bot: BotMessenger) => {
15
15
  format: import("@wirecat/cli-base").RenderFormat;
16
16
  color: boolean;
17
17
  streams: import("@wirecat/cli-base").Streams;
18
+ compact?: boolean;
19
+ full?: boolean;
18
20
  env: NodeJS.ProcessEnv;
19
21
  keyring?: import("@wirecat/cli-base").KeyringStore;
20
22
  run: <T>(body: (events: import("../index.js").EventSink) => Promise<T>, options?: {
@@ -0,0 +1,41 @@
1
+ import type { Command } from "commander";
2
+ import type { Body } from "./envelope.js";
3
+ /** How one column prints. `text` by default. */
4
+ export type RowValue = "text" | "time" | "day" | "count" | "files" | "reply" | "forward";
5
+ export interface RowColumn {
6
+ /** A snake_case field path in the item (`sender_name`, `last.timestamp`); of several, the first that is set. */
7
+ field: string | readonly string[];
8
+ as?: RowValue;
9
+ /** Printed for a field that is `true`, and after a count: `{ field: ["sender_is_me", "sender_name"], tag: "me" }`. */
10
+ tag?: string;
11
+ }
12
+ export interface RowShape {
13
+ /** Printed in order, joined by " · "; the first is the ref. A missing value is left out. */
14
+ columns: readonly RowColumn[];
15
+ /** The free text, printed last; the first non-empty field wins. */
16
+ text?: readonly string[];
17
+ /** An item answer's lists, drawn as rows of another entity: `{ items: "evidence_item" }`. */
18
+ sections?: Readonly<Record<string, string>>;
19
+ }
20
+ export interface CompactOptions {
21
+ /** IANA zone for times; the answer's `meta.timezone` wins. */
22
+ timezone: string;
23
+ /** Characters of text kept per row; 0 keeps all (`--full`). Default 400. */
24
+ textChars?: number;
25
+ }
26
+ export declare const TEXT_CHARS = 400;
27
+ /** Rows for cli-core's shared entities. */
28
+ export declare const SHARED_ROWS: Readonly<Record<string, RowShape>>;
29
+ /** Any record: what names it, what it is called, what state it is in, then what it says. */
30
+ export declare const GENERIC_ROW: RowShape;
31
+ export type RowsResolver = (command: Command | undefined) => RowShape | undefined;
32
+ /** An app's own rows by entity name (or `name.view`). */
33
+ export type RowTable = Readonly<Record<string, RowShape>>;
34
+ /** The row an entity named in a section is drawn with: the app's, the shared one, else the generic one. */
35
+ export declare const rowsOfEntity: (table?: RowTable) => (entity: string) => RowShape;
36
+ /** An app's own rows by entity name, as `entityFields` takes its fields; a shared name is refused. */
37
+ export declare const entityRows: (table: RowTable) => RowsResolver;
38
+ /** The row a command's answer is drawn with: its own, its app's, the shared one, else the generic one. */
39
+ export declare const rowShapeOf: (command: Command | undefined, resolve?: RowsResolver) => RowShape;
40
+ /** The whole answer as compact text: header, rows, footer. Pure: no store, no clock. */
41
+ export declare const compactText: <T>(body: Body<T>, shape: RowShape, options: CompactOptions, rowsOf?: (entity: string) => RowShape) => string;
@@ -0,0 +1,313 @@
1
+ import { visibleControls } from "@wirecat/cli-base";
2
+ import { metaOf } from "@wirecat/cli-base/commands";
3
+ import { ENTITIES } from "../entities/index.js";
4
+ import { answerRow } from "./answer-entity.js";
5
+ export const TEXT_CHARS = 400;
6
+ const sender = { field: ["sender_is_me", "sender_name"], tag: "me" };
7
+ const messageRow = {
8
+ columns: [
9
+ { field: "ref" },
10
+ { field: "timestamp", as: "time" },
11
+ { field: "chat_title" },
12
+ sender,
13
+ { field: ["reply_to_id", "reply_to.id"], as: "reply" },
14
+ { field: "forwarded_from", as: "forward" },
15
+ { field: "attachments", as: "files" },
16
+ ],
17
+ text: ["text", "transcript", "excerpt"],
18
+ };
19
+ /** Rows for cli-core's shared entities. */
20
+ export const SHARED_ROWS = {
21
+ message: messageRow,
22
+ "message.summary": messageRow,
23
+ "message.hit": {
24
+ columns: [
25
+ { field: "ref" },
26
+ { field: "timestamp", as: "time" },
27
+ { field: "chat_title" },
28
+ sender,
29
+ { field: "attachments", as: "files" },
30
+ ],
31
+ text: ["excerpt", "text"],
32
+ },
33
+ chat: {
34
+ columns: [
35
+ { field: "ref" },
36
+ { field: "title" },
37
+ { field: "kind" },
38
+ { field: "unread_count", as: "count", tag: "unread" },
39
+ { field: "last_message_at", as: "time" },
40
+ ],
41
+ },
42
+ };
43
+ /** Any record: what names it, what it is called, what state it is in, then what it says. */
44
+ export const GENERIC_ROW = {
45
+ columns: [
46
+ { field: ["key", "ref", "id"] },
47
+ { field: ["title", "name", "path"] },
48
+ { field: ["state", "status", "due_at"] },
49
+ ],
50
+ text: ["text", "excerpt"],
51
+ };
52
+ /** The row an entity named in a section is drawn with: the app's, the shared one, else the generic one. */
53
+ export const rowsOfEntity = (table = {}) => (entity) => (Object.hasOwn(table, entity) ? table[entity] : undefined) ?? SHARED_ROWS[entity] ?? GENERIC_ROW;
54
+ const declaredNames = (command) => {
55
+ const output = command ? metaOf(command).output : undefined;
56
+ if (!output)
57
+ return [];
58
+ return output.view ? [`${output.entity}.${output.view}`, output.entity] : [output.entity];
59
+ };
60
+ /** An app's own rows by entity name, as `entityFields` takes its fields; a shared name is refused. */
61
+ export const entityRows = (table) => {
62
+ const shared = Object.keys(table).filter((name) => name in ENTITIES || name in SHARED_ROWS);
63
+ if (shared.length > 0)
64
+ throw new Error(`entityRows: ${shared.join(", ")} is cli-core's entity; name yours apart`);
65
+ return (command) => {
66
+ const name = declaredNames(command).find((one) => Object.hasOwn(table, one));
67
+ return name === undefined ? undefined : table[name];
68
+ };
69
+ };
70
+ /** The row a command's answer is drawn with: its own, its app's, the shared one, else the generic one. */
71
+ export const rowShapeOf = (command, resolve) => {
72
+ const own = answerRow(command) ?? resolve?.(command);
73
+ if (own)
74
+ return own;
75
+ const shared = declaredNames(command).find((name) => Object.hasOwn(SHARED_ROWS, name));
76
+ return shared === undefined ? GENERIC_ROW : SHARED_ROWS[shared];
77
+ };
78
+ const isFields = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
79
+ const present = (value) => value !== null && value !== undefined && value !== "" && value !== false && !(Array.isArray(value) && !value.length);
80
+ const at = (item, path) => path.split(".").reduce((value, key) => (isFields(value) ? value[key] : undefined), item);
81
+ const fieldsOfColumn = (field) => (typeof field === "string" ? [field] : field);
82
+ // A record or a list is not one value; a file list is the only column that reads one, by its own path.
83
+ const printable = (value, column) => present(value) &&
84
+ (column?.as === "files" || column?.as === "forward" || column?.as === "reply" || !isListOrRecord(value));
85
+ const isListOrRecord = (value) => typeof value === "object" && value !== null;
86
+ const firstSet = (item, field, column) => fieldsOfColumn(field)
87
+ .map((path) => at(item, path))
88
+ .find((value) => printable(value, column));
89
+ const sourceOf = (item, column) => fieldsOfColumn(column.field).find((path) => printable(at(item, path), column));
90
+ // Whitespace runs become one space and control or direction characters show, so a text cannot start a row of its own.
91
+ const clean = (text) => visibleControls(text.replace(/\s+/g, " ").trim());
92
+ const cut = (text, chars) => {
93
+ if (chars <= 0)
94
+ return text;
95
+ const letters = Array.from(text);
96
+ return letters.length <= chars ? text : `${letters.slice(0, chars).join("")}…(+${letters.length - chars})`;
97
+ };
98
+ const clockFor = (zone) => {
99
+ const format = new Intl.DateTimeFormat("en-CA", {
100
+ timeZone: zone,
101
+ year: "numeric",
102
+ month: "2-digit",
103
+ day: "2-digit",
104
+ hour: "2-digit",
105
+ minute: "2-digit",
106
+ hourCycle: "h23",
107
+ });
108
+ return {
109
+ zone,
110
+ parts: (iso) => {
111
+ const ms = Date.parse(iso);
112
+ if (Number.isNaN(ms))
113
+ return undefined;
114
+ const part = Object.fromEntries(format.formatToParts(ms).map(({ type, value }) => [type, value]));
115
+ return { date: `${part.year}-${part.month}-${part.day}`, time: `${part.hour}:${part.minute}` };
116
+ },
117
+ };
118
+ };
119
+ const duration = (seconds) => {
120
+ const whole = Math.round(seconds);
121
+ return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
122
+ };
123
+ const fileTag = (file) => {
124
+ if (!isFields(file))
125
+ return undefined;
126
+ const kind = typeof file.kind === "string" && file.kind !== "" ? file.kind : "file";
127
+ const extra = typeof file.duration === "number" ? duration(file.duration) : typeof file.name === "string" ? file.name : "";
128
+ return `[${clean(extra ? `${kind} ${extra}` : kind)}]`;
129
+ };
130
+ const cellOf = (item, column, clock) => {
131
+ const value = firstSet(item, column.field, column);
132
+ if (!present(value))
133
+ return undefined;
134
+ switch (column.as) {
135
+ case "time":
136
+ case "day": {
137
+ if (typeof value !== "string")
138
+ return undefined;
139
+ const parts = clock.parts(value);
140
+ if (!parts)
141
+ return { text: clean(value) };
142
+ const day = parts.date.slice(5);
143
+ return { text: column.as === "day" ? day : `${day} ${parts.time}`, date: parts.date };
144
+ }
145
+ case "count":
146
+ return typeof value === "number" && value !== 0
147
+ ? { text: column.tag ? `${value} ${column.tag}` : String(value) }
148
+ : undefined;
149
+ case "files": {
150
+ const tags = (Array.isArray(value) ? value : [value]).map(fileTag).filter((tag) => tag !== undefined);
151
+ return tags.length ? { text: tags.join(" ") } : undefined;
152
+ }
153
+ case "reply": {
154
+ const id = isFields(value) ? value.id : value;
155
+ return typeof id === "string" || typeof id === "number" ? { text: `↳#${clean(String(id))}` } : undefined;
156
+ }
157
+ case "forward": {
158
+ const name = isFields(value) ? value.sender_name : value;
159
+ return { text: typeof name === "string" && name !== "" ? `↪ ${clean(name)}` : "↪" };
160
+ }
161
+ default:
162
+ if (value === true)
163
+ return column.tag ? { text: column.tag } : undefined;
164
+ if (typeof value === "string" || typeof value === "number")
165
+ return { text: clean(String(value)) };
166
+ return undefined;
167
+ }
168
+ };
169
+ const textOf = (item, shape, chars) => {
170
+ const value = (shape.text ?? [])
171
+ .map((field) => at(item, field))
172
+ .find((one) => typeof one === "string" && present(one));
173
+ return typeof value === "string" ? cut(clean(value), chars) : undefined;
174
+ };
175
+ // A line that starts with "# " is the answer's own; a row is shifted so that no item can pass for one.
176
+ const guarded = (line) => (line.startsWith("#") && !/^#[^\s#]/.test(line) ? `\\${line}` : line);
177
+ const REF = /^[a-z]+:\S*\/[^/\s]+$/;
178
+ /** The shared part of the refs up to their last `/`, when every row has one and there is more than one row. */
179
+ const sharedPrefix = (refs) => {
180
+ if (refs.length < 2 || !refs.every((ref) => ref !== undefined && REF.test(ref)))
181
+ return undefined;
182
+ const prefixes = new Set(refs.map((ref) => ref.slice(0, ref.lastIndexOf("/") + 1)));
183
+ return prefixes.size === 1 ? [...prefixes][0] : undefined;
184
+ };
185
+ const span = (dates) => {
186
+ if (!dates.length)
187
+ return undefined;
188
+ const sorted = [...dates].sort();
189
+ const first = sorted[0];
190
+ const last = sorted[sorted.length - 1];
191
+ return first === last ? first : `${first}…${last}`;
192
+ };
193
+ // A column every row prints the same, a chat's title in a list of its messages, is said once in the header.
194
+ const sameEverywhere = (rows, index) => {
195
+ const first = rows[0]?.cells[index]?.text;
196
+ return first !== undefined && rows.every(({ cells }) => cells[index]?.text === first);
197
+ };
198
+ const rowsOfItems = (items, shape, clock, chars) => {
199
+ const dates = [];
200
+ const rows = items.map((item) => {
201
+ if (!isFields(item))
202
+ return { cells: [], text: clean(String(item)) };
203
+ const cells = shape.columns.map((column) => cellOf(item, column, clock));
204
+ for (const cell of cells)
205
+ if (cell?.date)
206
+ dates.push(cell.date);
207
+ return { cells, text: textOf(item, shape, chars), item };
208
+ });
209
+ const firstColumn = shape.columns[0];
210
+ const refs = rows.map(({ item }) => item && firstColumn ? firstSet(item, firstColumn.field) : undefined);
211
+ const prefix = sharedPrefix(refs.map((ref) => (typeof ref === "string" ? ref : undefined)));
212
+ const hoisted = rows.length < 2 ? [] : shape.columns.map((_, index) => index > 0 && sameEverywhere(rows, index));
213
+ const named = hoisted.flatMap((hoist, index) => {
214
+ const column = shape.columns[index];
215
+ const item = rows[0]?.item;
216
+ if (!hoist || !column || !item)
217
+ return [];
218
+ return [`${sourceOf(item, column) ?? fieldsOfColumn(column.field)[0]}: ${rows[0]?.cells[index]?.text}`];
219
+ });
220
+ const lines = rows.map(({ cells, text, item }) => {
221
+ const parts = cells.map((cell, index) => (hoisted[index] ? undefined : cell?.text));
222
+ const shortened = prefix ? clean(prefix) : undefined;
223
+ if (shortened && parts[0]?.startsWith(shortened))
224
+ parts[0] = `#${parts[0].slice(shortened.length)}`;
225
+ const shown = [...parts, text].filter((part) => part !== undefined && part !== "");
226
+ return guarded(shown.length ? shown.join(" · ") : item ? blockLine(item, chars) : "");
227
+ });
228
+ const when = span(dates);
229
+ const header = [...(prefix ? [`ref: ${clean(prefix)}<id>`] : []), ...named, ...(when ? [when, clock.zone] : [])];
230
+ return { header, lines };
231
+ };
232
+ const blockLine = (fields, chars) => blockOf(fields, chars).join(" ");
233
+ /** A record as `field: value` lines; a nested record is indented under its field, a list of records counted. */
234
+ const blockOf = (fields, chars, indent = "") => Object.entries(fields)
235
+ .filter(([name]) => !(name === "priority" && "priority_label" in fields))
236
+ .filter(([, value]) => present(value) || value === false)
237
+ .flatMap(([name, value]) => {
238
+ const label = `${indent}${clean(name)}:`;
239
+ if (isFields(value))
240
+ return [label, ...blockOf(value, chars, `${indent} `)];
241
+ if (Array.isArray(value))
242
+ return value.every((one) => !isFields(one) && !Array.isArray(one))
243
+ ? [`${label} ${cut(clean(value.join(", ")), chars)}`]
244
+ : [`${label} ${value.length} — --json shows them`];
245
+ return [`${label} ${cut(clean(String(value)), chars)}`];
246
+ });
247
+ const footerOf = (body) => {
248
+ const lines = [];
249
+ const steps = body.next ?? [];
250
+ const lineOf = (step) => (step.kind === "command" ? (step.line ?? step.command) : undefined);
251
+ if ("items" in body && body.page.has_more) {
252
+ const more = steps.find((step) => step.step === "page.next");
253
+ const line = more?.kind === "command" ? more.line : undefined;
254
+ const page = body.page;
255
+ lines.push(`# more: ${clean(line ??
256
+ (page.mode === "cursor" && page.next_cursor
257
+ ? `--cursor ${page.next_cursor}`
258
+ : page.mode === "page" && page.next_page
259
+ ? `--page ${page.next_page}`
260
+ : "raise --limit"))}`);
261
+ }
262
+ for (const missing of body.missing ?? [])
263
+ lines.push(`# missing: ${clean(missing.field)}: ${clean(missing.why)}`);
264
+ for (const step of steps) {
265
+ if (step.step === "page.next" && "items" in body)
266
+ continue;
267
+ const line = lineOf(step);
268
+ lines.push(`# next: ${clean(step.why)}${line ? `: ${clean(line)}` : ""}`);
269
+ }
270
+ if (body.meta?.complete === false) {
271
+ const issues = (body.meta.issues ?? []).map((issue) => `${clean(issue.source)}: ${clean(String(issue.code))}`);
272
+ lines.push(`# complete: false${issues.length ? ` (${issues.join(", ")})` : ""}`);
273
+ }
274
+ if (body.meta?.truncated)
275
+ lines.push("# truncated: true");
276
+ return lines;
277
+ };
278
+ const usableZone = (zone) => {
279
+ if (!zone)
280
+ return undefined;
281
+ try {
282
+ new Intl.DateTimeFormat("en", { timeZone: zone });
283
+ return zone;
284
+ }
285
+ catch {
286
+ return undefined;
287
+ }
288
+ };
289
+ /** The whole answer as compact text: header, rows, footer. Pure: no store, no clock. */
290
+ export const compactText = (body, shape, options, rowsOf = rowsOfEntity()) => {
291
+ const clock = clockFor(usableZone(body.meta?.timezone) ?? usableZone(options.timezone) ?? "UTC");
292
+ const chars = options.textChars ?? TEXT_CHARS;
293
+ let lines = [];
294
+ if ("items" in body) {
295
+ const { header, lines: rows } = rowsOfItems(body.items, shape, clock, chars);
296
+ lines = (header.length ? [`# ${header.join(" · ")}`] : []).concat(rows.length ? rows : ["# nothing found"]);
297
+ }
298
+ else if (isFields(body.item)) {
299
+ const sections = shape.sections ?? {};
300
+ const rest = Object.fromEntries(Object.entries(body.item).filter(([name]) => !Object.hasOwn(sections, name)));
301
+ lines = blockOf(rest, chars).map(guarded);
302
+ for (const [name, entity] of Object.entries(sections)) {
303
+ const list = body.item[name];
304
+ if (!Array.isArray(list))
305
+ continue;
306
+ const { header, lines: rows } = rowsOfItems(list, rowsOf(entity), clock, chars);
307
+ lines = lines.concat([`# ${[`${clean(name)}: ${list.length}`, ...header].join(" · ")}`], rows);
308
+ }
309
+ }
310
+ else if (present(body.item))
311
+ lines = [guarded(cut(clean(String(body.item)), chars))];
312
+ return lines.concat(footerOf(body)).join("\n");
313
+ };