@wirecat/cli-core 0.495.0 → 0.497.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 +89 -1
- package/dist/calendar/cli/answers.d.ts +277 -0
- package/dist/calendar/cli/answers.js +257 -0
- package/dist/calendar/cli/index.d.ts +10 -1
- package/dist/calendar/cli/index.js +78 -47
- package/dist/calendar/index.d.ts +1 -1
- package/dist/calendar/reads.d.ts +7 -0
- package/dist/calendar/server-search.d.ts +31 -0
- package/dist/calendar/server-search.js +41 -0
- package/dist/calendar/source.d.ts +14 -0
- package/dist/calendar/testing/answers.d.ts +6 -0
- package/dist/calendar/testing/answers.js +37 -0
- package/dist/calendar/testing/source.d.ts +2 -0
- package/dist/calendar/testing/source.js +9 -0
- package/dist/cli/commands-command.js +10 -2
- package/dist/cli/config-command.js +18 -8
- package/dist/cli/messenger/conversation-answers.d.ts +1 -1
- package/dist/cli/messenger/message-answers.d.ts +1 -1
- package/dist/cli/messenger/message-writes.d.ts +2 -2
- package/dist/cli/messenger/search-answers.js +2 -2
- package/dist/cli/messenger/search-command.js +4 -2
- package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
- package/dist/cli/program.js +2 -1
- package/dist/entities/conversation-results.d.ts +2 -2
- package/dist/entities/evidence.d.ts +8 -8
- package/dist/entities/index.d.ts +26 -19
- package/dist/entities/listener.d.ts +4 -4
- package/dist/entities/message-context.d.ts +3 -3
- package/dist/entities/message-write.d.ts +4 -4
- package/dist/entities/message.d.ts +2 -2
- package/dist/entities/message.js +2 -2
- package/dist/entities/store-records.d.ts +1 -1
- package/dist/errors/reasons.js +4 -0
- package/dist/meetings/read-answers.d.ts +10 -0
- package/dist/meetings/read-answers.js +7 -0
- package/dist/services/document-source.d.ts +2 -3
- package/dist/services/document-source.js +2 -7
- package/dist/services/messages-discovery.js +7 -2
- package/dist/services/notes-search.d.ts +13 -3
- package/dist/services/notes-search.js +93 -19
- package/dist/store/sqlite/note-search.d.ts +5 -0
- package/dist/store/sqlite/note-search.js +11 -2
- package/package.json +1 -1
- package/parity.json +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { isFolderProvider, kindIn } from "../domain/providers.js";
|
|
2
2
|
/**
|
|
3
|
-
* Where a hit came from. The document's preferred file says it: its account's provider
|
|
3
|
+
* Where a hit came from. The document's preferred file says it: its account's provider and its link.
|
|
4
4
|
* A Drive document under a notes folder (before version 31 moves it) is still known by its front matter.
|
|
5
|
-
*
|
|
5
|
+
* The hit's time is the store search's: the one `date:` reads.
|
|
6
6
|
*/
|
|
7
7
|
export const documentSource = (note, accounts, profiles, file) => {
|
|
8
8
|
const metadata = note.frontMatter;
|
|
@@ -16,15 +16,10 @@ export const documentSource = (note, accounts, profiles, file) => {
|
|
|
16
16
|
(isFolderProvider(key.provider) && key.account === account.name))
|
|
17
17
|
: undefined;
|
|
18
18
|
const url = file?.url ?? (remote && typeof metadata?.sourceUrl === "string" ? metadata.sourceUrl : undefined);
|
|
19
|
-
const modified = file?.externalUpdatedAt ??
|
|
20
|
-
(remote && typeof metadata?.modifiedAt === "string" && Number.isFinite(Date.parse(metadata.modifiedAt))
|
|
21
|
-
? metadata.modifiedAt
|
|
22
|
-
: undefined);
|
|
23
19
|
return {
|
|
24
20
|
kind: remote ? "document" : "note",
|
|
25
21
|
provider: profile ? profile.command : "notes",
|
|
26
22
|
account: profile?.name ?? note.folderId ?? "internal",
|
|
27
23
|
...(remote && url !== undefined ? { url } : {}),
|
|
28
|
-
updated_at: remote && modified !== undefined ? modified : note.updatedAt,
|
|
29
24
|
};
|
|
30
25
|
};
|
|
@@ -31,10 +31,15 @@ const FOLLOW_UP_CANDIDATES = 20;
|
|
|
31
31
|
export const searchDiscovery = async (store, account, asked, messenger = {}, meaning, rerank) => {
|
|
32
32
|
// A question's own "?" would parse as a one-character wildcard and send it to strict search (BUG-395); the
|
|
33
33
|
// colon of a time or ratio ("22:00", "1:1") would parse as a field and fail the whole search, and escaped it is
|
|
34
|
-
// syntax again, so it splits the numbers (BUG-450).
|
|
34
|
+
// syntax again, so it splits the numbers (BUG-450). A quoted timestamp keeps its colons (BUG-458).
|
|
35
35
|
const request = asked.ast !== undefined || asked.exact || !asked.text
|
|
36
36
|
? asked
|
|
37
|
-
: {
|
|
37
|
+
: {
|
|
38
|
+
...asked,
|
|
39
|
+
text: asked.text
|
|
40
|
+
.replace(/(?<=\p{L})\?(?=\s|$)/gu, "")
|
|
41
|
+
.replace(/"(?:[^"\\]|\\.)*"|(?<=\d):(?=\d)/gu, (found) => (found === ":" ? " " : found)),
|
|
42
|
+
};
|
|
38
43
|
const original = await prepareLucene(store, account, request, messenger);
|
|
39
44
|
const text = request.text ?? "";
|
|
40
45
|
const plan = planQuestion(text, request.ast !== undefined || !!request.exact || !!request.newest);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type QueryNode } from "@wirecat/cli-base/query";
|
|
1
2
|
import { type Embedder, type WarmEmbedders } from "../embeddings/embed.js";
|
|
2
3
|
import { type TextModel } from "../embeddings/models.js";
|
|
3
4
|
import type { PictureEmbedder } from "../images/picture/embedder.js";
|
|
@@ -90,7 +91,7 @@ export interface FoundNote {
|
|
|
90
91
|
/** The note's path inside its folder; `null` for a note written in memo. */
|
|
91
92
|
path: string | null;
|
|
92
93
|
title: string | null;
|
|
93
|
-
/**
|
|
94
|
+
/** The time `date:` reads: a Drive document's edit time when the store has one, else when the store's copy changed. */
|
|
94
95
|
updated_at: string;
|
|
95
96
|
/** The first line holding a word of the query. */
|
|
96
97
|
excerpt: string;
|
|
@@ -135,7 +136,7 @@ export interface NotesQuery {
|
|
|
135
136
|
limit?: number;
|
|
136
137
|
offset?: number;
|
|
137
138
|
tag?: string;
|
|
138
|
-
/** A query in the same language that every hit must also match
|
|
139
|
+
/** A query in the same language that every hit must also match, as the query's own fields are. */
|
|
139
140
|
filter?: string;
|
|
140
141
|
folderIds?: string[];
|
|
141
142
|
/** With `folderIds`, the notes written in memo too: one ranked list, so `offset` pages it. */
|
|
@@ -164,9 +165,18 @@ export declare const noteSourceOfQuery: (query: string) => {
|
|
|
164
165
|
source?: Note["source"];
|
|
165
166
|
rest: string;
|
|
166
167
|
};
|
|
168
|
+
/**
|
|
169
|
+
* What a note found by meaning must still match: the query with each word it asks for taken as matched, since the
|
|
170
|
+
* meaning half stands in for those. Words it excludes, and every other field, stay. `undefined` when nothing but
|
|
171
|
+
* wanted words is left, so any note found by meaning fits.
|
|
172
|
+
*/
|
|
173
|
+
export declare const fieldsOfQuery: (root: QueryNode) => QueryNode | undefined;
|
|
174
|
+
/** The words and phrases the query asks for, for the meaning half to embed; none it excludes, no field. */
|
|
175
|
+
export declare const meaningWords: (root: QueryNode) => string;
|
|
167
176
|
/**
|
|
168
177
|
* `search notes`: words and stems, and meaning when the local model is there, merged by reciprocal rank;
|
|
169
|
-
* each hit says which half found it, and the answer lists what the notes found link to.
|
|
178
|
+
* each hit says which half found it, and the answer lists what the notes found link to. Every field of the query and
|
|
179
|
+
* of `filter` holds for every hit, found by words or by meaning; a query of fields alone is not searched by meaning.
|
|
170
180
|
*/
|
|
171
181
|
export declare const searchNotes: (store: MessageStore, query: string, { limit, offset, tag, filter: expression, folderIds, withInternal, source: given, exact, timezone, env, command, embedders, pictures: withPictures, pictureLimit, pictureEmbedder, }?: NotesQuery) => Promise<NotesFound>;
|
|
172
182
|
/** What `notes:internal` and `notes:<folder id>` ask of the store: one query, so a page of it is one page. */
|
|
@@ -19,8 +19,10 @@ import { listContextOf } from "./search-kind.js";
|
|
|
19
19
|
* Text in the query language messages use, parsed for a store search: `date:` read in `timezone`, and the store's
|
|
20
20
|
* stemmer when a word is stemmed.
|
|
21
21
|
*/
|
|
22
|
-
export const storeQuery = async (store, text, { exact, timezone } = {}) =>
|
|
23
|
-
|
|
22
|
+
export const storeQuery = async (store, text, { exact, timezone } = {}) => resolvedQuery(store, parsedQuery(text, exact), timezone);
|
|
23
|
+
const parsedQuery = (text, exact) => validateFields(parseLucene(text, { ...SEARCH_QUERY, defaultField: exact ? "exact" : "text" })).root;
|
|
24
|
+
const resolvedQuery = async (store, root, timezone) => {
|
|
25
|
+
const ast = { root };
|
|
24
26
|
const zone = timezoneOf(timezone);
|
|
25
27
|
const resolve = (node) => {
|
|
26
28
|
if (node.kind === "boolean")
|
|
@@ -145,8 +147,6 @@ const MAX_LINE = 200;
|
|
|
145
147
|
const MAX_NOTES_OFFSET = 1000;
|
|
146
148
|
/** How deep each half is read before the two are merged. */
|
|
147
149
|
const CANDIDATES = 50;
|
|
148
|
-
/** What a filter may narrow the meaning half to; past this the filter is applied to words only. */
|
|
149
|
-
const FILTERED = 500;
|
|
150
150
|
/** Reciprocal rank fusion's usual constant, as conversations use. */
|
|
151
151
|
export const RRF_K = 60;
|
|
152
152
|
const firstLine = (text, query) => {
|
|
@@ -225,17 +225,88 @@ export const noteSourceOfQuery = (query) => {
|
|
|
225
225
|
}
|
|
226
226
|
return { source, rest };
|
|
227
227
|
};
|
|
228
|
+
const WORD_FIELDS = new Set(["text", "exact"]);
|
|
229
|
+
/**
|
|
230
|
+
* What a note found by meaning must still match: the query with each word it asks for taken as matched, since the
|
|
231
|
+
* meaning half stands in for those. Words it excludes, and every other field, stay. `undefined` when nothing but
|
|
232
|
+
* wanted words is left, so any note found by meaning fits.
|
|
233
|
+
*/
|
|
234
|
+
export const fieldsOfQuery = (root) => {
|
|
235
|
+
let narrows = false;
|
|
236
|
+
const kept = (node) => {
|
|
237
|
+
if (node.kind === "boolean")
|
|
238
|
+
return {
|
|
239
|
+
...node,
|
|
240
|
+
clauses: node.clauses.map(({ occur, node }) => {
|
|
241
|
+
if (occur !== "must_not")
|
|
242
|
+
return { occur, node: kept(node) };
|
|
243
|
+
narrows = true;
|
|
244
|
+
return { occur, node };
|
|
245
|
+
}),
|
|
246
|
+
};
|
|
247
|
+
if (!WORD_FIELDS.has(node.field)) {
|
|
248
|
+
narrows = true;
|
|
249
|
+
return node;
|
|
250
|
+
}
|
|
251
|
+
// `in:notes` holds for every note and document; an empty clause list would match nothing.
|
|
252
|
+
return { kind: "predicate", field: "in", operator: "term", value: "notes", span: node.span };
|
|
253
|
+
};
|
|
254
|
+
const fields = kept(root);
|
|
255
|
+
return narrows ? fields : undefined;
|
|
256
|
+
};
|
|
257
|
+
/** The words and phrases the query asks for, for the meaning half to embed; none it excludes, no field. */
|
|
258
|
+
export const meaningWords = (root) => {
|
|
259
|
+
const wanted = (node) => node.kind === "boolean"
|
|
260
|
+
? node.clauses.flatMap(({ occur, node }) => (occur === "must_not" ? [] : wanted(node)))
|
|
261
|
+
: WORD_FIELDS.has(node.field) && (node.operator === "term" || node.operator === "phrase")
|
|
262
|
+
? [node.value]
|
|
263
|
+
: [];
|
|
264
|
+
return wanted(root).join(" ").trim();
|
|
265
|
+
};
|
|
266
|
+
const allOf = (nodes) => nodes.length <= 1
|
|
267
|
+
? nodes[0]
|
|
268
|
+
: {
|
|
269
|
+
kind: "boolean",
|
|
270
|
+
clauses: nodes.map((node) => ({ occur: "must", node })),
|
|
271
|
+
span: { start: 0, end: 0 },
|
|
272
|
+
};
|
|
273
|
+
/**
|
|
274
|
+
* The notes of `refs` that match `root`, in the order of `refs`, each with the time `date:` read. One store query over
|
|
275
|
+
* just those notes, so a filter that matches thousands of notes still checks every candidate.
|
|
276
|
+
*/
|
|
277
|
+
const matching = async (store, refs, root, scope) => {
|
|
278
|
+
if (refs.length === 0)
|
|
279
|
+
return new Map();
|
|
280
|
+
const resolved = await resolvedQuery(store, root ?? { kind: "predicate", field: "in", operator: "term", value: "notes", span: { start: 0, end: 0 } }, scope.timezone);
|
|
281
|
+
const found = await store.notes.search({
|
|
282
|
+
root: resolved.root,
|
|
283
|
+
refs,
|
|
284
|
+
limit: refs.length,
|
|
285
|
+
...(scope.folderIds ? { folderIds: scope.folderIds } : {}),
|
|
286
|
+
...(scope.withInternal ? { withInternal: true } : {}),
|
|
287
|
+
...(scope.source ? { source: scope.source } : {}),
|
|
288
|
+
...(resolved.stemmer ? { stemmer: resolved.stemmer } : {}),
|
|
289
|
+
});
|
|
290
|
+
return new Map(found.items.map(({ ref, note, at }) => [ref, { note, at }]));
|
|
291
|
+
};
|
|
228
292
|
/**
|
|
229
293
|
* `search notes`: words and stems, and meaning when the local model is there, merged by reciprocal rank;
|
|
230
|
-
* each hit says which half found it, and the answer lists what the notes found link to.
|
|
294
|
+
* each hit says which half found it, and the answer lists what the notes found link to. Every field of the query and
|
|
295
|
+
* of `filter` holds for every hit, found by words or by meaning; a query of fields alone is not searched by meaning.
|
|
231
296
|
*/
|
|
232
297
|
export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, filter: expression, folderIds, withInternal, source: given, exact = false, timezone, env, command, embedders, pictures: withPictures = false, pictureLimit = 5, pictureEmbedder, } = {}) => {
|
|
233
298
|
const typed = noteSourceOfQuery(query);
|
|
234
299
|
const source = given ?? typed.source;
|
|
235
|
-
const
|
|
300
|
+
const rest = typed.source !== undefined && typed.source === source ? typed.rest : query;
|
|
236
301
|
const label = tag === undefined ? undefined : normalizeTag(tag);
|
|
237
302
|
const filter = [expression ? `(${expression})` : "", label === undefined ? "" : `tag:${label}`].filter(Boolean).join(" AND ") ||
|
|
238
303
|
undefined;
|
|
304
|
+
const parsed = query.trim() ? parsedQuery(query, exact) : undefined;
|
|
305
|
+
const words = parsed === undefined ? "" : meaningWords(parsed);
|
|
306
|
+
const narrowing = allOf([
|
|
307
|
+
...(parsed === undefined ? [] : [fieldsOfQuery(parsed)].filter((node) => node !== undefined)),
|
|
308
|
+
...(filter === undefined ? [] : [parsedQuery(filter)]),
|
|
309
|
+
]);
|
|
239
310
|
const text = [query.trim() ? `(${query})` : "", filter ?? ""].filter(Boolean).join(" AND ");
|
|
240
311
|
const window = offset + limit;
|
|
241
312
|
const scope = {
|
|
@@ -248,7 +319,7 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
|
|
|
248
319
|
let meaning;
|
|
249
320
|
if (exact)
|
|
250
321
|
meaningSkipped = "--exact searches words as written";
|
|
251
|
-
else if (words
|
|
322
|
+
else if (words) {
|
|
252
323
|
try {
|
|
253
324
|
const nearest = await nearestNotes(store, words, {
|
|
254
325
|
limit: Math.max(window, CANDIDATES),
|
|
@@ -259,13 +330,11 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
|
|
|
259
330
|
...(withInternal ? { withInternal: true } : {}),
|
|
260
331
|
...(source === undefined ? {} : { source }),
|
|
261
332
|
});
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
meaning = nearest.filter(({ ref }) => refs.has(ref));
|
|
268
|
-
}
|
|
333
|
+
const allowed = await matching(store, nearest.map(({ ref }) => ref), narrowing, scope);
|
|
334
|
+
meaning = nearest.flatMap(({ ref }) => {
|
|
335
|
+
const found = allowed.get(ref);
|
|
336
|
+
return found ? [{ ref, ...found }] : [];
|
|
337
|
+
});
|
|
269
338
|
}
|
|
270
339
|
catch (error) {
|
|
271
340
|
if (!(error instanceof CliError && error.code === "not_found"))
|
|
@@ -273,7 +342,7 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
|
|
|
273
342
|
meaningSkipped = error.message;
|
|
274
343
|
}
|
|
275
344
|
}
|
|
276
|
-
const titleWords = plainQueryWords(
|
|
345
|
+
const titleWords = plainQueryWords(rest);
|
|
277
346
|
const titled = titleWords === undefined
|
|
278
347
|
? { items: [], hasMore: false }
|
|
279
348
|
: await searchNotesQuery(store, {
|
|
@@ -292,10 +361,10 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
|
|
|
292
361
|
...scope,
|
|
293
362
|
});
|
|
294
363
|
const ranked = new Map();
|
|
295
|
-
const add = (list, by) => list.forEach(({ ref, note }, rank) => {
|
|
364
|
+
const add = (list, by) => list.forEach(({ ref, note, at }, rank) => {
|
|
296
365
|
if (tierRefs.has(ref))
|
|
297
366
|
return;
|
|
298
|
-
const held = ranked.get(ref) ?? { note, score: 0, foundBy: [] };
|
|
367
|
+
const held = ranked.get(ref) ?? { note, at, score: 0, foundBy: [] };
|
|
299
368
|
held.score += 1 / (RRF_K + rank + 1);
|
|
300
369
|
held.foundBy.push(by);
|
|
301
370
|
ranked.set(ref, held);
|
|
@@ -304,7 +373,7 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
|
|
|
304
373
|
if (meaning !== undefined)
|
|
305
374
|
add(meaning, "meaning");
|
|
306
375
|
const ordered = [
|
|
307
|
-
...tier.map(({ ref, note }) => [ref, { note, score: 0, foundBy: ["title"] }]),
|
|
376
|
+
...tier.map(({ ref, note, at }) => [ref, { note, at, score: 0, foundBy: ["title"] }]),
|
|
308
377
|
...[...ranked.entries()].sort(([, a], [, b]) => b.score - a.score),
|
|
309
378
|
];
|
|
310
379
|
const page = slice ? ordered : ordered.slice(offset, window);
|
|
@@ -313,7 +382,7 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
|
|
|
313
382
|
const counts = new Map();
|
|
314
383
|
const accounts = await store.accounts.storedAccounts();
|
|
315
384
|
const profiles = await store.accounts.profiles();
|
|
316
|
-
for (const [ref, { note, foundBy }] of page) {
|
|
385
|
+
for (const [ref, { note, at, foundBy }] of page) {
|
|
317
386
|
const links = (await store.notes.links({ from: ref })).map(({ to, targetText, kind, anchor }) => ({
|
|
318
387
|
to,
|
|
319
388
|
target_text: targetText,
|
|
@@ -327,6 +396,7 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
|
|
|
327
396
|
path: note.path,
|
|
328
397
|
title: note.title,
|
|
329
398
|
...documentSource(note, accounts, profiles, note.source === "file" ? (await store.files.ofDocument(note.ref))[0] : undefined),
|
|
399
|
+
updated_at: at,
|
|
330
400
|
excerpt: firstLine(note.text, words),
|
|
331
401
|
links,
|
|
332
402
|
found_by: foundBy,
|
|
@@ -360,6 +430,10 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
|
|
|
360
430
|
embedder: pictureEmbedder,
|
|
361
431
|
})
|
|
362
432
|
: {};
|
|
433
|
+
if (lane.pictures && narrowing !== undefined) {
|
|
434
|
+
const allowed = await matching(store, lane.pictures.map(({ ref }) => ref), narrowing, scope);
|
|
435
|
+
lane.pictures = lane.pictures.filter(({ ref }) => allowed.has(ref));
|
|
436
|
+
}
|
|
363
437
|
return {
|
|
364
438
|
query,
|
|
365
439
|
...(label === undefined ? {} : { tag: label }),
|
|
@@ -13,6 +13,8 @@ export interface NoteQuery {
|
|
|
13
13
|
/** With `folderIds`, the notes written in memo too, in the same ranked list. */
|
|
14
14
|
withInternal?: boolean;
|
|
15
15
|
source?: Note["source"];
|
|
16
|
+
/** Only these notes (`document:<id>`, `note:<id>`): checks a list found another way against the query. */
|
|
17
|
+
refs?: string[];
|
|
16
18
|
/** Newest first instead of best first. */
|
|
17
19
|
newest?: boolean;
|
|
18
20
|
/** The store's stemmer, set when a leaf is stemmed. */
|
|
@@ -26,6 +28,8 @@ export interface NoteHit {
|
|
|
26
28
|
relevance: number | null;
|
|
27
29
|
/** Whether the note holds the exact forms, when the search was stemmed. */
|
|
28
30
|
exact?: boolean;
|
|
31
|
+
/** The time `date:` read: a Drive document's edit time when the store has one, else when the store's copy changed. */
|
|
32
|
+
at: string;
|
|
29
33
|
}
|
|
30
34
|
/** The fields a note has. A message-only field (`from:`, `chat:`, …) is refused rather than matching nothing. */
|
|
31
35
|
export declare const NOTE_FIELDS: readonly ["text", "exact", "body", "tag", "date", "in", "has", "title", "site", "lang", "author", "keyword", "source"];
|
|
@@ -68,6 +72,7 @@ export interface CorpusRow {
|
|
|
68
72
|
/** What one corpus is searched by; `accountIds` narrows a corpus that has accounts. */
|
|
69
73
|
export type CorpusQuery = Pick<NoteQuery, "root" | "limit" | "offset" | "newest" | "stemmer" | "signal"> & {
|
|
70
74
|
accountIds?: string[];
|
|
75
|
+
ids?: number[];
|
|
71
76
|
};
|
|
72
77
|
/** Notes matching a parsed query, by the same language as messages: files and written notes, merged. */
|
|
73
78
|
export declare const searchNotes: (context: StoreContext, query: NoteQuery) => {
|
|
@@ -4,6 +4,7 @@ import { compileAutomaton, foldRegex, wildcardPattern, } from "../../search/luce
|
|
|
4
4
|
import { isStemmed } from "../../search/lucene/resolved.js";
|
|
5
5
|
import { exhausted, QUERY_LIMITS, searchError } from "../../search/lucene/types.js";
|
|
6
6
|
import { normalize } from "../normalize.js";
|
|
7
|
+
import { contextDocumentTime } from "./context-document-source.js";
|
|
7
8
|
import { documentPicturesOver } from "./document-pictures.js";
|
|
8
9
|
import { linkKind } from "./link-kinds.js";
|
|
9
10
|
import { prefixOf } from "./lucene.js";
|
|
@@ -42,7 +43,7 @@ const CORPORA = [
|
|
|
42
43
|
type: "document",
|
|
43
44
|
noun: "note",
|
|
44
45
|
fields: NOTE_FIELDS,
|
|
45
|
-
view:
|
|
46
|
+
view: `(SELECT n.id, n.account_id AS folder_id, n.external_id AS path, n.title, coalesce(n.body, '') AS text, n.metadata, ${contextDocumentTime} AS at, n.deleted_at FROM documents n)`,
|
|
46
47
|
words: "document_words",
|
|
47
48
|
stems: "document_stems",
|
|
48
49
|
live: "coalesce(json_extract(n.metadata, '$.catalog.active'), 1)=1",
|
|
@@ -67,7 +68,13 @@ export const searchNotes = (context, query) => {
|
|
|
67
68
|
const offset = query.offset ?? 0;
|
|
68
69
|
const chosen = CORPORA.filter(({ type }) => (query.source === undefined || (query.source === "file") === (type === "document")) &&
|
|
69
70
|
(!query.folderIds?.length || query.withInternal === true || type === "document"));
|
|
70
|
-
const
|
|
71
|
+
const idsOf = (type) => query.refs?.flatMap((ref) => (ref.startsWith(`${type}:`) ? [Number(ref.slice(type.length + 1))] : []));
|
|
72
|
+
const rows = chosen.flatMap((corpus) => {
|
|
73
|
+
const ids = idsOf(corpus.type);
|
|
74
|
+
if (ids?.length === 0)
|
|
75
|
+
return [];
|
|
76
|
+
return searchCorpus(context, { ...query, ...(query.folderIds ? { accountIds: query.folderIds } : {}), ...(ids ? { ids } : {}) }, corpus);
|
|
77
|
+
});
|
|
71
78
|
const exactFirst = rows.some((row) => row.exact !== undefined);
|
|
72
79
|
rows.sort((a, b) => query.newest
|
|
73
80
|
? (b.at ?? 0) - (a.at ?? 0) || a.id - b.id
|
|
@@ -89,6 +96,7 @@ export const searchNotes = (context, query) => {
|
|
|
89
96
|
note,
|
|
90
97
|
relevance: row.relevance,
|
|
91
98
|
...(row.exact === undefined ? {} : { exact: row.exact }),
|
|
99
|
+
at: new Date(row.at ?? 0).toISOString(),
|
|
92
100
|
};
|
|
93
101
|
}),
|
|
94
102
|
hasMore: rows.length > offset + query.limit,
|
|
@@ -247,6 +255,7 @@ export const searchCorpus = (context, query, corpus) => {
|
|
|
247
255
|
bound("n.deleted_at IS NULL"),
|
|
248
256
|
...(corpus.live ? [bound(corpus.live)] : []),
|
|
249
257
|
...(query.accountIds?.length && corpus.inAccounts ? [corpus.inAccounts(JSON.stringify(query.accountIds))] : []),
|
|
258
|
+
...(query.ids ? [bound("n.id IN (SELECT value FROM json_each(?))", JSON.stringify(query.ids))] : []),
|
|
250
259
|
], "AND");
|
|
251
260
|
const where = combine([scope, expression], "AND");
|
|
252
261
|
const ranking = query.newest ? undefined : required(query.root, stemmed ? "stems" : "fts");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.497.0",
|
|
4
4
|
"description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Viacheslav Ptsarev",
|
package/parity.json
CHANGED
|
@@ -387,7 +387,7 @@
|
|
|
387
387
|
"--filter": {
|
|
388
388
|
"value": "query",
|
|
389
389
|
"meaning": "Strict Lucene filter: any message in the conversation must match; the meaning query stays unchanged",
|
|
390
|
-
"note": "search notes uses it for a query every note must also match,
|
|
390
|
+
"note": "search notes uses it for a query every note must also match, found by words or by meaning"
|
|
391
391
|
},
|
|
392
392
|
"--find-by-phone": {
|
|
393
393
|
"value": "<who>",
|