@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.
Files changed (109) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.md +1 -0
  3. package/dist/background/daily-archive-timer.d.ts +40 -0
  4. package/dist/background/daily-archive-timer.js +181 -0
  5. package/dist/background/index.d.ts +2 -0
  6. package/dist/background/index.js +1 -0
  7. package/dist/cli/commands-command.js +6 -0
  8. package/dist/cli/index.d.ts +1 -1
  9. package/dist/cli/index.js +1 -1
  10. package/dist/cli/input-fields.d.ts +3 -0
  11. package/dist/cli/input-fields.js +27 -0
  12. package/dist/cli/messenger/account-command.js +1 -0
  13. package/dist/cli/messenger/account-sessions-command.js +3 -1
  14. package/dist/cli/messenger/admin-commands.js +1 -0
  15. package/dist/cli/messenger/admin-contacts-command.js +9 -3
  16. package/dist/cli/messenger/archive-commands.js +3 -1
  17. package/dist/cli/messenger/attachments-command.js +32 -10
  18. package/dist/cli/messenger/backfill-command.js +4 -0
  19. package/dist/cli/messenger/chats-command.js +1 -0
  20. package/dist/cli/messenger/chats-members-command.js +4 -0
  21. package/dist/cli/messenger/chats-stats-command.js +1 -0
  22. package/dist/cli/messenger/chats-tracking-command.js +4 -1
  23. package/dist/cli/messenger/complete-command.js +1 -1
  24. package/dist/cli/messenger/contacts-command.js +9 -3
  25. package/dist/cli/messenger/conversation-answers.d.ts +5 -0
  26. package/dist/cli/messenger/conversations-command.js +18 -1
  27. package/dist/cli/messenger/counters-command.js +1 -0
  28. package/dist/cli/messenger/download-command.js +1 -0
  29. package/dist/cli/messenger/flood-command.js +1 -0
  30. package/dist/cli/messenger/guard-commands.js +5 -4
  31. package/dist/cli/messenger/inbox.js +1 -0
  32. package/dist/cli/messenger/mcp-command.js +6 -2
  33. package/dist/cli/messenger/media-archive-command.d.ts +3 -0
  34. package/dist/cli/messenger/media-archive-command.js +72 -0
  35. package/dist/cli/messenger/media-archive-move-command.d.ts +3 -0
  36. package/dist/cli/messenger/media-archive-move-command.js +40 -0
  37. package/dist/cli/messenger/media-archive-timer-command.d.ts +3 -0
  38. package/dist/cli/messenger/media-archive-timer-command.js +70 -0
  39. package/dist/cli/messenger/message-answers.d.ts +5 -0
  40. package/dist/cli/messenger/message-writes.d.ts +5 -0
  41. package/dist/cli/messenger/messages-comments-command.js +1 -0
  42. package/dist/cli/messenger/messages-delete-command.js +1 -1
  43. package/dist/cli/messenger/messages-search-command.js +1 -0
  44. package/dist/cli/messenger/messages-stats-command.js +1 -0
  45. package/dist/cli/messenger/models-command.js +3 -0
  46. package/dist/cli/messenger/private-people-command.js +12 -10
  47. package/dist/cli/messenger/rankings-command.js +2 -0
  48. package/dist/cli/messenger/reactions-command.js +8 -2
  49. package/dist/cli/messenger/retention-command.js +1 -0
  50. package/dist/cli/messenger/review.js +1 -0
  51. package/dist/cli/messenger/search-answers.js +2 -0
  52. package/dist/cli/messenger/search-command.js +5 -1
  53. package/dist/cli/messenger/searches-command.d.ts +2 -0
  54. package/dist/cli/messenger/searches-command.js +26 -8
  55. package/dist/cli/messenger/server-command.js +7 -2
  56. package/dist/cli/messenger/store-maintenance-command.js +7 -0
  57. package/dist/cli/messenger/tags-command.js +6 -2
  58. package/dist/cli/messenger/tasks-command.js +10 -7
  59. package/dist/cli/messenger/transcribe-command.js +1 -0
  60. package/dist/cli/messenger/watch-command.js +1 -0
  61. package/dist/cli/program.js +5 -2
  62. package/dist/entities/contact-read.d.ts +119 -0
  63. package/dist/entities/contact-read.js +50 -0
  64. package/dist/entities/evidence.d.ts +40 -0
  65. package/dist/entities/index.d.ts +191 -2
  66. package/dist/entities/index.js +7 -1
  67. package/dist/entities/message.d.ts +9 -0
  68. package/dist/entities/message.js +6 -0
  69. package/dist/errors/reasons.d.ts +1 -0
  70. package/dist/errors/reasons.js +15 -0
  71. package/dist/meetings/mcp/index.d.ts +15 -15
  72. package/dist/parity/catalogue.js +1 -1
  73. package/dist/parity/manifest.d.ts +3 -1
  74. package/dist/parity/manifest.js +2 -0
  75. package/dist/parity/wording.js +2 -0
  76. package/dist/search/lucene/registry.js +2 -2
  77. package/dist/sends/permissions.js +7 -0
  78. package/dist/services/index.d.ts +5 -0
  79. package/dist/services/index.js +2 -0
  80. package/dist/services/media-archive-files.d.ts +10 -0
  81. package/dist/services/media-archive-files.js +110 -0
  82. package/dist/services/media-archive-locks.d.ts +6 -0
  83. package/dist/services/media-archive-locks.js +71 -0
  84. package/dist/services/media-archive-manifest.d.ts +29 -0
  85. package/dist/services/media-archive-manifest.js +116 -0
  86. package/dist/services/media-archive-move.d.ts +21 -0
  87. package/dist/services/media-archive-move.js +163 -0
  88. package/dist/services/media-archive.d.ts +34 -0
  89. package/dist/services/media-archive.js +165 -0
  90. package/dist/services/messages-search.js +13 -2
  91. package/dist/services/messages.d.ts +5 -0
  92. package/dist/store/search-index.d.ts +4 -1
  93. package/dist/store/search-index.js +10 -1
  94. package/dist/store/sqlite/hit-places.d.ts +12 -0
  95. package/dist/store/sqlite/hit-places.js +76 -0
  96. package/dist/store/sqlite/lucene-fields.d.ts +8 -0
  97. package/dist/store/sqlite/lucene-fields.js +6 -4
  98. package/dist/store/sqlite/lucene.js +25 -70
  99. package/dist/store/sqlite/manifest.js +2 -0
  100. package/dist/store/sqlite/migrations.generated.js +19 -0
  101. package/dist/store/sqlite/note-index.d.ts +11 -1
  102. package/dist/store/sqlite/note-index.js +18 -6
  103. package/dist/store/sqlite/schema.d.ts +39 -0
  104. package/dist/store/sqlite/schema.js +4 -0
  105. package/dist/store/sqlite/transcript-query.d.ts +40 -0
  106. package/dist/store/sqlite/transcript-query.js +108 -0
  107. package/dist/store/sqlite/words.d.ts +6 -0
  108. package/package.json +3 -3
  109. package/parity.json +50 -1
@@ -0,0 +1,21 @@
1
+ export interface MoveMediaArchiveOptions {
2
+ directory: string;
3
+ archiveDirectory: string;
4
+ beforeTime: string;
5
+ dryRun?: boolean;
6
+ signal?: AbortSignal;
7
+ }
8
+ /** The destination is durable before a source relocation record is published, then the verified local copy is removed. */
9
+ export declare const moveMediaArchive: (options: MoveMediaArchiveOptions) => Promise<{
10
+ directory: string;
11
+ archiveDirectory: string;
12
+ beforeTime: string;
13
+ selected: number;
14
+ selectedBytes: number;
15
+ moved: number;
16
+ movedBytes: number;
17
+ alreadyArchived: number;
18
+ undated: number;
19
+ complete: boolean;
20
+ dryRun: boolean;
21
+ }>;
@@ -0,0 +1,163 @@
1
+ import { realpath, unlink } from "node:fs/promises";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { CliError } from "@wirecat/cli-base";
4
+ import { openOrdinaryFile, sameOrdinaryFile, syncDirectory, writeFileAtomic } from "../files/index.js";
5
+ import { validateMediaSince } from "./media-archive.js";
6
+ import { archiveMediaFile } from "./media-archive-files.js";
7
+ import { withMediaArchiveLocks } from "./media-archive-locks.js";
8
+ import { mediaFingerprint, mediaKey, mediaManifestName, missingMediaFile, parseMediaEntry, readMediaManifests, sameMediaCopy, verifyMediaEntry, } from "./media-archive-manifest.js";
9
+ const canonicalDestination = async (directory) => {
10
+ const path = resolve(directory);
11
+ try {
12
+ return await realpath(path);
13
+ }
14
+ catch (error) {
15
+ if (!missingMediaFile(error) || dirname(path) === path)
16
+ throw error;
17
+ return join(await canonicalDestination(dirname(path)), basename(path));
18
+ }
19
+ };
20
+ const image = (entry) => ["photo", "image"].includes(entry.kind) || entry.mime?.startsWith("image/");
21
+ const removeLocalCopy = async (root, entry, beforeWrite, signal) => {
22
+ const path = join(root, entry.path);
23
+ let file;
24
+ try {
25
+ file = await openOrdinaryFile(path);
26
+ }
27
+ catch (error) {
28
+ if (missingMediaFile(error))
29
+ return;
30
+ throw error;
31
+ }
32
+ try {
33
+ const identity = await file.stat({ bigint: true });
34
+ const proof = await mediaFingerprint(path, signal);
35
+ if (proof.size !== entry.size || proof.sha256 !== entry.sha256 || !(await sameOrdinaryFile(path, identity)))
36
+ throw new CliError("validation_error", "local image changed during rotation; it was preserved");
37
+ if (!(await verifyMediaEntry(root, entry, signal)))
38
+ throw new CliError("validation_error", "rotated image checksum mismatch; the local copy was preserved");
39
+ await beforeWrite();
40
+ if (!(await sameOrdinaryFile(path, identity)))
41
+ throw new CliError("validation_error", "local image was replaced; it was preserved");
42
+ await unlink(path);
43
+ await syncDirectory(root);
44
+ }
45
+ finally {
46
+ await file.close();
47
+ }
48
+ };
49
+ /** The destination is durable before a source relocation record is published, then the verified local copy is removed. */
50
+ export const moveMediaArchive = async (options) => {
51
+ const cutoff = validateMediaSince(options.beforeTime, "--before-time");
52
+ if (cutoff === undefined)
53
+ throw new CliError("validation_error", "--before-time is required");
54
+ const target = await canonicalDestination(options.archiveDirectory);
55
+ const source = await realpath(options.directory);
56
+ if (source === target)
57
+ throw new CliError("validation_error", "choose a different archive destination");
58
+ return withMediaArchiveLocks(source, options.signal, async (root, beforeWrite) => {
59
+ const entries = await readMediaManifests(root, options.signal);
60
+ if (!entries.length)
61
+ throw new CliError("not_found", "no media archive manifests in this directory");
62
+ const targetEntries = await readMediaManifests(target, options.signal).catch((error) => {
63
+ if (options.dryRun && missingMediaFile(error))
64
+ return [];
65
+ throw error;
66
+ });
67
+ for (const [folder, manifests] of [
68
+ [root, entries],
69
+ [target, targetEntries],
70
+ ])
71
+ for (const entry of manifests) {
72
+ let valid = false;
73
+ try {
74
+ valid = await verifyMediaEntry(folder, entry, options.signal);
75
+ }
76
+ catch {
77
+ options.signal?.throwIfAborted();
78
+ }
79
+ if (!valid)
80
+ throw new CliError("validation_error", "archive checksum mismatch or missing file; no images were moved");
81
+ }
82
+ const known = new Map(targetEntries.map((entry) => [mediaKey(entry), entry]));
83
+ let selected = 0, moved = 0, movedBytes = 0, selectedBytes = 0, undated = 0, alreadyArchived = 0;
84
+ const candidates = [];
85
+ for (const entry of entries) {
86
+ if (!image(entry))
87
+ continue;
88
+ if (!entry.messageTime) {
89
+ undated++;
90
+ continue;
91
+ }
92
+ if (Date.parse(entry.messageTime) >= cutoff)
93
+ continue;
94
+ if (entry.relocatedTo) {
95
+ alreadyArchived++;
96
+ if (!options.dryRun)
97
+ await removeLocalCopy(root, entry, beforeWrite, options.signal);
98
+ continue;
99
+ }
100
+ const existing = known.get(mediaKey(entry));
101
+ if (existing?.relocatedTo)
102
+ throw new CliError("validation_error", "destination image is already relocated; choose its current archive directory");
103
+ if (existing && !sameMediaCopy(entry, existing))
104
+ throw new CliError("validation_error", "destination has a different copy of this image; choose a new destination");
105
+ candidates.push(entry);
106
+ selected++;
107
+ selectedBytes += entry.size;
108
+ }
109
+ if (!options.dryRun)
110
+ for (const entry of candidates) {
111
+ await beforeWrite();
112
+ if (!known.has(mediaKey(entry))) {
113
+ const file = await openOrdinaryFile(join(root, entry.path));
114
+ try {
115
+ const identity = {
116
+ provider: entry.provider,
117
+ account: entry.account,
118
+ chat: entry.chat,
119
+ message: entry.message,
120
+ position: entry.position,
121
+ };
122
+ const copied = await archiveMediaFile(target, identity, entry.kind, async () => ({
123
+ kind: entry.kind,
124
+ size: entry.size,
125
+ bytes: () => file.createReadStream({ autoClose: false, ...(options.signal ? { signal: options.signal } : {}) }),
126
+ }), beforeWrite, options.signal, {
127
+ messageTime: entry.messageTime,
128
+ mime: entry.mime,
129
+ expected: { size: entry.size, sha256: entry.sha256 },
130
+ });
131
+ known.set(mediaKey(entry), copied);
132
+ }
133
+ finally {
134
+ await file.close();
135
+ }
136
+ }
137
+ const receipt = parseMediaEntry({ ...entry, relocatedTo: target });
138
+ if (!(await verifyMediaEntry(root, receipt, options.signal)))
139
+ throw new CliError("validation_error", "archive destination changed before rotation; the original was preserved");
140
+ await beforeWrite();
141
+ await writeFileAtomic(join(root, mediaManifestName(entry)), JSON.stringify(receipt), {
142
+ beforePublish: beforeWrite,
143
+ signal: options.signal,
144
+ });
145
+ await removeLocalCopy(root, receipt, beforeWrite, options.signal);
146
+ moved++;
147
+ movedBytes += entry.size;
148
+ }
149
+ return {
150
+ directory: root,
151
+ archiveDirectory: target,
152
+ beforeTime: new Date(cutoff).toISOString(),
153
+ selected,
154
+ selectedBytes,
155
+ moved,
156
+ movedBytes,
157
+ alreadyArchived,
158
+ undated,
159
+ complete: undated === 0,
160
+ dryRun: options.dryRun === true,
161
+ };
162
+ }, { additional: [target], createAdditional: !options.dryRun });
163
+ };
@@ -0,0 +1,34 @@
1
+ import type { ChatsService } from "./chats.js";
2
+ import { type MediaArchiveEntry } from "./media-archive-manifest.js";
3
+ import type { MessagesService } from "./messages.js";
4
+ export interface MediaArchivePorts {
5
+ chats: Pick<ChatsService, "list" | "account" | "show">;
6
+ messages: Pick<MessagesService, "list" | "download">;
7
+ }
8
+ export interface MediaArchiveOptions {
9
+ directory: string;
10
+ chats?: readonly string[];
11
+ sinceTime?: string;
12
+ orderByTime?: boolean;
13
+ pageSize?: number;
14
+ pauseMs?: number;
15
+ signal?: AbortSignal;
16
+ onSaved?: (entry: MediaArchiveEntry) => void;
17
+ }
18
+ export declare const validateMediaSince: (value?: string, flag?: string) => number | undefined;
19
+ export declare const checkMediaArchive: (directory: string, signal?: AbortSignal) => Promise<{
20
+ valid: boolean;
21
+ checked: number;
22
+ files: {
23
+ path: string;
24
+ valid: boolean;
25
+ archiveDirectory?: string;
26
+ }[];
27
+ }>;
28
+ export declare const archiveMedia: (ports: MediaArchivePorts, options: MediaArchiveOptions) => Promise<{
29
+ directory: string;
30
+ chats: number;
31
+ saved: number;
32
+ existing: number;
33
+ complete: boolean;
34
+ }>;
@@ -0,0 +1,165 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
2
+ import { CliError } from "@wirecat/cli-base";
3
+ import { matchDownloads, NOT_FILES } from "../domain/attachments.js";
4
+ import { archiveMediaFile } from "./media-archive-files.js";
5
+ import { withMediaArchiveLocks } from "./media-archive-locks.js";
6
+ import { mediaKey, readMediaManifests, verifyMediaEntry } from "./media-archive-manifest.js";
7
+ export const validateMediaSince = (value, flag = "--since-time") => {
8
+ if (value === undefined)
9
+ return undefined;
10
+ if (!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?(?:Z|[+-]\d\d:\d\d)$/.test(value) ||
11
+ !Number.isFinite(Date.parse(value)))
12
+ throw new CliError("validation_error", `${flag} needs an ISO timestamp with a time zone`);
13
+ const date = value.slice(0, 10);
14
+ if (new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) !== date)
15
+ throw new CliError("validation_error", `${flag} has an invalid calendar date`);
16
+ return Date.parse(value);
17
+ };
18
+ export const checkMediaArchive = (directory, signal) => withMediaArchiveLocks(directory, signal, async (root) => {
19
+ const entries = await readMediaManifests(root, signal);
20
+ if (!entries.length)
21
+ throw new CliError("not_found", "no media archive manifests in this directory");
22
+ const files = [];
23
+ for (const entry of entries) {
24
+ let valid = false;
25
+ try {
26
+ valid = await verifyMediaEntry(root, entry, signal);
27
+ }
28
+ catch {
29
+ signal?.throwIfAborted();
30
+ }
31
+ files.push({ path: entry.path, valid, ...(entry.relocatedTo ? { archiveDirectory: entry.relocatedTo } : {}) });
32
+ }
33
+ return { valid: files.every((one) => one.valid), checked: files.length, files };
34
+ });
35
+ const saveMessage = async (root, account, message, ports, known, beforeWrite, options) => {
36
+ let download;
37
+ let saved = 0;
38
+ let existing = 0;
39
+ for (const [position, attachment] of message.attachments.entries()) {
40
+ if (NOT_FILES.has(attachment.kind))
41
+ continue;
42
+ await beforeWrite();
43
+ const identity = { ...account, chat: message.chatId, message: message.id, position };
44
+ const key = mediaKey(identity);
45
+ if (known.has(key)) {
46
+ existing++;
47
+ continue;
48
+ }
49
+ const entry = await archiveMediaFile(root, identity, attachment.kind, async () => {
50
+ download ??= await ports.messages.download(message.chatId, message.id);
51
+ const positions = matchDownloads(message.attachments.map((one, index) => ({
52
+ position: index,
53
+ kind: one.kind,
54
+ name: one.name ?? null,
55
+ })), download.files);
56
+ const index = positions.indexOf(position);
57
+ const file = download.files[index];
58
+ if (!file)
59
+ throw new CliError("invalid_response", "the messenger did not return an identifiable attachment");
60
+ return file;
61
+ }, beforeWrite, options.signal, { messageTime: new Date(message.timestamp).toISOString(), ...(attachment.mime ? { mime: attachment.mime } : {}) });
62
+ known.set(key, entry);
63
+ saved++;
64
+ options.onSaved?.(entry);
65
+ }
66
+ return { saved, existing };
67
+ };
68
+ export const archiveMedia = async (ports, options) => {
69
+ const since = validateMediaSince(options.sinceTime);
70
+ const pageSize = options.pageSize ?? 100;
71
+ const pauseMs = options.pauseMs ?? 1000;
72
+ if (!Number.isSafeInteger(pageSize) ||
73
+ pageSize < 1 ||
74
+ pageSize > 1000 ||
75
+ !Number.isSafeInteger(pauseMs) ||
76
+ pauseMs < 0)
77
+ throw new CliError("validation_error", "invalid media archive page size or pause");
78
+ return withMediaArchiveLocks(options.directory, options.signal, async (root, beforeWrite) => {
79
+ const known = new Map();
80
+ for (const entry of await readMediaManifests(root, options.signal)) {
81
+ let valid = false;
82
+ try {
83
+ valid = await verifyMediaEntry(root, entry, options.signal);
84
+ }
85
+ catch {
86
+ options.signal?.throwIfAborted();
87
+ }
88
+ if (!valid)
89
+ throw new CliError("validation_error", "archive checksum mismatch or missing file; choose a new destination");
90
+ known.set(mediaKey(entry), entry);
91
+ }
92
+ const account = await ports.chats.account();
93
+ let saved = 0, existing = 0, chats = 0;
94
+ const walk = async (chat) => {
95
+ const seen = new Set();
96
+ let before;
97
+ for (;;) {
98
+ await beforeWrite();
99
+ const page = await ports.messages.list(chat, { limit: pageSize, ...(before ? { before } : {}) });
100
+ let fresh = 0;
101
+ let reachedSince = false;
102
+ let oldest;
103
+ for (const message of page.items) {
104
+ const timestamp = Date.parse(message.timestamp);
105
+ if (!Number.isFinite(timestamp))
106
+ throw new CliError("invalid_response", "media history has an invalid timestamp");
107
+ if (!oldest || timestamp < Date.parse(oldest.timestamp))
108
+ oldest = message;
109
+ if (since !== undefined && timestamp < since) {
110
+ reachedSince = true;
111
+ continue;
112
+ }
113
+ if (seen.has(message.id))
114
+ continue;
115
+ seen.add(message.id);
116
+ fresh++;
117
+ const one = await saveMessage(root, account, message, ports, known, beforeWrite, options);
118
+ saved += one.saved;
119
+ existing += one.existing;
120
+ }
121
+ if (!page.hasMore || reachedSince)
122
+ break;
123
+ if (!oldest || !fresh)
124
+ throw new CliError("invalid_response", "media history pagination did not advance; archive is incomplete");
125
+ before = options.orderByTime ? new Date(Date.parse(oldest.timestamp) + 1).toISOString() : page.items[0]?.id;
126
+ if (pauseMs)
127
+ await sleep(pauseMs, undefined, { signal: options.signal });
128
+ }
129
+ chats++;
130
+ };
131
+ if (options.chats?.length) {
132
+ const ids = new Set();
133
+ for (const chat of options.chats) {
134
+ const found = await ports.chats.show(chat);
135
+ if (!ids.has(found.id)) {
136
+ ids.add(found.id);
137
+ await walk(found.id);
138
+ }
139
+ }
140
+ }
141
+ else {
142
+ const seen = new Set();
143
+ let offset = 0;
144
+ for (;;) {
145
+ await beforeWrite();
146
+ const page = await ports.chats.list({ offset, limit: 100 });
147
+ let fresh = 0;
148
+ for (const chat of page.items)
149
+ if (!seen.has(chat.id)) {
150
+ seen.add(chat.id);
151
+ fresh++;
152
+ await walk(chat.id);
153
+ }
154
+ if (!page.hasMore)
155
+ break;
156
+ if (!fresh)
157
+ throw new CliError("invalid_response", "chat pagination did not advance; archive is incomplete");
158
+ offset += page.items.length;
159
+ if (pauseMs)
160
+ await sleep(pauseMs, undefined, { signal: options.signal });
161
+ }
162
+ }
163
+ return { directory: root, chats, saved, existing, complete: true };
164
+ }, { create: true });
165
+ };
@@ -331,7 +331,9 @@ export const searchLucene = async (store, account, request, messenger = {}) => {
331
331
  // Coverage counts messenger chats fetched from a server; mail is imported whole, so for mail it says nothing.
332
332
  const { completeness, coverage } = request.kind === "mail" ? { completeness: [], coverage: undefined } : await coverageOf(store, prepared, messenger);
333
333
  const items = await Promise.all(found.items.map(async (item) => {
334
- const hit = withExcerpt(item, request.text ?? "");
334
+ // A garbled spelling is not among the typed words, so the excerpt looks for it too.
335
+ const garbled = item.corrections?.flatMap(({ to }) => to) ?? [];
336
+ const hit = withExcerpt(item, [request.text ?? "", ...garbled].join(" "));
335
337
  if (!request.context)
336
338
  return hit;
337
339
  const locator = parseLocator(hit.locator);
@@ -347,7 +349,7 @@ export const searchLucene = async (store, account, request, messenger = {}) => {
347
349
  return {
348
350
  ...found,
349
351
  items,
350
- corrections: [],
352
+ corrections: correctionsOf(found.items),
351
353
  wordsReady: prepared.wordsReady,
352
354
  stemsReady: prepared.stemsReady,
353
355
  completeness,
@@ -359,6 +361,15 @@ export const searchLucene = async (store, account, request, messenger = {}) => {
359
361
  : {}),
360
362
  };
361
363
  };
364
+ /** The garbled spellings voice-note hits were found through, one entry per query word. */
365
+ const correctionsOf = (items) => {
366
+ const found = new Map();
367
+ for (const { corrections = [] } of items)
368
+ for (const { from, to } of corrections)
369
+ for (const word of to)
370
+ found.set(from, (found.get(from) ?? new Set()).add(word));
371
+ return [...found].map(([from, to]) => ({ from, to: [...to] }));
372
+ };
362
373
  const QUERY_PAGE_LIMIT = 1000;
363
374
  const oldestFetch = (chats) => chats.length === 0 || chats.some(({ fetchedAt }) => fetchedAt === null)
364
375
  ? null
@@ -81,6 +81,11 @@ export type FoundMessage = StoredHit & {
81
81
  parent?: string;
82
82
  };
83
83
  match?: "words" | "beginnings" | "corrected";
84
+ /** The garbled spellings a `corrected` hit's transcript holds, per query word. */
85
+ corrections?: {
86
+ from: string;
87
+ to: string[];
88
+ }[];
84
89
  /** Where the words were: the text, a voice note's transcript, a file's name or a file's text. */
85
90
  matchedIn?: MatchPlace;
86
91
  score?: number | null;
@@ -24,7 +24,10 @@ export interface SearchIndexStore {
24
24
  }): Promise<SearchIndexFill>;
25
25
  /** How far the stems are built, and by which stemmer choices; `undefined` on a file before they existed. */
26
26
  stemsState(): Promise<StemsState | undefined>;
27
- /** Builds the stems towards "ready" in short batches until done or `until` says stop; never rebuilds. */
27
+ /**
28
+ * Builds the stems towards "ready" in short batches until done or `until` says stop; never rebuilds. Then the
29
+ * voice-note transcripts written since, which are few, so they keep up with any stemmer choice.
30
+ */
28
31
  fillStems(options?: {
29
32
  until?: () => boolean;
30
33
  }): Promise<StemsFill>;
@@ -1,3 +1,4 @@
1
+ import { CORPORA, corpusIndexState, drainCorpus } from "./sqlite/note-index.js";
1
2
  import * as searchIndex from "./sqlite/search-index.js";
2
3
  import * as stems from "./sqlite/stems.js";
3
4
  import { fillStems } from "./sqlite/stems-fill.js";
@@ -5,13 +6,21 @@ import { write } from "./sqlite/transaction.js";
5
6
  import * as words from "./sqlite/words.js";
6
7
  export const searchIndexStoreOver = (context) => {
7
8
  const { database } = context;
9
+ const stemmerFor = stems.stemmerCache();
8
10
  return {
9
11
  knownTerms: async (terms) => words.knownTerms(context, terms),
10
12
  termCandidates: async (trigrams, lengths) => words.termCandidates(context, trigrams, lengths),
11
13
  searchIndexState: async () => searchIndex.searchIndexState(database),
12
14
  fillSearchIndex: async ({ until } = {}) => searchIndex.fillSearchIndex(database, { now: context.now, ...(until ? { until } : {}) }),
13
15
  stemsState: async () => stems.stemsState(database),
14
- fillStems: async ({ until } = {}) => fillStems(database, { now: context.now, ...(until ? { until } : {}) }),
16
+ fillStems: async ({ until } = {}) => {
17
+ const filled = fillStems(database, { now: context.now, ...(until ? { until } : {}) });
18
+ // Read first: every search asks, and an idle drain must not take the write lock.
19
+ const transcripts = corpusIndexState(database, CORPORA.transcript);
20
+ if (transcripts.wanted !== null && (transcripts.pending > 0 || transcripts.built !== transcripts.wanted))
21
+ drainCorpus(database, CORPORA.transcript, stemmerFor, until ? { until } : {});
22
+ return filled;
23
+ },
15
24
  stemmers: async () => stems.savedStemmers(database),
16
25
  saveStemmers: async (stemmers) => write(database, () => stems.saveStoreStemmers(database, stemmers, context.now())),
17
26
  };
@@ -0,0 +1,12 @@
1
+ import type { ResolvedPredicate } from "../../search/lucene/resolved.js";
2
+ import type { CacheDatabase } from "../driver.js";
3
+ import { type Corpus, type Leaf } from "./lucene-fields.js";
4
+ import type { hitsByPk } from "./search.js";
5
+ import { type TranscriptTerms } from "./transcript-query.js";
6
+ type Hits = ReturnType<typeof hitsByPk>;
7
+ /**
8
+ * Which place of each hit holds the words, first match wins in the order of `MatchPlace`; a hit its transcript
9
+ * holds only through a spelling speech recognition garbled is `corrected`.
10
+ */
11
+ export declare const placeHits: (database: CacheDatabase, corpus: Corpus, leaves: Leaf[], transcripts: Map<ResolvedPredicate, TranscriptTerms>, found: Hits, pks: number[]) => Hits;
12
+ export {};
@@ -0,0 +1,76 @@
1
+ import { normalize } from "../normalize.js";
2
+ import { ftsMatch, SPEECH } from "./lucene-fields.js";
3
+ import { spokenTranscripts } from "./transcript-query.js";
4
+ /**
5
+ * Which place of each hit holds the words, first match wins in the order of `MatchPlace`; a hit its transcript
6
+ * holds only through a spelling speech recognition garbled is `corrected`.
7
+ */
8
+ export const placeHits = (database, corpus, leaves, transcripts, found, pks) => {
9
+ const placeLeaves = leaves.filter(({ node }) => (node.operator === "term" || node.operator === "phrase") &&
10
+ (node.implicit || node.field === "content" || node.field === "transcript"));
11
+ if (!placeLeaves.length || !pks.length || found.length !== pks.length)
12
+ return found;
13
+ const keys = JSON.stringify(pks);
14
+ const wordy = placeLeaves.filter(({ node }) => /[\p{L}\p{N}]/u.test(normalize(node.value)));
15
+ if (!wordy.length)
16
+ return found;
17
+ const match = wordy.map(({ node }) => ftsMatch(node)).join(" OR ");
18
+ const implicit = placeLeaves.some(({ node }) => node.implicit);
19
+ const inText = new Set(implicit
20
+ ? database
21
+ .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)
22
+ ? ` UNION SELECT rowid FROM ${corpus.stems} WHERE ${corpus.stems} MATCH ? AND rowid IN (SELECT value FROM json_each(?))`
23
+ : ""}`)
24
+ .all(`normalized_text : (${match})`, keys, ...(placeLeaves.some(({ fragment }) => fragment.stems)
25
+ ? [
26
+ `stems : (${placeLeaves.flatMap(({ fragment }) => (fragment.stems ? [`(${fragment.stems})`] : [])).join(" OR ")})`,
27
+ keys,
28
+ ]
29
+ : []))
30
+ .map(({ id }) => Number(id))
31
+ : []);
32
+ const files = database
33
+ .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`)
34
+ .all(keys, match);
35
+ const fileFields = new Set(placeLeaves.flatMap(({ node }) => (node.implicit ? ["transcript", "content"] : [node.field])));
36
+ const spoken = spokenTranscripts(database, corpus.item, wordy.flatMap(({ node }) => transcripts.get(node) ?? []), keys);
37
+ const allNames = implicit
38
+ ? database
39
+ .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`)
40
+ .all(keys)
41
+ : [];
42
+ return found.map((hit, index) => {
43
+ const pk = pks[index];
44
+ const transcript = spoken.get(pk);
45
+ const file = files.find((row) => Number(row.id) === pk && Number(row.speech) !== 1);
46
+ const name = allNames.find((row) => {
47
+ if (Number(row.id) !== pk)
48
+ return false;
49
+ const words = normalize(String(row.name)).split(/[^\p{L}\p{N}]+/u);
50
+ return wordy.some(({ node }) => {
51
+ const phrase = normalize(node.value).match(/[\p{L}\p{N}]+/gu) ?? [];
52
+ if (node.slop !== undefined)
53
+ return phrase.every((word) => words.includes(word));
54
+ return words.some((_, at) => phrase.every((word, offset) => words[at + offset] === word));
55
+ });
56
+ });
57
+ const place = inText.has(pk)
58
+ ? ["text", hit.text]
59
+ : transcript && fileFields.has("transcript")
60
+ ? ["transcript", String(transcript.text)]
61
+ : name
62
+ ? ["filename", String(name.name)]
63
+ : file && fileFields.has("content")
64
+ ? ["file", String(file.text)]
65
+ : undefined;
66
+ if (!place)
67
+ return hit;
68
+ const fixes = place[0] === "transcript" ? (transcript?.corrections ?? []) : [];
69
+ return {
70
+ ...hit,
71
+ matchedIn: place[0],
72
+ matchedText: place[1],
73
+ ...(fixes.length ? { match: "corrected", corrections: fixes } : {}),
74
+ };
75
+ });
76
+ };
@@ -1,5 +1,6 @@
1
1
  import { type Automaton, type MatchBudget } from "../../search/lucene/automaton.js";
2
2
  import type { QueryExecution, ResolvedPredicate } from "../../search/lucene/resolved.js";
3
+ import { type Stemmer } from "../../search/stem.js";
3
4
  import type { CacheDatabase, SqlValue } from "../driver.js";
4
5
  export interface Fragment {
5
6
  sql: string;
@@ -57,6 +58,11 @@ export interface FieldScope {
57
58
  sql: string;
58
59
  params: SqlValue[];
59
60
  };
61
+ /** The items whose voice-note transcripts a leaf matches: typed, in another form, or garbled. */
62
+ transcripts: (node: ResolvedPredicate) => {
63
+ sql: string;
64
+ params: SqlValue[];
65
+ };
60
66
  everywhere: (node: ResolvedPredicate, fragment: Fragment) => Fragment;
61
67
  /** Counts the patterns' expanded words against the query's budget. */
62
68
  expand: (count: number) => number;
@@ -67,6 +73,8 @@ type Compiled = {
67
73
  test?: Leaf["test"];
68
74
  } | Leaf;
69
75
  type FieldCompiler = (node: ResolvedPredicate, scope: FieldScope) => Compiled;
76
+ /** A leaf as an FTS5 match over a stem index: phrases of stems, or with `~N` their `NEAR`. */
77
+ export declare const stemMatch: (stemmer: Stemmer, node: ResolvedPredicate) => string | undefined;
70
78
  /** One compiler per field of `QUERY_FIELDS`, by its name: a leaf compiles by a lookup, never a branch. */
71
79
  export declare const FIELD_COMPILERS: Readonly<Record<string, FieldCompiler>>;
72
80
  export {};
@@ -72,10 +72,12 @@ const mentioned = ({ mail }, where, params) => mail
72
72
  : bound(`m.id IN (SELECT mn.source_id FROM mentions mn WHERE mn.source_type='message' AND ${where} ` +
73
73
  "UNION SELECT a.attachable_id FROM mentions mn JOIN attachments a ON a.id=mn.source_id " +
74
74
  `AND a.attachable_type='message' WHERE mn.source_type='attachment' AND ${where})`, ...params, ...params);
75
- const stemsOf = ({ execution: { stemmer } }, node) => {
75
+ const stemsOf = ({ execution: { stemmer } }, node) => stemmer ? stemMatch(stemmer, node) : undefined;
76
+ /** A leaf as an FTS5 match over a stem index: phrases of stems, or with `~N` their `NEAR`. */
77
+ export const stemMatch = (stemmer, node) => {
76
78
  if (node.slop === undefined)
77
- return stemmer?.phrases(node.value);
78
- const sequences = (stemmer?.stemSequences(node.value) ?? []).filter((sequence) => sequence.length);
79
+ return stemmer.phrases(node.value);
80
+ const sequences = stemmer.stemSequences(node.value).filter((sequence) => sequence.length);
79
81
  return sequences.length
80
82
  ? sequences
81
83
  .map((sequence) => (sequence.length > 1 ? nearMatch(sequence, node.slop) : quoted(sequence.join(""))))
@@ -256,7 +258,7 @@ const size = (node, { corpus }) => {
256
258
  const attachmentText = (which) => (node, scope) => {
257
259
  const text = normalize(node.value);
258
260
  // Never `fts`: the ranking ANDs every required word into message_words, where a file's words are not.
259
- const set = scope.attachmentWords(ftsMatch(node), which);
261
+ const set = which === "transcript" ? scope.transcripts(node) : scope.attachmentWords(ftsMatch(node), which);
260
262
  return always(/[\p{L}\p{N}]/u.test(text) ? bound(`m.id IN (${set.sql})`, ...set.params) : bound("0"));
261
263
  };
262
264
  const date = (node) => {