@wirecat/cli-core 0.489.0 → 0.491.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 CHANGED
@@ -5,6 +5,29 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
+ ## 0.491.0 — 31.10.2026
9
+
10
+ ### Fixed
11
+
12
+ - **A budgeted mail sync now finishes locating a large folder stored before email locations existed.** It listed
13
+ the same newest messages every run, so a 16,100-message INBOX never got every UID stored and never switched to
14
+ the quick UID listing. Each budgeted run now leaves out the mail already done and located and lists the next
15
+ part; once the rest is listed whole, the folder is located. Such a run checks no deletions.
16
+
17
+ ## 0.490.0 — 31.10.2026
18
+
19
+ ### Changed — may break callers
20
+
21
+ - **Notes, documents, knowledge annotations and contacts `--notes` are found by any part of a word, in any letter
22
+ case (store version 40).** `notes` with a search, `knowledge.annotations` with a search and `contacts` with
23
+ `notes` used `instr(lower(...))`, which folds only Latin letters and reads every row: a mixed-case Cyrillic word
24
+ was missed, and a search took 1.5–4 s on a store five times today's size. They now match `note_trigrams` and
25
+ `document_trigrams`, trigram indexes over the normalized title and text (`ё` as `е`, accents dropped) that the
26
+ notes drain keeps, and answer in milliseconds. A query of one or two characters is refused with "search needs at
27
+ least three characters", as message search is; an empty one still filters nothing. The title is searched too,
28
+ not only the text. The notes and documents stored before version 40 get their trigrams in batches from the
29
+ notes drain (`search notes`, `store migrate`); until then they are matched as before.
30
+
8
31
  ## 0.489.0 — 31.10.2026
9
32
 
10
33
  ### Added
package/dist/mail/sync.js CHANGED
@@ -147,6 +147,8 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
147
147
  /** Every folder's stored point, valid or not: the widest window outlives what invalidates the rest. */
148
148
  const storedPoints = new Map();
149
149
  const resumed = new Set();
150
+ /** Backfilling folders listed without the mail already done and located: their listing proves no deletion. */
151
+ const narrowed = new Set();
150
152
  let mboxPoint;
151
153
  let mboxStamp;
152
154
  // A rerun lists only what the last left; --rescan, a rule change or a new UIDVALIDITY lists everything again.
@@ -254,6 +256,15 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
254
256
  resumed.add(folder);
255
257
  wanted = { ...searched, uids: searched.uids.filter((uid) => !within(point.done, Number(uid))) };
256
258
  }
259
+ else if (backfill && plan !== undefined) {
260
+ // A budgeted run lists newest first, so a backfill that starts over every run never reaches the oldest mail.
261
+ const placed = new Set((await store.mail.folderEmails(accountId, folder, searched.validity)).map(({ uid }) => uid));
262
+ const left = searched.uids.filter((uid) => !(placed.has(Number(uid)) && within(point.done, Number(uid))));
263
+ if (left.length < searched.uids.length) {
264
+ narrowed.add(folder);
265
+ wanted = { ...searched, uids: left };
266
+ }
267
+ }
257
268
  }
258
269
  if (plan !== undefined) {
259
270
  plan.modes.set(folder, resumed.has(folder) ? "resumed" : "full");
@@ -364,6 +375,8 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
364
375
  ? "folders this run did not list by UID are not checked for deletions; --rescan lists every message and checks them"
365
376
  : undefined);
366
377
  }
378
+ else if (narrowed.size > 0)
379
+ skipped = "this run listed only mail whose place is not stored yet; once every place is, deletions are checked";
367
380
  else if (resumed.size > 0)
368
381
  skipped =
369
382
  "this run listed only mail not read before; a full listing, at least daily or with --rescan, checks deletions";
@@ -487,6 +500,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
487
500
  // A narrower whole listing, like the timer's 30 days, does not replace a wider one that is still fresh.
488
501
  const keepFull = before !== undefined &&
489
502
  (resumed.has(folder) ||
503
+ narrowed.has(folder) ||
490
504
  (Date.parse(before.full.since) < since.getTime() && startedAt - Date.parse(before.full.at) < FULL_PASS_MS));
491
505
  const merged = mergeRanges(before?.done ?? [], rangesOf(done));
492
506
  const widest = before === undefined || since.getTime() <= Date.parse(before.window.since);
@@ -28,7 +28,7 @@ export declare const people: ({ orm, database }: StoreContext, provider: Provide
28
28
  export interface ContactFilter {
29
29
  /** Three letters or more of the name, the @username or the local alias. */
30
30
  query?: string;
31
- /** Text in one of the owner's private notes about them; case folds for Latin letters only, as SQLite's lower(). */
31
+ /** Three characters or more of one of the owner's private notes about them, anywhere, in any letter case. */
32
32
  notes?: string;
33
33
  }
34
34
  export declare const contacts: ({ orm }: StoreContext, accountKey: number, key: AccountKey, { order, limit, offset, ...filter }: {
@@ -2,7 +2,9 @@ import { CliError } from "@wirecat/cli-base";
2
2
  import { fold } from "../normalize.js";
3
3
  import { and, asc, desc, eq, inArray, isNotNull, isNull, ne, sql } from "./drizzle/core.js";
4
4
  import { folded } from "./folded.js";
5
+ import { holdingText } from "./note-index.js";
5
6
  import { resolvePersonLinks } from "./person-resolution.js";
7
+ import { bound } from "./query-filter.js";
6
8
  import { accountIdentities, accounts, aliases, chatMembers, chats, identities, identityLinkEvents, identityLinks, identityRevisions, messages, persons, } from "./schema.js";
7
9
  import { toIso } from "./values.js";
8
10
  const flag = (value) => (value === undefined || value === null ? null : Number(value));
@@ -209,13 +211,14 @@ const contactsWhere = (accountKey, key, { query, notes }) => {
209
211
  if (query !== undefined && query.trim().length < 3) {
210
212
  throw new CliError("validation_error", `a contact search takes at least 3 characters, got "${query}"`);
211
213
  }
214
+ const noted = notes === undefined ? undefined : holdingText("note", "n.id", notes);
212
215
  return and(eq(accountIdentities.accountId, accountKey), ne(identities.externalId, key.account), sql `EXISTS (SELECT 1 FROM ${chatMembers} JOIN ${chats} ON ${chats.id} = ${chatMembers.chatId}
213
216
  WHERE ${chatMembers.identityId} = ${identities.id} AND ${chats.accountId} = ${accountIdentities.accountId}
214
217
  AND ${chats.kind} = 'dialog')`, query === undefined
215
218
  ? undefined
216
219
  : sql `(${identities.id} IN (SELECT rowid FROM identities_fts WHERE identities_fts MATCH ${`"${query.trim().replaceAll('"', '""')}"`}) OR EXISTS (SELECT 1 FROM aliases ca WHERE ca.aliasable_type='identity' AND ca.display=1 AND ca.account_id=${accountKey} AND ca.aliasable_id=${identities.id} AND instr(ca.name_folded, ${fold(query.trim())}) > 0))`, notes === undefined
217
220
  ? undefined
218
- : sql `EXISTS (SELECT 1 FROM notes n WHERE n.notable_type='identity' AND n.notable_id=${identities.id} AND n.deleted_at IS NULL AND instr(lower(n.body), lower(${notes})) > 0)`);
221
+ : sql `EXISTS (SELECT 1 FROM notes n WHERE n.notable_type='identity' AND n.notable_id=${identities.id} AND n.deleted_at IS NULL${noted ? sql ` AND ${bound(noted)}` : sql ``})`);
219
222
  };
220
223
  export const contacts = ({ orm }, accountKey, key, { order, limit, offset = 0, ...filter }) => {
221
224
  const byName = [
@@ -10,6 +10,7 @@ import { ulid } from "../ulid.js";
10
10
  import { ownerPerson } from "./actors.js";
11
11
  import { linkKind, RELATION_KINDS } from "./link-kinds.js";
12
12
  import { meetingProjectsStoreOver } from "./meeting-projects.js";
13
+ import { holdingText } from "./note-index.js";
13
14
  import { insertLink, linkOf } from "./notes.js";
14
15
  import { queryFilter } from "./query-filter.js";
15
16
  import { addTags as addThingTags, canonicalTagNamed, createTag, labelsOf, liveTagging, removeTags as removeThingTags, setMainTopic, } from "./tags.js";
@@ -314,10 +315,11 @@ export const knowledgeStoreOver = (context) => {
314
315
  const thing = options.target ? resolve(key, options.target).thing : undefined;
315
316
  if (options.target && !thing)
316
317
  return { items: [], hasMore: false };
318
+ const text = options.search === undefined ? undefined : holdingText("note", "id", options.search);
317
319
  const rows = database
318
320
  .prepare("SELECT * FROM notes WHERE deleted_at IS NULL AND (? IS NULL OR (notable_type = ? AND notable_id = ?)) " +
319
- "AND (? IS NULL OR instr(lower(body), lower(?)) > 0) ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?")
320
- .all(thing?.type ?? null, thing?.type ?? null, thing?.id ?? null, options.search ?? null, options.search ?? null, limit + 1, offset);
321
+ `${text ? `AND ${text.sql} ` : ""}ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`)
322
+ .all(thing?.type ?? null, thing?.type ?? null, thing?.id ?? null, ...(text?.params ?? []), limit + 1, offset);
321
323
  const items = rows.slice(0, limit).flatMap((row) => annotationOf(row) ?? []);
322
324
  return { items, hasMore: rows.length > limit };
323
325
  },
@@ -53,6 +53,7 @@ export const MANIFEST = [
53
53
  { name: "20261029224106_version-38-folded-names", version: 38, minCompatible: 34 },
54
54
  { name: "20261029224107_version-38-folded-triggers", version: 38, minCompatible: 34 },
55
55
  { name: "20261030114008_version-39-email-locations", version: 39, minCompatible: 34 },
56
+ { name: "20261030225520_version-40-note-trigrams", version: 40, minCompatible: 34 },
56
57
  ];
57
58
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
58
59
  const migrations = [];
@@ -837,5 +837,15 @@ export const GENERATED = [
837
837
  "CREATE TABLE `email_locations` (\n\t`mailbox_id` integer NOT NULL,\n\t`uidvalidity` text NOT NULL,\n\t`uid` integer NOT NULL,\n\t`email_id` integer NOT NULL,\n\tCONSTRAINT `email_locations_pk` PRIMARY KEY(`mailbox_id`, `uidvalidity`, `uid`),\n\tCONSTRAINT `fk_email_locations_mailbox_id_mailboxes_id_fk` FOREIGN KEY (`mailbox_id`) REFERENCES `mailboxes`(`id`),\n\tCONSTRAINT `fk_email_locations_email_id_emails_id_fk` FOREIGN KEY (`email_id`) REFERENCES `emails`(`id`)\n);",
838
838
  "CREATE INDEX `email_locations_by_email_id` ON `email_locations` (`email_id`);"
839
839
  ]
840
+ },
841
+ {
842
+ "name": "20261030225520_version-40-note-trigrams",
843
+ "statements": [
844
+ "CREATE VIRTUAL TABLE note_trigrams USING fts5(text, content = '', contentless_delete = 1, tokenize = 'trigram');",
845
+ "CREATE VIRTUAL TABLE document_trigrams USING fts5(text, content = '', contentless_delete = 1, tokenize = 'trigram');",
846
+ "CREATE TRIGGER note_trigrams_bd BEFORE DELETE ON notes BEGIN\n DELETE FROM note_trigrams WHERE rowid = old.id;\nEND;",
847
+ "CREATE TRIGGER document_trigrams_bd BEFORE DELETE ON documents BEGIN\n DELETE FROM document_trigrams WHERE rowid = old.id;\nEND;",
848
+ "-- The trigrams hold JS-normalized text, which SQL cannot produce: fillTrigrams fills the rows stored before this\n-- version, up to `watermark`, and a search reads the rest of them by their text meanwhile.\nINSERT INTO search_index_state (name, watermark, filled_through, terms_through, normalizer_version)\n SELECT 'note_trigrams', coalesce(max(id), 0), 0, 0, 1 FROM notes\n UNION ALL SELECT 'document_trigrams', coalesce(max(id), 0), 0, 0, 1 FROM documents;"
849
+ ]
840
850
  }
841
851
  ];
@@ -1,5 +1,6 @@
1
1
  import { type Stemmer, type Stemmers } from "../../search/stem.js";
2
2
  import type { CacheDatabase } from "../driver.js";
3
+ import type { Where } from "./query-filter.js";
3
4
  export interface NoteIndexState {
4
5
  /** Rows written since they were indexed. */
5
6
  pending: number;
@@ -102,6 +103,20 @@ export declare const CORPORA: {
102
103
  };
103
104
  /** What a row is indexed as: its title, then its text. Chunk offsets point into this. */
104
105
  export declare const noteIndexText: (title: string | null, text: string) => string;
106
+ /**
107
+ * `column` (a note's or document's id) is a row whose title or text holds `search` anywhere, in any letter case;
108
+ * no condition for an empty `search`. A row the trigrams do not hold yet — waiting in its queue, or stored before
109
+ * version 40 and not yet reached by `fillTrigrams` — is matched by its text as SQLite's `lower()` reads it.
110
+ */
111
+ export declare const holdingText: (type: "note" | "document", column: string, search: string) => Where | undefined;
112
+ /**
113
+ * Writes the trigrams of the notes and documents stored before version 40, in short write transactions, up to the
114
+ * highest id the migration found; later rows get theirs from the drain. Stopping between batches loses nothing.
115
+ */
116
+ export declare const fillTrigrams: (database: CacheDatabase, { batch, until }?: {
117
+ batch?: number;
118
+ until?: () => boolean;
119
+ }) => number;
105
120
  export declare const corpusIndexState: (database: CacheDatabase, corpus: Corpus) => NoteIndexState;
106
121
  /** Documents and notes together: what a notes search reads. */
107
122
  export declare const noteIndexState: (database: CacheDatabase) => NoteIndexState;
@@ -113,7 +128,7 @@ export declare const drainCorpus: (database: CacheDatabase, corpus: Corpus, stem
113
128
  batch?: number;
114
129
  until?: () => boolean;
115
130
  }) => number;
116
- /** Documents, then notes: what a notes search reads. */
131
+ /** Documents, then notes: what a notes search reads; then the trigrams still to fill. */
117
132
  export declare const drainNoteIndex: (database: CacheDatabase, stemmerFor: (stemmers: Stemmers) => Stemmer, options?: {
118
133
  batch?: number;
119
134
  until?: () => boolean;
@@ -1,3 +1,4 @@
1
+ import { CliError } from "@wirecat/cli-base";
1
2
  import { CHUNK_CHARS, chunkHash, splitSections, splitText } from "../../conversations/chunks.js";
2
3
  import { analyzerIdentity, DEFAULT_STEMMERS } from "../../search/stem.js";
3
4
  import { taskStemsOf, taskWordsOf } from "../../search/task-similarity.js";
@@ -103,6 +104,62 @@ export const CORPORA = {
103
104
  /** What a row is indexed as: its title, then its text. Chunk offsets point into this. */
104
105
  export const noteIndexText = (title, text) => (title ? `${title}\n\n${text}` : text);
105
106
  const queuedAs = (corpus) => corpus.queued ?? corpus.type;
107
+ const hasTrigrams = (corpus) => corpus.type === "note" || corpus.type === "document";
108
+ /**
109
+ * `column` (a note's or document's id) is a row whose title or text holds `search` anywhere, in any letter case;
110
+ * no condition for an empty `search`. A row the trigrams do not hold yet — waiting in its queue, or stored before
111
+ * version 40 and not yet reached by `fillTrigrams` — is matched by its text as SQLite's `lower()` reads it.
112
+ */
113
+ export const holdingText = (type, column, search) => {
114
+ const text = normalize(search);
115
+ if (text === "")
116
+ return undefined;
117
+ // A trigram index answers a shorter query with nothing, silently.
118
+ if ([...text].length < 3)
119
+ throw new CliError("validation_error", "search needs at least three characters");
120
+ const queued = `SELECT id FROM ${type}_index_pending WHERE indexable_type = '${type}'`;
121
+ const state = (field) => `(SELECT ${field} FROM search_index_state WHERE name = '${type}_trigrams')`;
122
+ return {
123
+ sql: `${column} IN (SELECT rowid FROM ${type}_trigrams WHERE ${type}_trigrams MATCH ? AND rowid NOT IN (${queued}) ` +
124
+ `UNION ALL SELECT r.id FROM ${type}s r WHERE (r.id IN (${queued}) OR (r.id > ${state("filled_through")} ` +
125
+ `AND r.id <= ${state("watermark")})) AND instr(lower(coalesce(r.body, '')), lower(?)) > 0)`,
126
+ params: [`"${text.replaceAll('"', '""')}"`, search],
127
+ };
128
+ };
129
+ /**
130
+ * Writes the trigrams of the notes and documents stored before version 40, in short write transactions, up to the
131
+ * highest id the migration found; later rows get theirs from the drain. Stopping between batches loses nothing.
132
+ */
133
+ export const fillTrigrams = (database, { batch = 200, until = () => false } = {}) => {
134
+ let filled = 0;
135
+ for (const type of ["document", "note"]) {
136
+ const name = `${type}_trigrams`;
137
+ const state = database.prepare("SELECT watermark, filled_through FROM search_index_state WHERE name = ?");
138
+ const rows = database.prepare(`SELECT id, title, coalesce(body, '') AS body FROM ${type}s WHERE id > ? AND id <= ? AND deleted_at IS NULL ` +
139
+ "ORDER BY id LIMIT ?");
140
+ const drop = database.prepare(`DELETE FROM ${name} WHERE rowid = ?`);
141
+ const add = database.prepare(`INSERT INTO ${name} (rowid, text) VALUES (?, ?)`);
142
+ const reached = database.prepare("UPDATE search_index_state SET filled_through = ? WHERE name = ?");
143
+ for (;;) {
144
+ const at = state.get(name);
145
+ const mark = Number(at?.watermark ?? 0);
146
+ if (Number(at?.filled_through ?? mark) >= mark || until())
147
+ break;
148
+ filled += write(database, () => {
149
+ const page = rows.all(Number(at?.filled_through), mark, batch);
150
+ for (const row of page) {
151
+ const text = normalize(noteIndexText(row.title == null ? null : String(row.title), String(row.body)));
152
+ drop.run(Number(row.id));
153
+ if (text !== "")
154
+ add.run(Number(row.id), text);
155
+ }
156
+ reached.run(page.length < batch ? mark : Number(page.at(-1)?.id), name);
157
+ return page.length;
158
+ });
159
+ }
160
+ }
161
+ return filled;
162
+ };
106
163
  export const corpusIndexState = (database, corpus) => {
107
164
  const row = indexRow(database, corpus.index);
108
165
  const pending = Number(database
@@ -139,9 +196,13 @@ const writer = (database, corpus, stemmer) => {
139
196
  const statements = {
140
197
  dropWords: database.prepare(`DELETE FROM ${corpus.type}_words WHERE rowid = ?`),
141
198
  dropStems: database.prepare(`DELETE FROM ${corpus.type}_stems WHERE rowid = ?`),
199
+ dropTrigrams: hasTrigrams(corpus) ? database.prepare(`DELETE FROM ${corpus.type}_trigrams WHERE rowid = ?`) : null,
142
200
  dropChunks: database.prepare("DELETE FROM chunks WHERE chunkable_type = ? AND chunkable_id = ?"),
143
201
  words: database.prepare(`INSERT INTO ${corpus.type}_words (rowid, normalized_text, scope) VALUES (?, ?, ?)`),
144
202
  stems: database.prepare(`INSERT INTO ${corpus.type}_stems (rowid, stems, scope) VALUES (?, ?, ?)`),
203
+ trigrams: hasTrigrams(corpus)
204
+ ? database.prepare(`INSERT INTO ${corpus.type}_trigrams (rowid, text) VALUES (?, ?)`)
205
+ : null,
145
206
  chunk: database.prepare("INSERT INTO chunks (chunkable_type, chunkable_id, position, start_offset, end_offset, content_hash, scope, account_id, created_at, updated_at) " +
146
207
  "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
147
208
  dequeue: database.prepare(`DELETE FROM ${corpus.type}_index_pending WHERE indexable_type = ? AND id = ?`),
@@ -151,14 +212,17 @@ const writer = (database, corpus, stemmer) => {
151
212
  const before = oldHashes.all(corpus.type, id).map((hash) => String(hash.hash));
152
213
  statements.dropWords.run(id);
153
214
  statements.dropStems.run(id);
215
+ statements.dropTrigrams?.run(id);
154
216
  statements.dropChunks.run(corpus.type, id);
155
217
  const live = row !== undefined && Number(row.live) === 1;
156
218
  const text = live ? noteIndexText(row.title == null ? null : String(row.title), String(row.body)) : "";
157
219
  const scope = live ? String(row.scope ?? "personal") : "";
158
220
  const terms = corpus.terms && text ? corpus.terms(text, stemmer) : undefined;
159
221
  const words = terms ? terms.words : normalize(text);
160
- if (words !== "")
222
+ if (words !== "") {
161
223
  statements.words.run(id, words, scope);
224
+ statements.trigrams?.run(id, words);
225
+ }
162
226
  const stems = terms ? terms.stems : text ? stemmer.indexText(text) : "";
163
227
  if (stems !== "")
164
228
  statements.stems.run(id, stems, scope);
@@ -228,9 +292,10 @@ export const drainCorpus = (database, corpus, stemmerFor, { batch = 200, until =
228
292
  }
229
293
  return done;
230
294
  };
231
- /** Documents, then notes: what a notes search reads. */
295
+ /** Documents, then notes: what a notes search reads; then the trigrams still to fill. */
232
296
  export const drainNoteIndex = (database, stemmerFor, options = {}) => drainCorpus(database, CORPORA.document, stemmerFor, options) +
233
- drainCorpus(database, CORPORA.note, stemmerFor, options);
297
+ drainCorpus(database, CORPORA.note, stemmerFor, options) +
298
+ fillTrigrams(database, options);
234
299
  /** Queues every document, note and memory again — for `store rebuild`. */
235
300
  export const resetNoteIndex = (database) => {
236
301
  write(database, () => {
@@ -12,6 +12,7 @@ import { PREFERRED_FILE, syncFile } from "./files.js";
12
12
  import { importedDocumentWhere } from "./imported-documents.js";
13
13
  import { LINK_KINDS, LINK_ORIGINS, linkKind } from "./link-kinds.js";
14
14
  import { reviewLink } from "./link-reviews.js";
15
+ import { holdingText } from "./note-index.js";
15
16
  import { addTags, ensureTag, labelsOf } from "./tags.js";
16
17
  import { canonicalThingReference, referenceOfThing, storedThing, thingOf } from "./things.js";
17
18
  import { write } from "./transaction.js";
@@ -525,7 +526,8 @@ export const notesStoreOver = (context) => {
525
526
  if (options.about !== undefined && !about)
526
527
  return { items: [], hasMore: false };
527
528
  const folder = options.folderId === undefined ? null : Number(folderRow(options.folderId).id);
528
- const search = options.search ?? null;
529
+ const documentText = options.search === undefined ? undefined : holdingText("document", "d.id", options.search);
530
+ const noteText = options.search === undefined ? undefined : holdingText("note", "n.id", options.search);
529
531
  const aboutType = about?.type ?? null;
530
532
  const aboutId = about?.id ?? null;
531
533
  const files = options.source === "internal"
@@ -541,16 +543,16 @@ export const notesStoreOver = (context) => {
541
543
  "WHERE f.deleted_at IS NULL AND d.deleted_at IS NULL AND f.account_id = ? AND ? IS NOT NULL ") +
542
544
  "AND coalesce(json_extract(d.metadata, '$.catalog.active'), 1)=1 " +
543
545
  `AND (? IS NULL OR EXISTS (SELECT 1 FROM links l WHERE l.from_type = 'document' AND l.from_id = d.id AND l.kind = ${linkKind("about")} AND l.to_type = ? AND l.to_id = ?)) ` +
544
- "AND (? IS NULL OR instr(lower(coalesce(d.body, '')), lower(?)) > 0) ORDER BY d.created_at DESC, d.id DESC LIMIT ?")
545
- .all(folder, folder, aboutType, aboutType, aboutId, search, search, limit + offset + 1)
546
+ `${documentText ? `AND ${documentText.sql} ` : ""}ORDER BY d.created_at DESC, d.id DESC LIMIT ?`)
547
+ .all(folder, folder, aboutType, aboutType, aboutId, ...(documentText?.params ?? []), limit + offset + 1)
546
548
  .map(documentOf);
547
549
  const written = options.source === "file" || folder !== null
548
550
  ? []
549
551
  : database
550
552
  .prepare("SELECT n.* FROM notes n WHERE n.deleted_at IS NULL " +
551
553
  `AND (? IS NULL OR (n.notable_type = ? AND n.notable_id = ?) OR EXISTS (SELECT 1 FROM links l WHERE l.from_type = 'note' AND l.from_id = n.id AND l.kind = ${linkKind("about")} AND l.to_type = ? AND l.to_id = ?)) ` +
552
- "AND (? IS NULL OR instr(lower(n.body), lower(?)) > 0) ORDER BY n.created_at DESC, n.id DESC LIMIT ?")
553
- .all(aboutType, aboutType, aboutId, aboutType, aboutId, search, search, limit + offset + 1)
554
+ `${noteText ? `AND ${noteText.sql} ` : ""}ORDER BY n.created_at DESC, n.id DESC LIMIT ?`)
555
+ .all(aboutType, aboutType, aboutId, aboutType, aboutId, ...(noteText?.params ?? []), limit + offset + 1)
554
556
  .map(noteOf);
555
557
  const all = [...files, ...written].sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.ref.localeCompare(a.ref));
556
558
  return { items: all.slice(offset, offset + limit), hasMore: all.length > offset + limit };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.489.0",
3
+ "version": "0.491.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",