@wirecat/cli-core 0.395.0 → 0.397.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 +63 -1
- package/README.md +1 -0
- package/dist/background/daily-archive-timer.d.ts +40 -0
- package/dist/background/daily-archive-timer.js +181 -0
- package/dist/background/index.d.ts +2 -0
- package/dist/background/index.js +1 -0
- package/dist/cli/commands-command.js +6 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/cli/input-fields.d.ts +3 -0
- package/dist/cli/input-fields.js +27 -0
- package/dist/cli/messenger/account-command.js +1 -0
- package/dist/cli/messenger/account-sessions-command.js +3 -1
- package/dist/cli/messenger/admin-commands.js +1 -0
- package/dist/cli/messenger/admin-contacts-command.js +9 -3
- package/dist/cli/messenger/archive-commands.js +3 -1
- package/dist/cli/messenger/attachments-command.js +32 -10
- package/dist/cli/messenger/backfill-command.js +4 -0
- package/dist/cli/messenger/chats-command.js +1 -0
- package/dist/cli/messenger/chats-members-command.js +4 -0
- package/dist/cli/messenger/chats-stats-command.js +1 -0
- package/dist/cli/messenger/chats-tracking-command.js +4 -1
- package/dist/cli/messenger/complete-command.js +1 -1
- package/dist/cli/messenger/contacts-command.js +9 -3
- package/dist/cli/messenger/conversation-answers.d.ts +5 -0
- package/dist/cli/messenger/conversations-command.js +18 -1
- package/dist/cli/messenger/counters-command.js +1 -0
- package/dist/cli/messenger/download-command.js +1 -0
- package/dist/cli/messenger/flood-command.js +1 -0
- package/dist/cli/messenger/guard-commands.js +5 -4
- package/dist/cli/messenger/inbox.js +1 -0
- package/dist/cli/messenger/mcp-command.js +6 -2
- package/dist/cli/messenger/media-archive-command.d.ts +3 -0
- package/dist/cli/messenger/media-archive-command.js +72 -0
- package/dist/cli/messenger/media-archive-move-command.d.ts +3 -0
- package/dist/cli/messenger/media-archive-move-command.js +40 -0
- package/dist/cli/messenger/media-archive-timer-command.d.ts +3 -0
- package/dist/cli/messenger/media-archive-timer-command.js +70 -0
- package/dist/cli/messenger/message-answers.d.ts +5 -0
- package/dist/cli/messenger/message-writes.d.ts +5 -0
- package/dist/cli/messenger/messages-comments-command.js +1 -0
- package/dist/cli/messenger/messages-delete-command.js +1 -1
- package/dist/cli/messenger/messages-search-command.js +1 -0
- package/dist/cli/messenger/messages-stats-command.js +1 -0
- package/dist/cli/messenger/models-command.js +3 -0
- package/dist/cli/messenger/private-people-command.js +12 -10
- package/dist/cli/messenger/rankings-command.js +2 -0
- package/dist/cli/messenger/reactions-command.js +8 -2
- package/dist/cli/messenger/retention-command.js +1 -0
- package/dist/cli/messenger/review.js +1 -0
- package/dist/cli/messenger/search-answers.js +2 -0
- package/dist/cli/messenger/search-command.js +5 -1
- package/dist/cli/messenger/searches-command.d.ts +2 -0
- package/dist/cli/messenger/searches-command.js +26 -8
- package/dist/cli/messenger/server-command.js +7 -2
- package/dist/cli/messenger/store-maintenance-command.js +7 -0
- package/dist/cli/messenger/tags-command.js +6 -2
- package/dist/cli/messenger/tasks-command.js +10 -7
- package/dist/cli/messenger/transcribe-command.js +1 -0
- package/dist/cli/messenger/watch-command.js +1 -0
- package/dist/cli/program.js +5 -2
- package/dist/entities/contact-read.d.ts +119 -0
- package/dist/entities/contact-read.js +50 -0
- package/dist/entities/evidence.d.ts +40 -0
- package/dist/entities/index.d.ts +191 -2
- package/dist/entities/index.js +7 -1
- package/dist/entities/message.d.ts +9 -0
- package/dist/entities/message.js +6 -0
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +15 -0
- package/dist/meetings/mcp/index.d.ts +15 -15
- package/dist/parity/catalogue.js +1 -1
- package/dist/parity/manifest.d.ts +3 -1
- package/dist/parity/manifest.js +2 -0
- package/dist/parity/wording.js +2 -0
- package/dist/search/lucene/registry.js +2 -2
- package/dist/sends/permissions.js +7 -0
- package/dist/services/index.d.ts +5 -0
- package/dist/services/index.js +2 -0
- package/dist/services/media-archive-files.d.ts +10 -0
- package/dist/services/media-archive-files.js +110 -0
- package/dist/services/media-archive-locks.d.ts +6 -0
- package/dist/services/media-archive-locks.js +71 -0
- package/dist/services/media-archive-manifest.d.ts +29 -0
- package/dist/services/media-archive-manifest.js +116 -0
- package/dist/services/media-archive-move.d.ts +21 -0
- package/dist/services/media-archive-move.js +163 -0
- package/dist/services/media-archive.d.ts +34 -0
- package/dist/services/media-archive.js +165 -0
- package/dist/services/messages-search.js +13 -2
- package/dist/services/messages.d.ts +5 -0
- package/dist/store/search-index.d.ts +4 -1
- package/dist/store/search-index.js +10 -1
- package/dist/store/sqlite/hit-places.d.ts +12 -0
- package/dist/store/sqlite/hit-places.js +76 -0
- package/dist/store/sqlite/lucene-fields.d.ts +8 -0
- package/dist/store/sqlite/lucene-fields.js +6 -4
- package/dist/store/sqlite/lucene.js +25 -70
- package/dist/store/sqlite/manifest.js +2 -0
- package/dist/store/sqlite/migrations.generated.js +19 -0
- package/dist/store/sqlite/note-index.d.ts +11 -1
- package/dist/store/sqlite/note-index.js +18 -6
- package/dist/store/sqlite/schema.d.ts +39 -0
- package/dist/store/sqlite/schema.js +4 -0
- package/dist/store/sqlite/transcript-query.d.ts +40 -0
- package/dist/store/sqlite/transcript-query.js +108 -0
- package/dist/store/sqlite/words.d.ts +6 -0
- package/package.json +3 -3
- package/parity.json +50 -1
|
@@ -2,11 +2,12 @@ import { CliError } from "@wirecat/cli-base";
|
|
|
2
2
|
import { compileAutomaton } from "../../search/lucene/automaton.js";
|
|
3
3
|
import { isStemmed } from "../../search/lucene/resolved.js";
|
|
4
4
|
import { exhausted, QUERY_LIMITS, searchError } from "../../search/lucene/types.js";
|
|
5
|
-
import { normalize } from "../normalize.js";
|
|
6
5
|
import { emailHitsByPk } from "./emails.js";
|
|
6
|
+
import { placeHits } from "./hit-places.js";
|
|
7
7
|
import { combine, FIELD_COMPILERS, ftsMatch, SPEECH, } from "./lucene-fields.js";
|
|
8
8
|
import { hitsByPk } from "./search.js";
|
|
9
9
|
import { read } from "./transaction.js";
|
|
10
|
+
import { transcriptItems, transcriptTerms } from "./transcript-query.js";
|
|
10
11
|
export { prefixOf } from "./lucene-fields.js";
|
|
11
12
|
const MESSAGES = {
|
|
12
13
|
rows: "messages",
|
|
@@ -82,13 +83,19 @@ const compileQuery = (context, execution, boundedAttachments = false) => {
|
|
|
82
83
|
sql: `SELECT att.attachable_id FROM attachments att WHERE att.attachable_type='${corpus.item}'${which === "any" ? "" : ` AND ${which === "file" ? "NOT " : ""}${SPEECH}`} AND att.id IN (SELECT rowid FROM attachment_words WHERE attachment_words MATCH ?)`,
|
|
83
84
|
params: [match],
|
|
84
85
|
});
|
|
86
|
+
const transcripts = transcriptTerms(database, execution.root, corpus.item);
|
|
87
|
+
const said = (node, part) => transcriptItems(corpus.item, transcripts.get(node) ?? { match: ftsMatch(node), widened: [], from: node.value }, part);
|
|
85
88
|
/** A bare word also matches a transcript, a file's text and a word of a file's name. */
|
|
86
89
|
const everywhere = (node, fragment) => {
|
|
87
90
|
if (!node.implicit || fragment.sql === "0")
|
|
88
91
|
return fragment;
|
|
89
92
|
const match = ftsMatch(node);
|
|
90
|
-
const files = attachmentWords(match, "
|
|
91
|
-
const
|
|
93
|
+
const files = attachmentWords(match, "file");
|
|
94
|
+
const spoken = said(node);
|
|
95
|
+
const elsewhere = {
|
|
96
|
+
sql: `${files.sql} UNION ${spoken.sql} UNION ${namedSql}`,
|
|
97
|
+
params: [...files.params, ...spoken.params, match],
|
|
98
|
+
};
|
|
92
99
|
return {
|
|
93
100
|
...fragment,
|
|
94
101
|
sql: `(${fragment.sql} OR m.id IN (${elsewhere.sql}))`,
|
|
@@ -107,6 +114,7 @@ const compileQuery = (context, execution, boundedAttachments = false) => {
|
|
|
107
114
|
patternOf,
|
|
108
115
|
expanded,
|
|
109
116
|
attachmentWords,
|
|
117
|
+
transcripts: (node) => said(node),
|
|
110
118
|
everywhere,
|
|
111
119
|
expand: (count) => {
|
|
112
120
|
expansions += count;
|
|
@@ -284,14 +292,21 @@ const compileQuery = (context, execution, boundedAttachments = false) => {
|
|
|
284
292
|
ranked.sql.push(`f(id, rank) AS MATERIALIZED (SELECT rowid, bm25(${rankIndex.table}, 1.0, 0.0) FROM ${rankIndex.table} WHERE ${rankIndex.table} MATCH ?)`);
|
|
285
293
|
ranked.params.push(`${rankIndex.column} : (${ranking})`);
|
|
286
294
|
}
|
|
287
|
-
// Among hits the message text does not hold: a transcript, then a file's name,
|
|
288
|
-
// of different indexes do not compare, and a long spreadsheet must
|
|
289
|
-
|
|
295
|
+
// Among hits the message text does not hold: a transcript as typed, in another form, then a file's name, a file's
|
|
296
|
+
// text, and last a garbled spelling. bm25 scores of different indexes do not compare, and a long spreadsheet must
|
|
297
|
+
// not crowd out what was said.
|
|
298
|
+
const placeNodes = [...fragments.values()].flatMap(({ node, fragment }) => fragment.elsewhere && fragment.fts ? [node] : []);
|
|
299
|
+
const places = placeNodes.map(ftsMatch);
|
|
290
300
|
if (places.length && !execution.newest) {
|
|
291
|
-
|
|
292
|
-
|
|
301
|
+
const spoken = (part, tier) => placeNodes.map((node) => {
|
|
302
|
+
const one = said(node, part);
|
|
303
|
+
return { sql: `SELECT attachable_id, ${tier} FROM (${one.sql})`, params: one.params };
|
|
304
|
+
});
|
|
305
|
+
const parts = [...spoken("forms", 2), ...spoken("widened", 5)];
|
|
306
|
+
ranked.sql.push(`s(id, tier) AS MATERIALIZED (SELECT att.attachable_id, min(CASE WHEN ${SPEECH} THEN 1 ELSE 4 END) FROM attachments att WHERE att.attachable_type='${corpus.item}' AND att.id IN (SELECT rowid FROM attachment_words WHERE attachment_words MATCH ?) GROUP BY att.attachable_id UNION ALL SELECT attachable_id, 3 FROM (${namedSql})${parts.map(({ sql }) => ` UNION ALL ${sql}`).join("")})`);
|
|
307
|
+
ranked.params.push(places.join(" OR "), places.join(" OR "), ...parts.flatMap(({ params }) => params));
|
|
293
308
|
}
|
|
294
|
-
const placeTier = places.length && !execution.newest ? "coalesce((SELECT min(tier) FROM s WHERE s.id=m.id),
|
|
309
|
+
const placeTier = places.length && !execution.newest ? "coalesce((SELECT min(tier) FROM s WHERE s.id=m.id), 6)," : "";
|
|
295
310
|
if (exactTier) {
|
|
296
311
|
ranked.sql.push(`x(id) AS MATERIALIZED (SELECT rowid FROM ${corpus.words} WHERE ${corpus.words} MATCH ?)`);
|
|
297
312
|
ranked.params.push(`normalized_text : (${exactTier})`);
|
|
@@ -336,67 +351,7 @@ const compileQuery = (context, execution, boundedAttachments = false) => {
|
|
|
336
351
|
count: Number(row.count),
|
|
337
352
|
}));
|
|
338
353
|
};
|
|
339
|
-
const
|
|
340
|
-
(node.implicit || node.field === "content" || node.field === "transcript"));
|
|
341
|
-
/** Which place of each hit holds the words, first match wins in the order of `MatchPlace`. */
|
|
342
|
-
const placed = (found, pks) => {
|
|
343
|
-
if (!placeLeaves.length || !pks.length || found.length !== pks.length)
|
|
344
|
-
return found;
|
|
345
|
-
const keys = JSON.stringify(pks);
|
|
346
|
-
const wordy = placeLeaves.filter(({ node }) => /[\p{L}\p{N}]/u.test(normalize(node.value)));
|
|
347
|
-
if (!wordy.length)
|
|
348
|
-
return found;
|
|
349
|
-
const match = wordy.map(({ node }) => ftsMatch(node)).join(" OR ");
|
|
350
|
-
const implicit = placeLeaves.some(({ node }) => node.implicit);
|
|
351
|
-
const inText = new Set(implicit
|
|
352
|
-
? database
|
|
353
|
-
.prepare(`SELECT rowid AS id FROM ${corpus.words} WHERE ${corpus.words} MATCH ? AND rowid IN (SELECT value FROM json_each(?))${placeLeaves.some(({ fragment }) => fragment.stems)
|
|
354
|
-
? ` UNION SELECT rowid FROM ${corpus.stems} WHERE ${corpus.stems} MATCH ? AND rowid IN (SELECT value FROM json_each(?))`
|
|
355
|
-
: ""}`)
|
|
356
|
-
.all(`normalized_text : (${match})`, keys, ...(placeLeaves.some(({ fragment }) => fragment.stems)
|
|
357
|
-
? [
|
|
358
|
-
`stems : (${placeLeaves.flatMap(({ fragment }) => (fragment.stems ? [`(${fragment.stems})`] : [])).join(" OR ")})`,
|
|
359
|
-
keys,
|
|
360
|
-
]
|
|
361
|
-
: []))
|
|
362
|
-
.map(({ id }) => Number(id))
|
|
363
|
-
: []);
|
|
364
|
-
const files = database
|
|
365
|
-
.prepare(`SELECT att.attachable_id AS id, att.text AS text, ${SPEECH} AS speech FROM attachments att WHERE att.attachable_type='${corpus.item}' AND att.attachable_id IN (SELECT value FROM json_each(?)) AND att.id IN (SELECT rowid FROM attachment_words WHERE attachment_words MATCH ?) ORDER BY att.position`)
|
|
366
|
-
.all(keys, match);
|
|
367
|
-
const fileFields = new Set(placeLeaves.flatMap(({ node }) => (node.implicit ? ["transcript", "content"] : [node.field])));
|
|
368
|
-
const allNames = implicit
|
|
369
|
-
? database
|
|
370
|
-
.prepare(`SELECT att.attachable_id AS id, att.name AS name FROM attachments att WHERE att.attachable_type='${corpus.item}' AND att.attachable_id IN (SELECT value FROM json_each(?)) AND att.name IS NOT NULL ORDER BY att.position`)
|
|
371
|
-
.all(keys)
|
|
372
|
-
: [];
|
|
373
|
-
return found.map((hit, index) => {
|
|
374
|
-
const pk = pks[index];
|
|
375
|
-
const transcript = files.find((row) => Number(row.id) === pk && Number(row.speech) === 1);
|
|
376
|
-
const file = files.find((row) => Number(row.id) === pk && Number(row.speech) !== 1);
|
|
377
|
-
const name = allNames.find((row) => {
|
|
378
|
-
if (Number(row.id) !== pk)
|
|
379
|
-
return false;
|
|
380
|
-
const words = normalize(String(row.name)).split(/[^\p{L}\p{N}]+/u);
|
|
381
|
-
return wordy.some(({ node }) => {
|
|
382
|
-
const phrase = normalize(node.value).match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
383
|
-
if (node.slop !== undefined)
|
|
384
|
-
return phrase.every((word) => words.includes(word));
|
|
385
|
-
return words.some((_, at) => phrase.every((word, offset) => words[at + offset] === word));
|
|
386
|
-
});
|
|
387
|
-
});
|
|
388
|
-
const place = inText.has(pk)
|
|
389
|
-
? ["text", hit.text]
|
|
390
|
-
: transcript && fileFields.has("transcript")
|
|
391
|
-
? ["transcript", String(transcript.text)]
|
|
392
|
-
: name
|
|
393
|
-
? ["filename", String(name.name)]
|
|
394
|
-
: file && fileFields.has("content")
|
|
395
|
-
? ["file", String(file.text)]
|
|
396
|
-
: undefined;
|
|
397
|
-
return place ? { ...hit, matchedIn: place[0], matchedText: place[1] } : hit;
|
|
398
|
-
});
|
|
399
|
-
};
|
|
354
|
+
const placed = (found, pks) => placeHits(database, corpus, [...fragments.values()], transcripts, found, pks);
|
|
400
355
|
const hits = (pks) => placed(mail ? emailHitsByPk(database, pks) : hitsByPk(context, pks), pks);
|
|
401
356
|
return {
|
|
402
357
|
corpus,
|
|
@@ -34,6 +34,8 @@ export const MANIFEST = [
|
|
|
34
34
|
{ name: "20261021214318_version-26-message-reactions", version: 26, minCompatible: 1 },
|
|
35
35
|
{ name: "20261021221808_version-27-message-delivery", version: 27, minCompatible: 1 },
|
|
36
36
|
{ name: "20261021224625_version-28-chat-state", version: 28, minCompatible: 1 },
|
|
37
|
+
{ name: "20261023105057_version-29-transcript-words", version: 29, minCompatible: 1 },
|
|
38
|
+
{ name: "20261023105101_version-29-transcript-words-triggers", version: 29, minCompatible: 1 },
|
|
37
39
|
];
|
|
38
40
|
export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
|
|
39
41
|
const migrations = [];
|
|
@@ -577,5 +577,24 @@ export const GENERATED = [
|
|
|
577
577
|
"ALTER TABLE `chats` ADD `marked_unread` integer;",
|
|
578
578
|
"ALTER TABLE `chats` ADD `unread_counted_at` integer;"
|
|
579
579
|
]
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
"name": "20261023105057_version-29-transcript-words",
|
|
583
|
+
"statements": [
|
|
584
|
+
"CREATE TABLE `transcript_index_pending` (\n\t`id` integer NOT NULL,\n\t`indexable_type` text NOT NULL,\n\tCONSTRAINT `transcript_index_pending_pk` PRIMARY KEY(`indexable_type`, `id`)\n);"
|
|
585
|
+
]
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
"name": "20261023105101_version-29-transcript-words-triggers",
|
|
589
|
+
"statements": [
|
|
590
|
+
"-- Words and stems of a voice note's transcript, rowid = attachment id. The words are the vocabulary garbled\n-- spellings are read from; the typed form is still found in attachment_words. Stemming needs JS, so the\n-- triggers only queue the attachment and the next search indexes it, as tasks do.\nCREATE VIRTUAL TABLE transcript_words USING fts5(\n normalized_text, scope,\n content = '', contentless_delete = 1,\n tokenize = 'unicode61 remove_diacritics 2', prefix = '3');",
|
|
591
|
+
"CREATE VIRTUAL TABLE transcript_words_vocab USING fts5vocab(transcript_words, 'col');",
|
|
592
|
+
"CREATE VIRTUAL TABLE transcript_stems USING fts5(\n stems, scope,\n content = '', contentless_delete = 1,\n tokenize = 'unicode61 remove_diacritics 2');",
|
|
593
|
+
"-- A transcript is the text of a message's voice attachment, whoever wrote it, as `speech()` in lucene-fields.ts.\nCREATE TRIGGER transcript_index_ai AFTER INSERT ON attachments\n WHEN new.attachable_type = 'message' AND (coalesce(new.extractor, '') LIKE 'speech:%'\n OR new.kind IN ('voice', 'video_note') OR coalesce(new.mime, '') = 'audio/ogg') BEGIN\n INSERT OR IGNORE INTO transcript_index_pending (indexable_type, id) VALUES ('attachment', new.id);\nEND;",
|
|
594
|
+
"-- Queued whether it became a transcript or stopped being one: the drain drops what is no longer live.\nCREATE TRIGGER transcript_index_au AFTER UPDATE OF text, kind, mime, extractor, attachable_type ON attachments\n WHEN (old.text IS NOT new.text OR old.kind IS NOT new.kind OR old.mime IS NOT new.mime\n OR old.extractor IS NOT new.extractor OR old.attachable_type IS NOT new.attachable_type)\n AND ((new.attachable_type = 'message' AND (coalesce(new.extractor, '') LIKE 'speech:%'\n OR new.kind IN ('voice', 'video_note') OR coalesce(new.mime, '') = 'audio/ogg'))\n OR (old.attachable_type = 'message' AND (coalesce(old.extractor, '') LIKE 'speech:%'\n OR old.kind IN ('voice', 'video_note') OR coalesce(old.mime, '') = 'audio/ogg'))) BEGIN\n INSERT OR IGNORE INTO transcript_index_pending (indexable_type, id) VALUES ('attachment', new.id);\nEND;",
|
|
595
|
+
"CREATE TRIGGER transcript_index_ad AFTER DELETE ON attachments BEGIN\n DELETE FROM transcript_words WHERE rowid = old.id;\n DELETE FROM transcript_stems WHERE rowid = old.id;\n DELETE FROM transcript_index_pending WHERE indexable_type = 'attachment' AND id = old.id;\nEND;",
|
|
596
|
+
"INSERT OR IGNORE INTO search_index_state (name, watermark, filled_through, terms_through, normalizer_version, built_at, analyzer)\n VALUES ('transcript_index', 0, 0, 0, 1, NULL, NULL);",
|
|
597
|
+
"INSERT OR IGNORE INTO transcript_index_pending (indexable_type, id)\n SELECT 'attachment', id FROM attachments WHERE attachable_type = 'message' AND coalesce(text, '') <> ''\n AND (coalesce(extractor, '') LIKE 'speech:%' OR kind IN ('voice', 'video_note') OR coalesce(mime, '') = 'audio/ogg');"
|
|
598
|
+
]
|
|
580
599
|
}
|
|
581
600
|
];
|
|
@@ -15,12 +15,14 @@ export interface NoteIndexState {
|
|
|
15
15
|
*/
|
|
16
16
|
export interface Corpus {
|
|
17
17
|
/** The singular table name: `indexable_type` in the queue, `chunkable_type` in chunks. */
|
|
18
|
-
type: "document" | "note" | "memory" | "email" | "task";
|
|
18
|
+
type: "document" | "note" | "memory" | "email" | "task" | "transcript";
|
|
19
19
|
/** Its row in `search_index_state`. */
|
|
20
20
|
index: string;
|
|
21
21
|
table: string;
|
|
22
22
|
/** Title and body as `title`/`body`, whether it is live, and its scope, for one row by `id`. */
|
|
23
23
|
read: string;
|
|
24
|
+
/** `indexable_type` in the queue when it is not `type`: the table a queued id points into. */
|
|
25
|
+
queued?: "attachment";
|
|
24
26
|
/** False when the corpus cuts its own pieces for search by meaning, as mail does by thread. */
|
|
25
27
|
pieces?: false;
|
|
26
28
|
/** The words and stems rows, when the corpus cleans its text its own way; `normalize` and `indexText` otherwise. */
|
|
@@ -66,6 +68,14 @@ export declare const CORPORA: {
|
|
|
66
68
|
stems: string;
|
|
67
69
|
};
|
|
68
70
|
};
|
|
71
|
+
readonly transcript: {
|
|
72
|
+
readonly type: "transcript";
|
|
73
|
+
readonly queued: "attachment";
|
|
74
|
+
readonly index: "transcript_index";
|
|
75
|
+
readonly table: `(SELECT a.id FROM attachments a WHERE a.attachable_type = 'message' AND ${string})`;
|
|
76
|
+
readonly read: string;
|
|
77
|
+
readonly pieces: false;
|
|
78
|
+
};
|
|
69
79
|
};
|
|
70
80
|
/** What a row is indexed as: its title, then its text. Chunk offsets point into this. */
|
|
71
81
|
export declare const noteIndexText: (title: string | null, text: string) => string;
|
|
@@ -5,6 +5,7 @@ import { normalize } from "../normalize.js";
|
|
|
5
5
|
import { dropDocumentPicture } from "./document-pictures.js";
|
|
6
6
|
import { int } from "./events.js";
|
|
7
7
|
import { indexRow } from "./index-state.js";
|
|
8
|
+
import { speech } from "./lucene-fields.js";
|
|
8
9
|
import { savedStemmers } from "./stems.js";
|
|
9
10
|
import { write } from "./transaction.js";
|
|
10
11
|
export const CORPORA = {
|
|
@@ -45,13 +46,24 @@ export const CORPORA = {
|
|
|
45
46
|
pieces: false,
|
|
46
47
|
terms: (text, stemmer) => ({ words: taskWordsOf(text), stems: [...taskStemsOf(text, stemmer)].join(" ") }),
|
|
47
48
|
},
|
|
49
|
+
transcript: {
|
|
50
|
+
type: "transcript",
|
|
51
|
+
queued: "attachment",
|
|
52
|
+
index: "transcript_index",
|
|
53
|
+
table: `(SELECT a.id FROM attachments a WHERE a.attachable_type = 'message' AND ${speech("a")})`,
|
|
54
|
+
read: `SELECT NULL AS title, coalesce(a.text, '') AS body, a.attachable_type = 'message' AND ${speech("a")} AS live, ` +
|
|
55
|
+
"'personal' AS scope FROM attachments a WHERE a.id = ?",
|
|
56
|
+
pieces: false,
|
|
57
|
+
},
|
|
48
58
|
};
|
|
49
59
|
/** What a row is indexed as: its title, then its text. Chunk offsets point into this. */
|
|
50
60
|
export const noteIndexText = (title, text) => (title ? `${title}\n\n${text}` : text);
|
|
61
|
+
const queuedAs = (corpus) => corpus.queued ?? corpus.type;
|
|
51
62
|
export const corpusIndexState = (database, corpus) => {
|
|
52
63
|
const row = indexRow(database, corpus.index);
|
|
53
|
-
const pending = Number(database
|
|
54
|
-
|
|
64
|
+
const pending = Number(database
|
|
65
|
+
.prepare(`SELECT count(*) AS n FROM ${corpus.type}_index_pending WHERE indexable_type = ?`)
|
|
66
|
+
.get(queuedAs(corpus))?.n);
|
|
55
67
|
const saved = savedStemmers(database);
|
|
56
68
|
const wanted = saved === null ? null : analyzerIdentity(saved ?? DEFAULT_STEMMERS);
|
|
57
69
|
const built = row.analyzer === null ? null : String(row.analyzer);
|
|
@@ -126,7 +138,7 @@ const writer = (database, corpus, stemmer) => {
|
|
|
126
138
|
statements.purge.run(hash, hash);
|
|
127
139
|
if (!live && corpus.type === "document")
|
|
128
140
|
dropDocumentPicture(database, id);
|
|
129
|
-
statements.dequeue.run(corpus
|
|
141
|
+
statements.dequeue.run(queuedAs(corpus), id);
|
|
130
142
|
};
|
|
131
143
|
};
|
|
132
144
|
/**
|
|
@@ -154,7 +166,7 @@ export const drainCorpus = (database, corpus, stemmerFor, { batch = 200, until =
|
|
|
154
166
|
if (state.built !== null)
|
|
155
167
|
database
|
|
156
168
|
.prepare(`INSERT OR IGNORE INTO ${queue} (indexable_type, id) SELECT ?, id FROM ${corpus.table}`)
|
|
157
|
-
.run(corpus
|
|
169
|
+
.run(queuedAs(corpus));
|
|
158
170
|
database.prepare("UPDATE search_index_state SET analyzer = ? WHERE name = ?").run(stemmer.identity, corpus.index);
|
|
159
171
|
});
|
|
160
172
|
}
|
|
@@ -167,7 +179,7 @@ export const drainCorpus = (database, corpus, stemmerFor, { batch = 200, until =
|
|
|
167
179
|
if (until())
|
|
168
180
|
break;
|
|
169
181
|
const count = write(database, () => {
|
|
170
|
-
const ids = next.all(corpus
|
|
182
|
+
const ids = next.all(queuedAs(corpus), batch).map((row) => Number(row.id));
|
|
171
183
|
for (const id of ids)
|
|
172
184
|
index(id);
|
|
173
185
|
return ids.length;
|
|
@@ -187,7 +199,7 @@ export const resetNoteIndex = (database) => {
|
|
|
187
199
|
for (const corpus of Object.values(CORPORA))
|
|
188
200
|
database
|
|
189
201
|
.prepare(`INSERT OR IGNORE INTO ${corpus.type}_index_pending (indexable_type, id) SELECT ?, id FROM ${corpus.table}`)
|
|
190
|
-
.run(corpus
|
|
202
|
+
.run(queuedAs(corpus));
|
|
191
203
|
});
|
|
192
204
|
return true;
|
|
193
205
|
};
|
|
@@ -6497,6 +6497,45 @@ export declare const attachments: import("drizzle-orm/sqlite-core").SQLiteTableW
|
|
|
6497
6497
|
};
|
|
6498
6498
|
dialect: 'sqlite';
|
|
6499
6499
|
}>;
|
|
6500
|
+
export declare const transcriptIndexPending: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
|
|
6501
|
+
name: "transcript_index_pending";
|
|
6502
|
+
schema: undefined;
|
|
6503
|
+
columns: {
|
|
6504
|
+
id: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
6505
|
+
name: string;
|
|
6506
|
+
tableName: "transcript_index_pending";
|
|
6507
|
+
dataType: "number int53";
|
|
6508
|
+
data: number;
|
|
6509
|
+
driverParam: number;
|
|
6510
|
+
notNull: true;
|
|
6511
|
+
hasDefault: false;
|
|
6512
|
+
isPrimaryKey: false;
|
|
6513
|
+
isAutoincrement: false;
|
|
6514
|
+
hasRuntimeDefault: false;
|
|
6515
|
+
enumValues: undefined;
|
|
6516
|
+
baseColumn: never;
|
|
6517
|
+
identity: undefined;
|
|
6518
|
+
generated: undefined;
|
|
6519
|
+
}, {}>;
|
|
6520
|
+
indexableType: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
6521
|
+
name: string;
|
|
6522
|
+
tableName: "transcript_index_pending";
|
|
6523
|
+
dataType: "string";
|
|
6524
|
+
data: string;
|
|
6525
|
+
driverParam: string;
|
|
6526
|
+
notNull: true;
|
|
6527
|
+
hasDefault: false;
|
|
6528
|
+
isPrimaryKey: false;
|
|
6529
|
+
isAutoincrement: false;
|
|
6530
|
+
hasRuntimeDefault: false;
|
|
6531
|
+
enumValues: [string, ...string[]];
|
|
6532
|
+
baseColumn: never;
|
|
6533
|
+
identity: undefined;
|
|
6534
|
+
generated: undefined;
|
|
6535
|
+
}, {}>;
|
|
6536
|
+
};
|
|
6537
|
+
dialect: 'sqlite';
|
|
6538
|
+
}>;
|
|
6500
6539
|
export { documentAnnotationTransfers } from "./schema/document-transfers.js";
|
|
6501
6540
|
export declare const documents: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
|
|
6502
6541
|
name: "documents";
|
|
@@ -715,6 +715,10 @@ export const attachments = sqliteTable("attachments", {
|
|
|
715
715
|
.on(table.id)
|
|
716
716
|
.where(sql `local_path IS NOT NULL AND local_path_folded IS NULL`),
|
|
717
717
|
]);
|
|
718
|
+
export const transcriptIndexPending = sqliteTable("transcript_index_pending", {
|
|
719
|
+
id: integer("id").notNull(),
|
|
720
|
+
indexableType: text("indexable_type").notNull(),
|
|
721
|
+
}, (table) => [primaryKey({ columns: [table.indexableType, table.id] })]);
|
|
718
722
|
export { documentAnnotationTransfers } from "./schema/document-transfers.js";
|
|
719
723
|
export const documents = sqliteTable("documents", {
|
|
720
724
|
id: integer("id").primaryKey(),
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ResolvedNode, ResolvedPredicate } from "../../search/lucene/resolved.js";
|
|
2
|
+
import type { CacheDatabase, SqlValue } from "../driver.js";
|
|
3
|
+
/** How one leaf reaches voice-note transcripts: its typed form, other forms by stem, and garbled spellings. */
|
|
4
|
+
export interface TranscriptTerms {
|
|
5
|
+
/** The leaf's words as typed, matched in `attachment_words`. */
|
|
6
|
+
match: string;
|
|
7
|
+
/** Any form of them, matched in `transcript_stems`; none under `exact:`. */
|
|
8
|
+
stems?: string;
|
|
9
|
+
/** Stored spellings speech recognition may have made of a single word; matched in `attachment_words`. */
|
|
10
|
+
widened: string[];
|
|
11
|
+
from: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Each leaf that reads transcripts, with what it matches there. A transcript always widens, not only when nothing
|
|
15
|
+
* matched as typed text does: a correct and a garbled spelling sit in one chat. Only a single word widens —
|
|
16
|
+
* FTS5 `NEAR` and phrases take no alternatives — and never under `exact:`, as meetings do.
|
|
17
|
+
*/
|
|
18
|
+
export declare const transcriptTerms: (database: CacheDatabase, root: ResolvedNode, item: string) => Map<ResolvedPredicate, TranscriptTerms>;
|
|
19
|
+
export type TranscriptPart = "all" | "typed" | "forms" | "widened";
|
|
20
|
+
/** The attachment ids a leaf's transcripts match, as a condition on `att.id`; `part` keeps one of them. */
|
|
21
|
+
export declare const transcriptCondition: (terms: TranscriptTerms, part?: TranscriptPart) => {
|
|
22
|
+
sql: string;
|
|
23
|
+
params: SqlValue[];
|
|
24
|
+
};
|
|
25
|
+
/** The items whose transcripts a leaf matches. */
|
|
26
|
+
export declare const transcriptItems: (item: string, terms: TranscriptTerms, part?: TranscriptPart) => {
|
|
27
|
+
sql: string;
|
|
28
|
+
params: SqlValue[];
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Of the items `keys` names, the transcript the leaves match — one they match as typed or in another form first —
|
|
32
|
+
* and, when only a spelling recognition garbled matched it, which: as meetings say it.
|
|
33
|
+
*/
|
|
34
|
+
export declare const spokenTranscripts: (database: CacheDatabase, item: string, speaking: TranscriptTerms[], keys: string) => Map<number, {
|
|
35
|
+
text: string;
|
|
36
|
+
corrections: {
|
|
37
|
+
from: string;
|
|
38
|
+
to: string[];
|
|
39
|
+
}[];
|
|
40
|
+
}>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { isNumberWord, widenWords } from "../../search/garbled.js";
|
|
2
|
+
import { knownRussianWord } from "../../search/russian-dictionary.js";
|
|
3
|
+
import { createStemmer, DEFAULT_STEMMERS, STEM_SEPARATOR } from "../../search/stem.js";
|
|
4
|
+
import { normalize } from "../normalize.js";
|
|
5
|
+
import { ftsMatch, quoted, SPEECH, stemMatch } from "./lucene-fields.js";
|
|
6
|
+
import { savedStemmers } from "./stems.js";
|
|
7
|
+
const predicates = (node) => node.kind === "predicate" ? [node] : node.clauses.flatMap(({ node }) => predicates(node));
|
|
8
|
+
/** A bare word and `transcript:` read transcripts; `text:` is only what was written. */
|
|
9
|
+
const readsTranscripts = (node) => (node.operator === "term" || node.operator === "phrase") &&
|
|
10
|
+
/[\p{L}\p{N}]/u.test(normalize(node.value)) &&
|
|
11
|
+
(node.field === "transcript" || (node.implicit === true && (node.field === "text" || node.field === "exact")));
|
|
12
|
+
/**
|
|
13
|
+
* Each leaf that reads transcripts, with what it matches there. A transcript always widens, not only when nothing
|
|
14
|
+
* matched as typed text does: a correct and a garbled spelling sit in one chat. Only a single word widens —
|
|
15
|
+
* FTS5 `NEAR` and phrases take no alternatives — and never under `exact:`, as meetings do.
|
|
16
|
+
*/
|
|
17
|
+
export const transcriptTerms = (database, root, item) => {
|
|
18
|
+
const leaves = predicates(root).filter(readsTranscripts);
|
|
19
|
+
const terms = new Map(leaves.map((node) => [node, { match: ftsMatch(node), widened: [], from: node.value }]));
|
|
20
|
+
const saved = savedStemmers(database);
|
|
21
|
+
if (item !== "message" || !leaves.length || saved === null)
|
|
22
|
+
return terms;
|
|
23
|
+
const stemmer = createStemmer(saved ?? DEFAULT_STEMMERS);
|
|
24
|
+
const stemsOf = (word) => [...new Set(stemmer.explain(word).map((part) => part.stem))];
|
|
25
|
+
const widening = [];
|
|
26
|
+
for (const [node, one] of terms) {
|
|
27
|
+
if (node.field === "exact")
|
|
28
|
+
continue;
|
|
29
|
+
const stems = stemMatch(stemmer, node);
|
|
30
|
+
// `NEAR` over the stems can bridge the separator between a text's Latin readings; those rows are left out.
|
|
31
|
+
if (stems)
|
|
32
|
+
Object.assign(one, { stems: node.slop === undefined ? stems : `(${stems}) NOT ${quoted(STEM_SEPARATOR)}` });
|
|
33
|
+
const words = normalize(node.value).match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
34
|
+
if (node.operator === "term" && words.length === 1 && !isNumberWord(words[0]))
|
|
35
|
+
widening.push({ node, literal: words[0], stems: stemsOf(node.value) });
|
|
36
|
+
}
|
|
37
|
+
if (!widening.length)
|
|
38
|
+
return terms;
|
|
39
|
+
const vocabulary = database
|
|
40
|
+
.prepare("SELECT term, doc FROM transcript_words_vocab WHERE col = 'normalized_text'")
|
|
41
|
+
.all()
|
|
42
|
+
.map((row) => ({ term: String(row.term), docs: Number(row.doc) }));
|
|
43
|
+
widenWords(widening, vocabulary, stemsOf, knownRussianWord).forEach((words, at) => {
|
|
44
|
+
const leaf = widening[at];
|
|
45
|
+
if (leaf)
|
|
46
|
+
Object.assign(terms.get(leaf.node), { widened: words });
|
|
47
|
+
});
|
|
48
|
+
return terms;
|
|
49
|
+
};
|
|
50
|
+
/** The attachment ids a leaf's transcripts match, as a condition on `att.id`; `part` keeps one of them. */
|
|
51
|
+
export const transcriptCondition = (terms, part = "all") => {
|
|
52
|
+
const parts = [];
|
|
53
|
+
const words = (match) => ({
|
|
54
|
+
sql: "att.id IN (SELECT rowid FROM attachment_words WHERE attachment_words MATCH ?)",
|
|
55
|
+
params: [match],
|
|
56
|
+
});
|
|
57
|
+
if (part === "all" || part === "typed")
|
|
58
|
+
parts.push(words(terms.match));
|
|
59
|
+
if ((part === "all" || part === "forms") && terms.stems)
|
|
60
|
+
parts.push({
|
|
61
|
+
sql: "att.id IN (SELECT rowid FROM transcript_stems WHERE transcript_stems MATCH ?)",
|
|
62
|
+
params: [`stems : (${terms.stems})`],
|
|
63
|
+
});
|
|
64
|
+
if ((part === "all" || part === "widened") && terms.widened.length)
|
|
65
|
+
parts.push(words(terms.widened.map(quoted).join(" OR ")));
|
|
66
|
+
return parts.length
|
|
67
|
+
? { sql: `(${parts.map(({ sql }) => sql).join(" OR ")})`, params: parts.flatMap(({ params }) => params) }
|
|
68
|
+
: { sql: "0", params: [] };
|
|
69
|
+
};
|
|
70
|
+
/** The items whose transcripts a leaf matches. */
|
|
71
|
+
export const transcriptItems = (item, terms, part) => {
|
|
72
|
+
const condition = transcriptCondition(terms, part);
|
|
73
|
+
return {
|
|
74
|
+
sql: `SELECT att.attachable_id FROM attachments att WHERE att.attachable_type='${item}' AND ${SPEECH} AND ${condition.sql}`,
|
|
75
|
+
params: condition.params,
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Of the items `keys` names, the transcript the leaves match — one they match as typed or in another form first —
|
|
80
|
+
* and, when only a spelling recognition garbled matched it, which: as meetings say it.
|
|
81
|
+
*/
|
|
82
|
+
export const spokenTranscripts = (database, item, speaking, keys) => {
|
|
83
|
+
const any = speaking.map((terms) => transcriptCondition(terms));
|
|
84
|
+
if (!any.length)
|
|
85
|
+
return new Map();
|
|
86
|
+
const plain = speaking
|
|
87
|
+
.flatMap((terms) => [transcriptCondition(terms, "typed"), transcriptCondition(terms, "forms")])
|
|
88
|
+
.filter(({ sql }) => sql !== "0");
|
|
89
|
+
const rows = database
|
|
90
|
+
.prepare(`SELECT att.attachable_id AS id, att.text AS text, ${plain.length ? plain.map(({ sql }) => sql).join(" OR ") : "0"} AS plain FROM attachments att WHERE att.attachable_type='${item}' AND ${SPEECH} AND att.attachable_id IN (SELECT value FROM json_each(?)) AND (${any.map(({ sql }) => sql).join(" OR ")}) ORDER BY plain DESC, att.position`)
|
|
91
|
+
.all(...plain.flatMap(({ params }) => params), keys, ...any.flatMap(({ params }) => params));
|
|
92
|
+
const found = new Map();
|
|
93
|
+
for (const row of rows) {
|
|
94
|
+
const id = Number(row.id);
|
|
95
|
+
if (found.has(id))
|
|
96
|
+
continue;
|
|
97
|
+
const text = String(row.text);
|
|
98
|
+
const tokens = new Set(normalize(text).match(/[\p{L}\p{N}]+/gu) ?? []);
|
|
99
|
+
const corrections = Number(row.plain) === 1
|
|
100
|
+
? []
|
|
101
|
+
: speaking.flatMap(({ from, widened }) => {
|
|
102
|
+
const to = widened.filter((word) => tokens.has(word));
|
|
103
|
+
return to.length ? [{ from, to }] : [];
|
|
104
|
+
});
|
|
105
|
+
found.set(id, { text, corrections });
|
|
106
|
+
}
|
|
107
|
+
return found;
|
|
108
|
+
};
|
|
@@ -9,6 +9,12 @@ export type ScoredHit = StoredHit & {
|
|
|
9
9
|
matchedIn?: MatchPlace;
|
|
10
10
|
/** The text of that place, for the excerpt; never part of an answer. */
|
|
11
11
|
matchedText?: string;
|
|
12
|
+
/** `corrected`: found only through a spelling speech recognition garbled, in a voice note's transcript. */
|
|
13
|
+
match?: "words" | "beginnings" | "corrected";
|
|
14
|
+
corrections?: {
|
|
15
|
+
from: string;
|
|
16
|
+
to: string[];
|
|
17
|
+
}[];
|
|
12
18
|
};
|
|
13
19
|
export type MatchPlace = "text" | "transcript" | "filename" | "file";
|
|
14
20
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.397.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",
|
|
@@ -207,7 +207,7 @@
|
|
|
207
207
|
"@types/node": "^22.20.4",
|
|
208
208
|
"@types/pngjs": "6.0.5",
|
|
209
209
|
"@vitest/coverage-v8": "^5.0.2",
|
|
210
|
-
"@wirecat/cli-base": "0.50.
|
|
210
|
+
"@wirecat/cli-base": "0.50.1",
|
|
211
211
|
"commander": "15.0.0",
|
|
212
212
|
"cspell": "10.3.6",
|
|
213
213
|
"drizzle-kit": "1.0.0-rc.4",
|
|
@@ -227,7 +227,7 @@
|
|
|
227
227
|
},
|
|
228
228
|
"peerDependencies": {
|
|
229
229
|
"@napi-rs/canvas": ">=0.1.69 <2",
|
|
230
|
-
"@wirecat/cli-base": ">=0.
|
|
230
|
+
"@wirecat/cli-base": ">=0.50.1 <0.51.0",
|
|
231
231
|
"commander": ">=15"
|
|
232
232
|
},
|
|
233
233
|
"bin": {
|
package/parity.json
CHANGED
|
@@ -121,6 +121,10 @@
|
|
|
121
121
|
"value": "<n>",
|
|
122
122
|
"meaning": "how many messages before it"
|
|
123
123
|
},
|
|
124
|
+
"--before-time": {
|
|
125
|
+
"value": "<time>",
|
|
126
|
+
"meaning": "read what came before this time"
|
|
127
|
+
},
|
|
124
128
|
"--block": {
|
|
125
129
|
"value": "<value>",
|
|
126
130
|
"meaning": "Set to `true` if user should be blocked in chat."
|
|
@@ -1287,12 +1291,26 @@
|
|
|
1287
1291
|
"--description": "all",
|
|
1288
1292
|
"--first-name": "all",
|
|
1289
1293
|
"--last-name": "all",
|
|
1290
|
-
"--photo":
|
|
1294
|
+
"--photo": {
|
|
1295
|
+
"in": "all",
|
|
1296
|
+
"wording": "WhatsApp accepts JPEG (.jpg or .jpeg) profile photos; help names each messenger's accepted image formats (WC-CMD-25)."
|
|
1297
|
+
}
|
|
1291
1298
|
}
|
|
1292
1299
|
},
|
|
1293
1300
|
"attachments": {
|
|
1294
1301
|
"in": "all"
|
|
1295
1302
|
},
|
|
1303
|
+
"attachments check": {
|
|
1304
|
+
"in": "all"
|
|
1305
|
+
},
|
|
1306
|
+
"attachments download": {
|
|
1307
|
+
"in": "all",
|
|
1308
|
+
"options": {
|
|
1309
|
+
"--chat": "all",
|
|
1310
|
+
"--pause": "all",
|
|
1311
|
+
"--since-time": "all"
|
|
1312
|
+
}
|
|
1313
|
+
},
|
|
1296
1314
|
"attachments extract": {
|
|
1297
1315
|
"in": "all",
|
|
1298
1316
|
"options": {
|
|
@@ -1318,6 +1336,30 @@
|
|
|
1318
1336
|
}
|
|
1319
1337
|
}
|
|
1320
1338
|
},
|
|
1339
|
+
"attachments jobs": {
|
|
1340
|
+
"in": "all"
|
|
1341
|
+
},
|
|
1342
|
+
"attachments jobs install": {
|
|
1343
|
+
"in": "all",
|
|
1344
|
+
"options": {
|
|
1345
|
+
"--at": "all",
|
|
1346
|
+
"--chat": "all",
|
|
1347
|
+
"--since-time": "all",
|
|
1348
|
+
"--timezone": "all"
|
|
1349
|
+
}
|
|
1350
|
+
},
|
|
1351
|
+
"attachments jobs start": {
|
|
1352
|
+
"in": "all"
|
|
1353
|
+
},
|
|
1354
|
+
"attachments jobs status": {
|
|
1355
|
+
"in": "all"
|
|
1356
|
+
},
|
|
1357
|
+
"attachments jobs stop": {
|
|
1358
|
+
"in": "all"
|
|
1359
|
+
},
|
|
1360
|
+
"attachments jobs uninstall": {
|
|
1361
|
+
"in": "all"
|
|
1362
|
+
},
|
|
1321
1363
|
"attachments list": {
|
|
1322
1364
|
"in": "all",
|
|
1323
1365
|
"options": {
|
|
@@ -1332,6 +1374,13 @@
|
|
|
1332
1374
|
}
|
|
1333
1375
|
}
|
|
1334
1376
|
},
|
|
1377
|
+
"attachments move": {
|
|
1378
|
+
"in": "all",
|
|
1379
|
+
"options": {
|
|
1380
|
+
"--before-time": "all",
|
|
1381
|
+
"--dry-run": "all"
|
|
1382
|
+
}
|
|
1383
|
+
},
|
|
1335
1384
|
"attachments show": {
|
|
1336
1385
|
"in": "all",
|
|
1337
1386
|
"options": {
|