@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.
- package/CHANGELOG.md +228 -0
- package/dist/calendar/cli/answers.d.ts +7 -2
- package/dist/calendar/cli/answers.js +12 -4
- package/dist/calendar/cli/index.d.ts +10 -1
- package/dist/calendar/cli/index.js +18 -11
- package/dist/calendar/cli/search-all.d.ts +17 -0
- package/dist/calendar/cli/search-all.js +72 -0
- package/dist/calendar/feeds.d.ts +19 -0
- package/dist/calendar/feeds.js +52 -9
- package/dist/calendar/source.d.ts +10 -2
- package/dist/calendar/sync.d.ts +2 -0
- package/dist/calendar/sync.js +10 -6
- package/dist/calendar/testing/memory.js +39 -0
- package/dist/cli/answer-entity.d.ts +4 -0
- package/dist/cli/answer-entity.js +5 -0
- package/dist/cli/bot/context.d.ts +2 -0
- package/dist/cli/compact.d.ts +41 -0
- package/dist/cli/compact.js +313 -0
- package/dist/cli/context.d.ts +11 -2
- package/dist/cli/context.js +15 -6
- package/dist/cli/envelope.d.ts +5 -1
- package/dist/cli/envelope.js +15 -4
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/messenger/search-command.d.ts +10 -0
- package/dist/cli/messenger/search-command.js +19 -13
- package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
- package/dist/cli/program.d.ts +3 -0
- package/dist/cli/program.js +7 -1
- package/dist/domain/providers.d.ts +3 -0
- package/dist/domain/providers.js +20 -4
- package/dist/domain/ref-arguments.js +3 -7
- package/dist/domain/ref-summaries.d.ts +56 -0
- package/dist/domain/ref-summaries.js +63 -0
- package/dist/entities/index.d.ts +11 -0
- package/dist/entities/relationship.d.ts +2 -0
- package/dist/entities/relationship.js +2 -0
- package/dist/entities/task-results.d.ts +10 -0
- package/dist/entities/task-results.js +1 -0
- package/dist/errors/reasons.js +1 -0
- package/dist/meetings/event-store.d.ts +10 -0
- package/dist/meetings/testing/event-store-contract.js +36 -0
- package/dist/output-detail.js +9 -5
- package/dist/output.d.ts +5 -1
- package/dist/output.js +29 -4
- package/dist/services/proposals/kinds/relationship-update.d.ts +4 -0
- package/dist/services/proposals/kinds/relationship-update.js +53 -0
- package/dist/services/proposals/registry.js +2 -0
- package/dist/store/decisions.d.ts +3 -1
- package/dist/store/decisions.js +7 -1
- package/dist/store/index.d.ts +2 -1
- package/dist/store/sqlite/accounts.d.ts +8 -0
- package/dist/store/sqlite/accounts.js +2 -2
- package/dist/store/sqlite/calendar-forget.d.ts +17 -0
- package/dist/store/sqlite/calendar-forget.js +68 -0
- package/dist/store/sqlite/calendar.js +8 -0
- package/dist/store/sqlite/evidence-sources.d.ts +2 -2
- package/dist/store/sqlite/evidence-sources.js +41 -77
- package/dist/store/sqlite/knowledge.d.ts +17 -23
- package/dist/store/sqlite/knowledge.js +13 -13
- package/dist/store/sqlite/manifest.js +2 -0
- package/dist/store/sqlite/migrations.generated.js +12 -0
- package/dist/store/sqlite/notes.d.ts +5 -0
- package/dist/store/sqlite/notes.js +6 -2
- package/dist/store/sqlite/open-store.js +2 -0
- package/dist/store/sqlite/proposed-actions.d.ts +1 -1
- package/dist/store/sqlite/proposed-actions.js +10 -2
- package/dist/store/sqlite/ref-summaries.d.ts +17 -0
- package/dist/store/sqlite/ref-summaries.js +378 -0
- package/dist/store/sqlite/relation-proposals.d.ts +40 -0
- package/dist/store/sqlite/relation-proposals.js +88 -1
- package/dist/store/sqlite/schema.d.ts +16 -0
- package/dist/store/sqlite/schema.js +1 -0
- package/dist/store/sqlite/suggested-links.d.ts +10 -0
- package/dist/store/sqlite/suggested-links.js +68 -0
- package/dist/store/store.d.ts +3 -0
- package/dist/store/store.js +5 -0
- package/package.json +3 -3
- package/parity.json +4 -0
package/dist/calendar/feeds.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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;
|
package/dist/calendar/sync.d.ts
CHANGED
|
@@ -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 {
|
package/dist/calendar/sync.js
CHANGED
|
@@ -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 =
|
|
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
|
+
};
|