@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
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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 } = {}) =>
|
|
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
|
|
78
|
-
const sequences =
|
|
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) => {
|