@wirecat/cli-core 0.395.0 → 0.396.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 +30 -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/attachments-command.js +16 -4
- package/dist/cli/messenger/conversation-answers.d.ts +5 -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/search-answers.js +2 -0
- package/dist/cli/messenger/searches-command.d.ts +2 -0
- package/dist/cli/messenger/searches-command.js +2 -1
- package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
- package/dist/cli/program.js +5 -2
- package/dist/entities/evidence.d.ts +40 -0
- package/dist/entities/index.d.ts +71 -1
- package/dist/entities/index.js +2 -0
- 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/search/lucene/registry.js +2 -2
- 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
|
@@ -294,11 +294,11 @@ export const QUERY_FIELDS = [
|
|
|
294
294
|
{
|
|
295
295
|
name: "transcript",
|
|
296
296
|
example: "transcript:invoice",
|
|
297
|
-
index: "attachment_words
|
|
297
|
+
index: "attachment_words ∪ transcript_stems; garbled spellings from transcript_words_vocab",
|
|
298
298
|
aliases: [],
|
|
299
299
|
type: "txt",
|
|
300
300
|
operators: terms,
|
|
301
|
-
normalization: "
|
|
301
|
+
normalization: "any form of the word (Snowball), widened to spellings speech recognition garbles; what a voice message said",
|
|
302
302
|
support: "A2",
|
|
303
303
|
validate: filled,
|
|
304
304
|
},
|
|
@@ -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) => {
|
|
@@ -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
|
+
}>;
|