@tobq/boardclip 0.0.0-stage → 2.1.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -2
  3. package/assets/boardclip-icon.png +0 -0
  4. package/bin/boardclip.js +43 -0
  5. package/editor-preload.js +71 -0
  6. package/editor.html +220 -0
  7. package/icon.png +0 -0
  8. package/icon@2x.png +0 -0
  9. package/iconTemplate.png +0 -0
  10. package/iconTemplate@2x.png +0 -0
  11. package/index.html +1339 -0
  12. package/lib/appearance.js +183 -0
  13. package/lib/auto-update.js +306 -0
  14. package/lib/backup-worker.js +46 -0
  15. package/lib/backup.js +275 -0
  16. package/lib/blob-field-store.js +275 -0
  17. package/lib/blob-store.js +68 -0
  18. package/lib/build-info.js +89 -0
  19. package/lib/clip-blob-store.js +161 -0
  20. package/lib/clip-revision.js +81 -0
  21. package/lib/clipboard-capture.js +95 -0
  22. package/lib/clipboard-follow.js +98 -0
  23. package/lib/clipboard-model.js +1235 -0
  24. package/lib/cloud-accounts.js +250 -0
  25. package/lib/conflict-model.js +186 -0
  26. package/lib/control-client.js +87 -0
  27. package/lib/control-server.js +149 -0
  28. package/lib/diagnostics.js +121 -0
  29. package/lib/drag-files.js +48 -0
  30. package/lib/ensure-directory.js +56 -0
  31. package/lib/fork-names.js +35 -0
  32. package/lib/fs-probe.js +132 -0
  33. package/lib/history-feed.js +48 -0
  34. package/lib/hmac-auth.js +78 -0
  35. package/lib/macos-hotkey.js +297 -0
  36. package/lib/macos-paste.js +112 -0
  37. package/lib/mcp-core.js +270 -0
  38. package/lib/mcp-installers.js +284 -0
  39. package/lib/mcp-paths.js +99 -0
  40. package/lib/p2p-crypto.js +54 -0
  41. package/lib/p2p-discovery.js +191 -0
  42. package/lib/quick-paste.js +187 -0
  43. package/lib/retention.js +43 -0
  44. package/lib/sync-delta.js +227 -0
  45. package/lib/sync-journal.js +181 -0
  46. package/lib/sync-paths.js +73 -0
  47. package/lib/tailscale.js +80 -0
  48. package/lib/text-blob-store.js +38 -0
  49. package/lib/windows-clipboard.js +213 -0
  50. package/lib/windows-console.js +63 -0
  51. package/lib/windows-dwm.js +183 -0
  52. package/lib/windows-hook-worker.js +236 -0
  53. package/lib/windows-hook.js +91 -0
  54. package/lib/windows-paste.js +185 -0
  55. package/main.js +7540 -0
  56. package/mcp/boardclip-mcp.js +255 -0
  57. package/mcp-approval-preload.js +22 -0
  58. package/mcp-approval.html +218 -0
  59. package/package.json +55 -4
  60. package/preload.js +106 -0
  61. package/site/index.html +1624 -0
  62. package/site/shared/clip-search.js +2330 -0
  63. package/site/shared/clipboard-popup.css +1046 -0
  64. package/site/shared/clipboard-tokens.css +326 -0
  65. package/site/shared/clipboard-ui-core.js +6921 -0
  66. package/site/shared/clipboard-window.css +36 -0
  67. package/site/shared/vendor/cm5/README.md +52 -0
  68. package/site/shared/vendor/cm5/codemirror.css +344 -0
  69. package/site/shared/vendor/cm5/codemirror.js +9884 -0
  70. package/site/shared/vendor/cm5/diff-match-patch.js +2228 -0
  71. package/site/shared/vendor/cm5/merge.css +119 -0
  72. package/site/shared/vendor/cm5/merge.js +1182 -0
  73. package/site/shared/vendor/fonts/README.md +19 -0
  74. package/site/shared/vendor/fonts/material-symbols-rounded.woff2 +0 -0
  75. package/viewer-preload.js +51 -0
  76. package/viewer.html +129 -0
@@ -0,0 +1,2330 @@
1
+ // BoardClip search engine — the ONE authority for query syntax, filtering, and ranking.
2
+ // Isomorphic UMD (same header idiom as clipboard-ui-core.js): browser global
3
+ // `window.BoardClipSearch`, or CommonJS require. Consumers: the app popup + website demo
4
+ // (via clipboard-ui-core.js), the MCP `search_clips` tool (lib/mcp-core.js), and tests.
5
+ //
6
+ // Design: PURE, no DOM. Operates on normalized "docs" (clipToDoc below) so the pin-shape
7
+ // read lives in ONE place here (mirrors lib/clipboard-model.js's documented pin model:
8
+ // item.pin == null => unpinned; {number?, groups?} when pinned). Everything a clip can be
9
+ // filtered/ranked by — title, body, groups, numpad slot, pinned, type, ts (seconds), length,
10
+ // id — is a field on the doc.
11
+ //
12
+ // Grammar (colon-uniform, quote-aware, `-` negates any token, unknown `word:val` is stripped
13
+ // to `val` as free text + recorded so a typo can't silently flood). The field's text IS
14
+ // the query (the Regex toggle only types /.../ for you: regexTypingEdit):
15
+ // free text bare words / "quoted phrase" -> literal substring over
16
+ // title+body+groups (always literal: no implicit regex)
17
+ // /pattern/ one regular expression term (case-insensitive, `.` never
18
+ // crosses a line break); also title:/re/ and text:/re/
19
+ // a b side by side = AND (every term must match)
20
+ // a OR b either (OR binds tighter than AND, Gmail's rule:
21
+ // `x a OR b` = x AND (a OR b)); `or` is a plain word
22
+ // ( ... ) -( ... ) grouping at any depth; `-` negates a term or a group
23
+ // title:VALUE text:VALUE field-scoped content (text:/body: = the clip body only)
24
+ // group:NAME g:NAME group membership (hierarchical: matches NAME and NAME/child)
25
+ // is:pinned is:image is:text is:numpad boolean facets
26
+ // num:N numpad slot 1-9 (alias for is:numpad + that slot)
27
+ // since:SPEC before:SPEC time bounds ("24h" | "7d" | "2026-01-01" | ISO)
28
+ // len:>N len:<N len:>=N character-count bound (text length); also len:N-M (range)
29
+ // lines:>N words:>N line / word count bounds (same comparators as len:)
30
+ // is:url is:multiline is:rich body is a link / spans lines / carries HTML or RTF
31
+ // id:PREFIX clip id contains PREFIX
32
+ // sort:new|best explicit ranking override
33
+ // The user-facing reference is the search options panel (OPTION_FACETS, OPTION_FIELDS,
34
+ // SYNTAX_NOTES) plus the autocomplete's hints (FIELD_INFO) - keep them in step with the parser.
35
+ // Only free text and title:/text: take a /regex/; every other facet is an enum/number/time
36
+ // spec. ONE compile primitive (compileTerm) matches literal and regex terms for search, the
37
+ // row highlight and the editor's find bar.
38
+
39
+ (function (root, factory) {
40
+ if (typeof module === 'object' && module.exports) module.exports = factory();
41
+ else root.BoardClipSearch = factory();
42
+ })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
43
+ 'use strict';
44
+
45
+ // ── pin-shape accessors (mirror lib/clipboard-model.js; kept inline so the engine is
46
+ // self-contained in the browser where it can't require the CJS model) ──
47
+ function pinNumber(item) { return item && item.pin && typeof item.pin.number === 'number' ? item.pin.number : null; }
48
+ function pinGroups(item) { return item && item.pin && Array.isArray(item.pin.groups) ? [...new Set(item.pin.groups)] : []; }
49
+ function isPinnedItem(item) { return !!(item && item.pin != null); }
50
+ function cleanTitle(v) { return String(v == null ? '' : v).replace(/\s+/g, ' ').trim(); }
51
+ // A clip that IS a link: one line, a URL scheme or a bare domain, no spaces.
52
+ const URL_BODY_RE = /^(?:[a-z][a-z0-9+.-]*:\/\/|www\.)[^\s]+$|^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:\/[^\s]*)?$/i;
53
+
54
+ // Normalized search document for a clip. mcp-core + ui-core both build these.
55
+ function clipToDoc(item) {
56
+ if (!item) return null;
57
+ const isImage = item.type === 'image';
58
+ const body = isImage ? '' : String(item.text || '');
59
+ return {
60
+ id: String(item.id || ''),
61
+ type: isImage ? 'image' : 'text',
62
+ title: cleanTitle(item.title),
63
+ body,
64
+ groups: pinGroups(item),
65
+ numpad: pinNumber(item),
66
+ pinned: isPinnedItem(item),
67
+ ts: Number(item.ts) || 0, // Unix SECONDS
68
+ len: isImage ? 0 : body.length,
69
+ // lines / words / url: computed on first use (docLines/docWords/docUrl).
70
+ // Only the lines:/words:/is:multiline/is:url facets read them, and working
71
+ // them out splits every body - a quarter-second per keystroke on a 70 MB
72
+ // history when it was done here.
73
+ rich: !isImage && !!(item.html || item.htmlRef || item.htmlHash || item.rtf || item.rtfRef || item.rtfHash),
74
+ };
75
+ }
76
+ function docLines(doc) {
77
+ if (doc.lines === undefined) {
78
+ const s = doc.type === 'image' ? '' : doc.body;
79
+ let n = s ? 1 : 0;
80
+ for (let i = 0; i < s.length; i += 1) {
81
+ const c = s.charCodeAt(i);
82
+ if (c === 10) n += 1;
83
+ else if (c === 13) { n += 1; if (s.charCodeAt(i + 1) === 10) i += 1; }
84
+ }
85
+ doc.lines = n;
86
+ }
87
+ return doc.lines;
88
+ }
89
+ function docWords(doc) {
90
+ if (doc.words === undefined) {
91
+ const s = doc.type === 'image' ? '' : doc.body.trim();
92
+ doc.words = s ? s.split(/\s+/).length : 0;
93
+ }
94
+ return doc.words;
95
+ }
96
+ function docUrl(doc) {
97
+ if (doc.url === undefined) doc.url = doc.type !== 'image' && URL_BODY_RE.test(doc.body.trim());
98
+ return doc.url;
99
+ }
100
+
101
+ // Combined free-text haystack (title + body + groups + a type keyword) — matches the old
102
+ // itemSearchText so bare words behave as before.
103
+ function docSearchText(doc) {
104
+ if (!doc) return '';
105
+ return [doc.title, doc.type === 'image' ? 'image' : doc.body, doc.type, doc.groups.join(' ')].join(' ');
106
+ }
107
+
108
+ // ── group-name helpers (hierarchical `parent/child`, shared semantics with the tag tree) ──
109
+ function normalizeTagName(group) {
110
+ return String(group || '').split('/').map((p) => p.trim()).filter(Boolean).join('/');
111
+ }
112
+ function tagMatchesFilter(group, filter) {
113
+ const tag = normalizeTagName(group);
114
+ const parent = normalizeTagName(filter);
115
+ return !!parent && (tag === parent || tag.startsWith(`${parent}/`));
116
+ }
117
+ function docInGroup(doc, filter) {
118
+ return doc.groups.some((g) => tagMatchesFilter(g, filter));
119
+ }
120
+
121
+ // ── ONE recency decay (usage x recency, "frecency"): a weight that falls off
122
+ // exponentially with age (ageMs / scaleMs = 1 -> 1/e). The result ranking's
123
+ // recency term and the group usage score both use it. ──
124
+ function decayWeight(ageMs, scaleMs) {
125
+ return Math.exp(-Math.max(0, ageMs) / scaleMs);
126
+ }
127
+ // How much each group is used: its clips, each weighted by how recently it
128
+ // was copied or used (ts moves on every paste), on a two-week scale. Every
129
+ // parent path counts its sub-groups' clips too. ONE score orders the chip
130
+ // row, the group pickers and the group autocomplete, and nudges the ranking
131
+ // (clips of the groups you use rank a little higher). list: clips or search
132
+ // docs (both carry ts in seconds); kept per list and per hour. The map's
133
+ // `max` = the top score (0: no group has clips).
134
+ const GROUP_USAGE_SCALE_MS = 14 * 86400 * 1000;
135
+ const groupWeightCache = typeof WeakMap === 'function' ? new WeakMap() : null;
136
+ function groupWeights(list, now) {
137
+ const arr = Array.isArray(list) ? list : [];
138
+ const nowMs = now || Date.now();
139
+ const hour = Math.floor(nowMs / 3600000);
140
+ const hit = groupWeightCache && groupWeightCache.get(arr);
141
+ if (hit && hit.hour === hour) return hit.weights;
142
+ const weights = new Map();
143
+ for (const x of arr) {
144
+ if (!x) continue;
145
+ const gs = Array.isArray(x.groups) ? x.groups : pinGroups(x);
146
+ if (!gs.length) continue;
147
+ const w = decayWeight(nowMs - (Number(x.ts) || 0) * 1000, GROUP_USAGE_SCALE_MS);
148
+ const seen = new Set();
149
+ for (const g of gs) {
150
+ const parts = normalizeTagName(g).split('/').filter(Boolean);
151
+ for (let i = 1; i <= parts.length; i += 1) {
152
+ const path = parts.slice(0, i).join('/');
153
+ if (seen.has(path)) continue;
154
+ seen.add(path);
155
+ weights.set(path, (weights.get(path) || 0) + w);
156
+ }
157
+ }
158
+ }
159
+ let max = 0;
160
+ for (const w of weights.values()) if (w > max) max = w;
161
+ weights.max = max;
162
+ if (groupWeightCache) groupWeightCache.set(arr, { hour, weights });
163
+ return weights;
164
+ }
165
+ // Order group paths by use (groupWeights), then by name.
166
+ function compareGroupUse(weights) {
167
+ return (a, b) => ((weights && (weights.get(normalizeTagName(b)) || 0)) - (weights && (weights.get(normalizeTagName(a)) || 0)))
168
+ || a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true });
169
+ }
170
+
171
+ // ── quote-aware tokenizer (ported from Forge querySyntax.ts) ──
172
+ function tokenizeQuery(text) {
173
+ const tokens = [];
174
+ let cur = '';
175
+ let has = false;
176
+ let inQuote = false;
177
+ const s = String(text || '');
178
+ for (let i = 0; i < s.length; i++) {
179
+ const ch = s[i];
180
+ if (ch === '"') { inQuote = !inQuote; has = true; continue; }
181
+ if (!inQuote && /\s/.test(ch)) { if (has) { tokens.push(cur); cur = ''; has = false; } continue; }
182
+ cur += ch; has = true;
183
+ }
184
+ if (has) tokens.push(cur);
185
+ return tokens.filter((t) => t.length > 0);
186
+ }
187
+ function quoteToken(v) { return /\s/.test(v) ? `"${String(v).replace(/"/g, '')}"` : String(v); }
188
+
189
+ // Builtin chip ids (used by the filter bar) <-> is: facets.
190
+ const BUILTIN_TO_IS = { __pinned__: 'pinned', __images__: 'image', __numbered__: 'numpad' };
191
+ const IS_TO_BUILTIN = { pinned: '__pinned__', image: '__images__', numpad: '__numbered__' };
192
+ const IS_VALUES = ['pinned', 'image', 'text', 'numpad', 'url', 'multiline', 'rich'];
193
+
194
+ // Canonical prefix + every accepted alias (short + long). ONE map feeds the
195
+ // parser, the highlight lexer, and autocomplete, so a new alias is added in
196
+ // exactly one place. Short forms: t=title, b=text/body, g=group, n=num, l=len.
197
+ const PREFIX_ALIASES = {
198
+ t: 'title', title: 'title',
199
+ b: 'text', text: 'text', body: 'text',
200
+ g: 'group', group: 'group',
201
+ is: 'is',
202
+ n: 'num', num: 'num',
203
+ s: 'since', since: 'since', after: 'since',
204
+ bf: 'before', before: 'before',
205
+ l: 'len', len: 'len',
206
+ ln: 'lines', lines: 'lines',
207
+ wd: 'words', words: 'words',
208
+ id: 'id',
209
+ o: 'sort', sort: 'sort',
210
+ };
211
+ const RECOGNIZED_PREFIXES = new Set(Object.keys(PREFIX_ALIASES));
212
+ // What a filter key looks like: ONE rule for the parser, the autocomplete and
213
+ // the validator, so they cannot disagree (Content-Type:x has a hyphen, so it
214
+ // is plain text everywhere). An unrecognised key of this shape is stripped to
215
+ // its value (UNKNOWN_KEY_RE, lowercased key); a longer one stays text.
216
+ const KEY_TOKEN_RE = /^([a-zA-Z][a-zA-Z0-9_]*):([\s\S]*)$/;
217
+ const UNKNOWN_KEY_RE = /^[a-z][a-z0-9_]{0,14}$/;
218
+ // colon-bearing tokens that are NOT filters (urls / windows paths) — leave verbatim.
219
+ const NON_FILTER_SCHEMES = new Set(['http', 'https', 'ftp', 'ws', 'wss', 'file', 'mailto', 'data', 'blob', 'codex', 'forge', 'claude', 'vscode', 'ssh', 'git', 'tel', 'sms']);
220
+
221
+ function emptyParsed(raw) {
222
+ return {
223
+ raw: String(raw == null ? '' : raw),
224
+ content: [], // { scope:'any'|'title'|'body', value, neg, regex? }
225
+ groups: [], negGroups: [],
226
+ is: [], negIs: [], // arrays of 'pinned'|'image'|'text'|'numpad'
227
+ nums: [], negNums: [], // numbers 1-9 (each one must hold: a clip has one key)
228
+ since: null, before: null,
229
+ len: null, // { op:'>'|'<'|'>='|'<='|'='|'range', n, m? }
230
+ lines: null, // same shape as len
231
+ words: null, // same shape as len
232
+ id: null,
233
+ sort: null, // 'new' | 'best'
234
+ unknown: [], // unrecognized prefixes (for the "not a filter" hint)
235
+ // The tree parts no flat field can hold. anyOf: a top-level OR of one
236
+ // filter's values (group:A OR group:B: the chip row's or / and toggle
237
+ // flips it). compound: any other top-level OR or group (a OR b,
238
+ // -(a b), group:A OR is:image), evaluated as a tree; a chip whose value
239
+ // sits inside one is greyed ("part of a custom filter").
240
+ anyOf: [], // { dim, field, values, members: [{ value, text, start, end }], start, end }
241
+ compound: [], // expression nodes (see parseExpression)
242
+ terms: [], // every content term that can match (top level and inside compound): highlight + relevance
243
+ syntax: [], // structural problems: { kind: 'dangling-or' | 'unclosed', start, end }
244
+ items: [], // the top-level items in text order: { kind: 'leaf'|'any'|'compound', start, end, leaf|any|node }
245
+ };
246
+ }
247
+
248
+ const TIME_RE = /^(\d+)([mhdw])$/i;
249
+ const LEN_RE = /^(>=|<=|>|<|=)?(\d+)$/;
250
+ const RANGE_RE = /^(\d+)\s*(?:-|\.\.)\s*(\d+)$/;
251
+ function parseBound(val) {
252
+ const rm = RANGE_RE.exec(val);
253
+ if (rm) { const a = parseInt(rm[1], 10); const b = parseInt(rm[2], 10); return { op: 'range', n: Math.min(a, b), m: Math.max(a, b) }; }
254
+ const lm = LEN_RE.exec(val);
255
+ return lm ? { op: lm[1] || '=', n: parseInt(lm[2], 10) } : null;
256
+ }
257
+ function serializeBound(b) {
258
+ if (!b) return '';
259
+ if (b.op === 'range') return `${b.n}-${b.m}`;
260
+ return (b.op === '=' ? '' : b.op) + b.n;
261
+ }
262
+
263
+ // ── the ONE query scanner: parse, highlight, validation and edits all walk it ──
264
+ // scanQuery(text) -> tokens in text order, each with its exact source range:
265
+ // { t: 'lp', neg } '(' or '-(' at a token start
266
+ // { t: 'rp' } ')' closing an open group (an unmatched ')' is plain text)
267
+ // { t: 'or' } the word OR, unquoted and upper case
268
+ // { t: 'term', text, neg, body, regex } anything else, quotes kept
269
+ // A term's regex is { scope, pattern, open, close } when its value is
270
+ // /pattern/ (bare, or after title: / text:): closed by the first unescaped
271
+ // '/' outside a [class], on one line, followed by whitespace, the end or a
272
+ // group's ')'. Anything else that starts with '/' (/usr/bin, an unclosed
273
+ // /abc, //) is plain text. A token whose first character is quoted is
274
+ // literal: "-foo", "title:x" and "OR" are text; -"a b" negates a phrase.
275
+ const CONTENT_KEYS = new Set(['title', 'text']);
276
+ const isSpace = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\r' || (c !== undefined && /\s/.test(c));
277
+ function regexClose(s, at, depth, classes) {
278
+ let inClass = false;
279
+ for (let k = at + 1; k < s.length; k += 1) {
280
+ const ch = s[k];
281
+ if (ch === '\n' || ch === '\r') return -1;
282
+ if (ch === '\\') { k += 1; continue; }
283
+ if (classes && inClass) { if (ch === ']') inClass = false; continue; }
284
+ if (classes && ch === '[') { inClass = true; continue; }
285
+ if (ch !== '/') continue;
286
+ if (k === at + 1) return -1; // `//` is text, never an empty regex
287
+ const next = s[k + 1];
288
+ if (next === undefined || isSpace(next) || (next === ')' && depth > 0)) return k;
289
+ if (classes) return -1; // the first closer must end the token
290
+ }
291
+ return -1;
292
+ }
293
+ // The closing '/' of a regex opening at `at`, or -1. An unclosed [class]
294
+ // (a broken regex) still ends at its last '/', so it is reported as a broken
295
+ // regex instead of searched as text.
296
+ function regexEnd(s, at, depth) {
297
+ if (s[at] !== '/') return -1;
298
+ const k = regexClose(s, at, depth, true);
299
+ return k >= 0 ? k : regexClose(s, at, depth, false);
300
+ }
301
+ function scanQuery(text) {
302
+ const s = String(text == null ? '' : text);
303
+ const out = [];
304
+ let depth = 0;
305
+ let i = 0;
306
+ while (i < s.length) {
307
+ const c = s[i];
308
+ if (isSpace(c)) { i += 1; continue; }
309
+ if (c === '(') { out.push({ t: 'lp', start: i, end: i + 1, neg: false }); depth += 1; i += 1; continue; }
310
+ if (c === '-' && s[i + 1] === '(') { out.push({ t: 'lp', start: i, end: i + 2, neg: true }); depth += 1; i += 2; continue; }
311
+ if (c === ')' && depth > 0) { out.push({ t: 'rp', start: i, end: i + 1 }); depth -= 1; i += 1; continue; }
312
+ const start = i;
313
+ const neg = c === '-' && i + 1 < s.length && !isSpace(s[i + 1]);
314
+ const bodyAt = neg ? i + 1 : i;
315
+ // A /regex/ value: bare, or after a content key (title:/re/, text:/re/).
316
+ const km = /^([a-zA-Z][a-zA-Z0-9_]*):/.exec(s.slice(bodyAt, bodyAt + 32));
317
+ const canon = km ? PREFIX_ALIASES[km[1].toLowerCase()] : null;
318
+ const valueAt = km ? (CONTENT_KEYS.has(canon) ? bodyAt + km[0].length : -1) : bodyAt;
319
+ const close = valueAt >= 0 ? regexEnd(s, valueAt, depth) : -1;
320
+ if (close > 0) {
321
+ const regex = { scope: km ? (canon === 'title' ? 'title' : 'body') : 'any', pattern: s.slice(valueAt + 1, close), open: valueAt, close };
322
+ out.push({ t: 'term', start, end: close + 1, neg, text: s.slice(start, close + 1), body: s.slice(bodyAt, close + 1), regex });
323
+ i = close + 1;
324
+ continue;
325
+ }
326
+ // A plain term: up to unquoted whitespace.
327
+ let j = i;
328
+ let inQuote = false;
329
+ for (; j < s.length; j += 1) {
330
+ const ch = s[j];
331
+ if (ch === '"') inQuote = !inQuote;
332
+ else if (!inQuote && isSpace(ch)) break;
333
+ }
334
+ // Closers at its end close open groups, but only those its own brackets
335
+ // leave over: f(x) stays a word, f(x)) closes one group.
336
+ let end = j;
337
+ if (depth > 0) {
338
+ let tail = j;
339
+ while (tail > bodyAt && s[tail - 1] === ')') tail -= 1;
340
+ let quotes = 0;
341
+ let opens = 0;
342
+ let closes = 0;
343
+ let q = false;
344
+ for (let k = bodyAt; k < j; k += 1) {
345
+ const ch = s[k];
346
+ if (ch === '"') { q = !q; if (k < tail) quotes += 1; } else if (!q) { if (ch === '(') opens += 1; else if (ch === ')') closes += 1; }
347
+ }
348
+ if (quotes % 2 === 0) end = j - Math.max(0, Math.min(j - tail, closes - opens, depth));
349
+ }
350
+ const textOf = s.slice(start, end);
351
+ if (textOf === 'OR') out.push({ t: 'or', start, end });
352
+ else if (end > start) out.push({ t: 'term', start, end, neg: neg && end > bodyAt, text: textOf, body: s.slice(neg && end > bodyAt ? bodyAt : start, end), regex: null });
353
+ for (let k = end; k < j; k += 1) { out.push({ t: 'rp', start: k, end: k + 1 }); depth -= 1; }
354
+ i = j;
355
+ }
356
+ return out;
357
+ }
358
+
359
+ // One term token -> a leaf: { field, neg, value, scope?, regex?, bound?,
360
+ // unknownKey? }. field: 'content' | 'group' | 'is' | 'num' | 'since' |
361
+ // 'before' | 'len' | 'lines' | 'words' | 'id' | 'sort', or null (nothing to
362
+ // match: an empty phrase).
363
+ function leafOf(tok) {
364
+ const neg = !!tok.neg;
365
+ if (tok.regex) return { field: 'content', scope: tok.regex.scope, value: tok.regex.pattern, regex: true, neg };
366
+ const body = tok.body;
367
+ const plain = body.replace(/"/g, '');
368
+ if (!plain) return { field: null, neg };
369
+ const content = (value, scope, unknownKey) => ({ field: 'content', scope: scope || 'any', value, neg, ...(unknownKey ? { unknownKey } : {}) });
370
+ const m = body[0] !== '"' ? KEY_TOKEN_RE.exec(body) : null;
371
+ if (!m || !m[2]) return content(plain);
372
+ const rawKey = m[1].toLowerCase();
373
+ const key = PREFIX_ALIASES[rawKey] || rawKey; // fold short aliases to canonical
374
+ const val = m[2].replace(/"/g, '');
375
+ if (!val) return content(plain);
376
+ // URL / windows-path guard: a value starting with / or \ (http://, C:\path)
377
+ if ((m[2][0] === '/' || m[2][0] === '\\') && !RECOGNIZED_PREFIXES.has(rawKey)) return content(plain);
378
+ if (key === 'title') return content(val, 'title');
379
+ if (key === 'text') return content(val, 'body');
380
+ if (key === 'group') return { field: 'group', value: normalizeTagName(val), neg };
381
+ if (key === 'is') { const v = val.toLowerCase(); if (IS_VALUES.includes(v)) return { field: 'is', value: v, neg }; }
382
+ if (key === 'num') { const n = parseInt(val, 10); if (n >= 1 && n <= 9 && /^\d$/.test(val)) return { field: 'num', value: n, neg }; }
383
+ if (key === 'since' || key === 'before' || key === 'id') return { field: key, value: val, neg };
384
+ if (key === 'len' || key === 'lines' || key === 'words') { const bound = parseBound(val); if (bound) return { field: key, value: val, bound, neg }; }
385
+ if (key === 'sort') { const v = val.toLowerCase(); if (v === 'new' || v === 'best' || v === 'recent' || v === 'relevance') return { field: 'sort', value: v === 'recent' ? 'new' : v === 'relevance' ? 'best' : v, neg }; }
386
+ // an unrecognized word: prefix (typo / unsupported) that isn't a URL scheme ->
387
+ // strip to its value as free text + record the bad prefix for a hint.
388
+ if (!NON_FILTER_SCHEMES.has(rawKey) && UNKNOWN_KEY_RE.test(rawKey)) return content(val, 'any', rawKey);
389
+ // recognized-but-malformed (e.g. num:99) or URL scheme -> treat whole token as text
390
+ return content(plain);
391
+ }
392
+ // A negated single-valued filter (-since:7d) cannot be excluded: it is
393
+ // ignored (validateQuery offers the inverse) instead of acting as positive.
394
+ const SINGLE_FIELDS = new Set(['since', 'before', 'len', 'lines', 'words', 'id', 'sort']);
395
+ function addLeaf(out, leaf, raw) {
396
+ const neg = leaf.neg;
397
+ switch (leaf.field) {
398
+ case 'content': {
399
+ const c = { scope: leaf.scope, value: leaf.value, neg };
400
+ if (leaf.regex) c.regex = true;
401
+ out.content.push(c);
402
+ // Non-enumerable: serialization metadata, invisible to consumers that
403
+ // compare or spread a term.
404
+ Object.defineProperty(c, 'raw', { value: raw, writable: true, configurable: true });
405
+ if (leaf.unknownKey && !out.unknown.includes(leaf.unknownKey)) out.unknown.push(leaf.unknownKey);
406
+ if (!neg) out.terms.push(c);
407
+ return;
408
+ }
409
+ case 'group': (neg ? out.negGroups : out.groups).push(leaf.value); return;
410
+ case 'is': (neg ? out.negIs : out.is).push(leaf.value); return;
411
+ case 'num': (neg ? out.negNums : out.nums).push(leaf.value); return;
412
+ default:
413
+ if (!SINGLE_FIELDS.has(leaf.field) || neg) return;
414
+ out[leaf.field] = leaf.bound || leaf.value;
415
+ }
416
+ }
417
+ // Which OR dimension a filter value belongs to: every group is one, the
418
+ // kinds of clip (image, text, link, multi-line, rich) are one, numpad keys
419
+ // are one; pinned and "on a key" are each their own (pinned OR image is not
420
+ // one question). A second chip of an OR dimension adds an OR.
421
+ const TYPE_IS = new Set(['image', 'text', 'url', 'multiline', 'rich']);
422
+ const OR_DIMS = new Set(['group', 'type', 'num']);
423
+ function facetDim(field, value) {
424
+ if (field === 'group' || field === 'num') return field;
425
+ if (field === 'is') return TYPE_IS.has(value) ? 'type' : value;
426
+ return null;
427
+ }
428
+ // A top-level OR whose sides are all positive values of ONE OR dimension
429
+ // (group:A OR group:B): an anyOf (the chip row's or / and). null for any other OR.
430
+ function anyOfGroup(node) {
431
+ let dim = null;
432
+ let field = null;
433
+ const members = [];
434
+ for (const k of node.children) {
435
+ if (k.type !== 'leaf' || k.leaf.neg) return null;
436
+ const d = facetDim(k.leaf.field, k.leaf.value);
437
+ if (!d || !OR_DIMS.has(d) || (dim && d !== dim)) return null;
438
+ dim = d;
439
+ field = k.leaf.field;
440
+ if (!members.some((x) => x.value === k.leaf.value)) members.push({ value: k.leaf.value, text: k.text, start: k.start, end: k.end });
441
+ }
442
+ return { dim, field, values: members.map((x) => x.value), members, start: node.start, end: node.end };
443
+ }
444
+
445
+ // Tokens -> expression: juxtaposition is AND; OR binds tighter (Gmail's
446
+ // rule, so `x a OR b` = x AND (a OR b) and an OR of filter values sits beside other terms
447
+ // without brackets); `-` negates a term or a group. Nodes: { type: 'leaf',
448
+ // leaf, text } | { type: 'or', children } | { type: 'and', neg, children },
449
+ // each with its source range. A dangling OR or an unclosed '(' is recorded in
450
+ // `syntax` (validateQuery reports it) and parsed as if it were not there /
451
+ // closed at the end, so a query mid-typing still searches.
452
+ function parseExpression(s, syntax) {
453
+ const toks = scanQuery(s);
454
+ let pos = 0;
455
+ function unary() {
456
+ while (pos < toks.length && toks[pos].t === 'or') { syntax.push({ kind: 'dangling-or', start: toks[pos].start, end: toks[pos].end }); pos += 1; }
457
+ const tok = toks[pos];
458
+ if (!tok || tok.t === 'rp') return null;
459
+ pos += 1;
460
+ if (tok.t === 'lp') {
461
+ const children = sequence(true);
462
+ const close = toks[pos] && toks[pos].t === 'rp' ? toks[pos] : null;
463
+ if (close) pos += 1; else syntax.push({ kind: 'unclosed', start: tok.start, end: tok.end });
464
+ return { type: 'and', neg: tok.neg, children, start: tok.start, end: close ? close.end : (children.length ? children[children.length - 1].end : tok.end) };
465
+ }
466
+ return { type: 'leaf', leaf: leafOf(tok), text: tok.text, start: tok.start, end: tok.end };
467
+ }
468
+ function either() {
469
+ const first = unary();
470
+ if (!first) return null;
471
+ const kids = [first];
472
+ while (pos < toks.length && toks[pos].t === 'or') {
473
+ const orTok = toks[pos];
474
+ pos += 1;
475
+ const next = toks[pos] && toks[pos].t !== 'rp' && toks[pos].t !== 'or' ? unary() : null;
476
+ if (!next) { syntax.push({ kind: 'dangling-or', start: orTok.start, end: orTok.end }); break; }
477
+ kids.push(next);
478
+ }
479
+ return kids.length === 1 ? first : { type: 'or', children: kids, start: first.start, end: kids[kids.length - 1].end };
480
+ }
481
+ function sequence(inGroup) {
482
+ const items = [];
483
+ while (pos < toks.length) {
484
+ if (toks[pos].t === 'rp') { if (inGroup) break; pos += 1; continue; }
485
+ const before = pos;
486
+ const node = either();
487
+ if (node) items.push(node);
488
+ else if (pos === before) pos += 1;
489
+ }
490
+ return items;
491
+ }
492
+ return sequence(false).map(simplifyNode).filter(Boolean);
493
+ }
494
+ // Flatten what changes nothing: a group of one, a group inside an AND, an
495
+ // OR inside an OR; drop empty groups.
496
+ function simplifyNode(node) {
497
+ if (node.type === 'leaf') return node.leaf.field ? node : null;
498
+ const kids = node.children.map(simplifyNode).filter(Boolean);
499
+ if (!kids.length) return null;
500
+ if (node.type === 'or') {
501
+ const flat = [];
502
+ for (const k of kids) { if (k.type === 'or') flat.push(...k.children); else flat.push(k); }
503
+ return flat.length === 1 ? flat[0] : { ...node, children: flat };
504
+ }
505
+ // A group of one keeps its brackets in its range: removing or naming it
506
+ // takes the brackets too.
507
+ if (kids.length === 1 && !node.neg) return { ...kids[0], start: node.start, end: node.end };
508
+ const flat = [];
509
+ for (const k of kids) { if (k.type === 'and' && !k.neg) flat.push(...k.children); else flat.push(k); }
510
+ return { ...node, children: flat };
511
+ }
512
+ // The content terms of a node that can match (an even number of NOTs above).
513
+ function collectTerms(node, negated, out) {
514
+ if (node.type === 'leaf') {
515
+ const l = node.leaf;
516
+ if (l.field === 'content' && l.neg === negated) {
517
+ const c = { scope: l.scope, value: l.value, neg: false };
518
+ if (l.regex) c.regex = true;
519
+ out.push(c);
520
+ }
521
+ return;
522
+ }
523
+ const n = node.type === 'and' && node.neg ? !negated : negated;
524
+ for (const k of node.children) collectTerms(k, n, out);
525
+ }
526
+
527
+ // Content terms keep the token exactly as typed (`raw`), so a chip rewriting
528
+ // the query never reshapes the user's words: an unknown `titel:foo` stays
529
+ // `titel:foo`, quotes stay where they were typed.
530
+ function parseQuery(query) {
531
+ const out = emptyParsed(query);
532
+ const top = [];
533
+ for (const node of parseExpression(out.raw, out.syntax)) {
534
+ if (node.type === 'and' && !node.neg) top.push(...node.children); else top.push(node);
535
+ }
536
+ for (const node of top) {
537
+ if (node.type === 'leaf') {
538
+ addLeaf(out, node.leaf, node.text);
539
+ out.items.push({ kind: 'leaf', start: node.start, end: node.end, leaf: node.leaf, text: node.text });
540
+ continue;
541
+ }
542
+ const any = node.type === 'or' ? anyOfGroup(node) : null;
543
+ if (any) {
544
+ out.anyOf.push(any);
545
+ out.items.push({ kind: 'any', start: node.start, end: node.end, any });
546
+ continue;
547
+ }
548
+ out.compound.push(node);
549
+ out.items.push({ kind: 'compound', start: node.start, end: node.end, node });
550
+ collectTerms(node, false, out.terms);
551
+ }
552
+ return out;
553
+ }
554
+ // Canonical serialization: the content terms first, as typed (their `raw`
555
+ // token) and in the typed order, then the facets in ONE fixed order
556
+ // (FACET_ORDER, then value: groups by name, is: by IS_VALUES, slots
557
+ // ascending), then the ORs of filter values and the custom expressions as typed.
558
+ // facetKey uses it; a chip never re-serializes the query (see applyFacet: it
559
+ // edits only the tokens it changes).
560
+ const byName = (a, b) => a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true }) || (a < b ? -1 : a > b ? 1 : 0);
561
+ const byIs = (a, b) => IS_VALUES.indexOf(a) - IS_VALUES.indexOf(b);
562
+ const byNum = (a, b) => a - b;
563
+ const FACET_ORDER = ['group', '-group', 'is', '-is', 'num', '-num', 'since', 'before', 'len', 'lines', 'words', 'id', 'sort'];
564
+ // One filter value's canonical token ('group:"My notes"', 'is:image', 'num:3').
565
+ function facetPart(field, value) {
566
+ if (field === 'group') return 'group:' + quoteToken(value);
567
+ if (field === 'num') return 'num:' + value;
568
+ return 'is:' + value;
569
+ }
570
+ function anyOfText(g) {
571
+ const sort = g.field === 'group' ? byName : g.field === 'num' ? byNum : byIs;
572
+ return [...g.values].sort(sort).map((v) => facetPart(g.field, v)).join(' OR ');
573
+ }
574
+ // One query token per facet value, canonical text, canonical order.
575
+ function facetParts(p) {
576
+ const parts = [];
577
+ for (const g of [...p.groups].sort(byName)) parts.push('group:' + quoteToken(g));
578
+ for (const g of [...p.negGroups].sort(byName)) parts.push('-group:' + quoteToken(g));
579
+ for (const v of [...p.is].sort(byIs)) parts.push('is:' + v);
580
+ for (const v of [...p.negIs].sort(byIs)) parts.push('-is:' + v);
581
+ for (const n of [...p.nums].sort(byNum)) parts.push('num:' + n);
582
+ for (const n of [...p.negNums].sort(byNum)) parts.push('-num:' + n);
583
+ if (p.since) parts.push('since:' + quoteToken(p.since));
584
+ if (p.before) parts.push('before:' + quoteToken(p.before));
585
+ if (p.len) parts.push('len:' + serializeBound(p.len));
586
+ if (p.lines) parts.push('lines:' + serializeBound(p.lines));
587
+ if (p.words) parts.push('words:' + serializeBound(p.words));
588
+ if (p.id) parts.push('id:' + quoteToken(p.id));
589
+ if (p.sort) parts.push('sort:' + p.sort);
590
+ for (const g of p.anyOf || []) parts.push(anyOfText(g));
591
+ for (const n of p.compound || []) parts.push(p.raw.slice(n.start, n.end));
592
+ return parts;
593
+ }
594
+ function serializeQuery(p) {
595
+ const parts = [];
596
+ for (const c of p.content) parts.push(c.raw || ((c.neg ? '-' : '') + (c.scope === 'title' ? 'title:' : c.scope === 'body' ? 'text:' : '') + (c.regex ? `/${c.value}/` : quoteToken(c.value))));
597
+ return parts.concat(facetParts(p)).join(' ');
598
+ }
599
+ // The query's filters alone (no free text, no sort), canonical: the cache key
600
+ // for everything that depends only on them (the availability census, the
601
+ // options panel's chips, incremental refinement).
602
+ function facetKey(parsed) {
603
+ return serializeQuery({ ...parsed, content: [], sort: null });
604
+ }
605
+ // A facet part's kind as FACET_ORDER names it ('-group', 'since', ...).
606
+ function partKind(part) {
607
+ const neg = part[0] === '-' ? '-' : '';
608
+ return neg + part.slice(neg.length).split(':')[0];
609
+ }
610
+ function partValue(part) {
611
+ return part.slice(part.indexOf(':') + 1).replace(/^"|"$/g, '');
612
+ }
613
+ // FACET_ORDER, then the value order of that kind.
614
+ function compareParts(a, b) {
615
+ const ka = partKind(a);
616
+ const kb = partKind(b);
617
+ if (ka !== kb) return FACET_ORDER.indexOf(ka) - FACET_ORDER.indexOf(kb);
618
+ const k = ka.replace(/^-/, '');
619
+ if (k === 'group') return byName(partValue(a), partValue(b));
620
+ if (k === 'is') return byIs(partValue(a), partValue(b));
621
+ if (k === 'num') return byNum(Number(partValue(a)), Number(partValue(b)));
622
+ return 0;
623
+ }
624
+ // The canonical facet part a top-level leaf stands for, or null (content).
625
+ function leafPart(leaf) {
626
+ if (!leaf || !leaf.field || leaf.field === 'content') return null;
627
+ const neg = leaf.neg ? '-' : '';
628
+ if (leaf.field === 'group' || leaf.field === 'is' || leaf.field === 'num') return neg + facetPart(leaf.field, leaf.value);
629
+ if (leaf.bound) return `${neg}${leaf.field}:${serializeBound(leaf.bound)}`;
630
+ return `${neg}${leaf.field}:${quoteToken(leaf.value)}`;
631
+ }
632
+
633
+ // ── chip <-> query bridge: toggle a facet in the query string (bar = source of truth) ──
634
+ // token: { kind:'group'|'builtin'|'is'|'num', value } ; intent: 'include'|'exclude'.
635
+ // Single-valued facets ({ kind:'since'|'before'|'len'|'lines'|'words'|'id', value }) have no
636
+ // exclude: the chip's value replaces the token, and the same value again clears it.
637
+ // The text is EDITED, never re-serialized: only the tokens the change removes
638
+ // go, a new token goes in at its canonical place among the facet tokens
639
+ // already there (FACET_ORDER + value order, so chips clicked in any order
640
+ // write the same text), and everything else - the words, their quotes, an
641
+ // unknown prefix, the order the user typed, a custom (a OR b) - stays exactly
642
+ // as typed. A value joins an OR dimension (groups, kinds of clip, numpad keys)
643
+ // with OR when it holds one value or an OR already (group:A -> group:A OR
644
+ // group:B), unless the device remembered "and" for that kind (opts.joins);
645
+ // two values side by side (an "and") take a third the same way. Only the top level is edited: a value inside a custom expression is
646
+ // left alone (its chip is greyed).
647
+ const SINGLE_FACETS = ['since', 'before', 'len', 'lines', 'words', 'id'];
648
+ const BOUND_FACETS = ['len', 'lines', 'words'];
649
+ // A chip token -> { field, value } (builtin chip ids are is: kinds).
650
+ function tokenFieldValue(token) {
651
+ if (token.kind === 'group') return { field: 'group', value: normalizeTagName(token.value) };
652
+ if (token.kind === 'num') return { field: 'num', value: Number(token.value) };
653
+ return { field: 'is', value: BUILTIN_TO_IS[token.value] || token.value };
654
+ }
655
+ function singleFacetNext(p, token) {
656
+ const k = token.kind;
657
+ if (BOUND_FACETS.includes(k)) {
658
+ const bound = parseBound(String(token.value == null ? '' : token.value));
659
+ return bound && !(p[k] && serializeBound(p[k]) === serializeBound(bound)) ? `${k}:${serializeBound(bound)}` : null;
660
+ }
661
+ const v = String(token.value == null ? '' : token.value).trim();
662
+ return v && String(p[k] || '').toLowerCase() !== v.toLowerCase() ? `${k}:${quoteToken(v)}` : null;
663
+ }
664
+ // Apply range edits ({ start, end, text }) to text. A removal also takes the
665
+ // whitespace after it (or, at the end, before it), so no double spaces are
666
+ // left; groups the edit emptied go too.
667
+ function applyEdits(text, edits) {
668
+ let out = text;
669
+ for (const e of [...edits].sort((a, b) => b.start - a.start || b.end - a.end)) {
670
+ let { start, end } = e;
671
+ if (!e.text) {
672
+ let after = end;
673
+ while (after < out.length && isSpace(out[after])) after += 1;
674
+ if (after < out.length) end = after;
675
+ else { while (start > 0 && isSpace(out[start - 1])) start -= 1; end = after; }
676
+ }
677
+ out = out.slice(0, start) + e.text + out.slice(end);
678
+ }
679
+ return out.replace(/(^|\s)-?\(\s*\)(?=\s|$)/g, '$1').replace(/^\s+/, '');
680
+ }
681
+ function editFacets(text, p, plan) {
682
+ const edits = [];
683
+ const removed = new Set();
684
+ const remove = (item) => { removed.add(item); edits.push({ start: item.start, end: item.end, text: '' }); };
685
+ const rewriteAny = (item, members) => {
686
+ if (!members.length) { remove(item); return; }
687
+ removed.add(item);
688
+ edits.push({ start: item.start, end: item.end, text: members.map((m) => m.text).join(' OR ') });
689
+ };
690
+ // A new token goes before the first top-level filter that sorts after it,
691
+ // else at the end.
692
+ const insertPart = (part) => {
693
+ for (const item of p.items) {
694
+ if (removed.has(item) || item.kind !== 'leaf') continue;
695
+ const at = leafPart(item.leaf);
696
+ if (at && compareParts(at, part) > 0) { edits.push({ start: item.start, end: item.start, text: `${part} ` }); return; }
697
+ }
698
+ const end = text.replace(/\s+$/, '').length;
699
+ edits.push({ start: end, end, text: end ? ` ${part}` : part });
700
+ };
701
+ plan({ remove, rewriteAny, insertPart, edits });
702
+ const out = applyEdits(text, edits);
703
+ return out && /\s$/.test(text) && !/\s$/.test(out) ? `${out} ` : out; // a trailing space (mid-typing) stays
704
+ }
705
+ // opts.joins: how a second value of a kind joins the first ({ group, type }:
706
+ // 'or' | 'and', JOIN_DIMS; the device remembers the last or / and the user
707
+ // picked, normalizeJoins). A value joins an existing OR or AND the way it is
708
+ // written; the remembered join only decides when one value is there.
709
+ function applyFacet(query, token, intent, opts) {
710
+ const joins = normalizeJoins(opts && opts.joins);
711
+ const text = String(query == null ? '' : query);
712
+ const p = parseQuery(text);
713
+ if (SINGLE_FACETS.includes(token.kind)) {
714
+ const k = token.kind;
715
+ const next = singleFacetNext(p, token);
716
+ return editFacets(text, p, ({ remove, insertPart }) => {
717
+ // A later token overrides an earlier one, so the text may hold several.
718
+ for (const item of p.items) if (item.kind === 'leaf' && item.leaf.field === k) remove(item);
719
+ if (next) insertPart(next);
720
+ });
721
+ }
722
+ const { field, value } = tokenFieldValue(token);
723
+ const part = facetPart(field, value);
724
+ const dim = facetDim(field, value);
725
+ const isLeaf = (item, neg) => item.kind === 'leaf' && item.leaf.field === field && !!item.leaf.neg === neg && item.leaf.value === value;
726
+ const pos = p.items.filter((item) => isLeaf(item, false));
727
+ const negs = p.items.filter((item) => isLeaf(item, true));
728
+ const holding = p.items.filter((item) => item.kind === 'any' && item.any.field === field && item.any.values.includes(value));
729
+ // One group family, one choice (group:Work already holds Work/Clients):
730
+ // a group picked replaces its selected parents and sub-groups (the last
731
+ // click wins: a parent widens, a sub-group drills down); an excluded group
732
+ // drops its selected sub-groups (they would show nothing). The selection
733
+ // keeps the join it had.
734
+ if (field === 'group' && !pos.length && !holding.length && !negs.length) {
735
+ const kin = (v) => v !== value && (tagMatchesFilter(v, value) || (intent !== 'exclude' && tagMatchesFilter(value, v)));
736
+ const family = p.items.filter((item) => (item.kind === 'leaf' && item.leaf.field === 'group' && !item.leaf.neg && kin(item.leaf.value))
737
+ || (item.kind === 'any' && item.any.field === 'group' && item.any.values.some(kin)));
738
+ if (family.length) {
739
+ const stripped = editFacets(text, p, ({ remove, rewriteAny }) => {
740
+ for (const item of family) {
741
+ if (item.kind === 'leaf') remove(item);
742
+ else rewriteAny(item, item.any.members.filter((m) => !kin(m.value)));
743
+ }
744
+ });
745
+ return applyFacet(stripped, token, intent, { joins: { ...joins, group: dimConnective(p, 'group') || joins.group } });
746
+ }
747
+ }
748
+ return editFacets(text, p, ({ remove, rewriteAny, insertPart, edits }) => {
749
+ const dropValue = () => {
750
+ pos.forEach(remove);
751
+ for (const item of holding) rewriteAny(item, item.any.members.filter((m) => m.value !== value));
752
+ };
753
+ if (intent === 'exclude') {
754
+ if (negs.length) negs.forEach(remove); // already excluded -> clear
755
+ else { dropValue(); insertPart(`-${part}`); } // include->exclude / add exclude
756
+ return;
757
+ }
758
+ if (pos.length || holding.length) { dropValue(); return; } // already included -> clear
759
+ if (negs.length) { negs.forEach(remove); return; } // excluded -> clear
760
+ // Add: OR into the dimension's OR, or onto its one value, at its value
761
+ // order (chips clicked in any order write the same text); else a token.
762
+ if (OR_DIMS.has(dim)) {
763
+ const order = field === 'group' ? byName : field === 'num' ? byNum : byIs;
764
+ const anyItem = p.items.find((item) => item.kind === 'any' && item.any.dim === dim);
765
+ const ones = p.items.filter((item) => item.kind === 'leaf' && !item.leaf.neg && facetDim(item.leaf.field, item.leaf.value) === dim);
766
+ const members = anyItem ? anyItem.any.members : ones.length === 1 && joins[dim] !== 'and' ? [{ value: ones[0].leaf.value, start: ones[0].start, end: ones[0].end }] : null;
767
+ if (members) {
768
+ const next = members.find((m) => order(m.value, value) > 0);
769
+ if (next) edits.push({ start: next.start, end: next.start, text: `${part} OR ` });
770
+ else edits.push({ start: members[members.length - 1].end, end: members[members.length - 1].end, text: ` OR ${part}` });
771
+ return;
772
+ }
773
+ }
774
+ insertPart(part);
775
+ });
776
+ }
777
+ // Remove every top-level token of one filter value (both signs, from an OR
778
+ // too): a deleted group leaves no group: behind.
779
+ function stripFacet(query, token) {
780
+ const text = String(query == null ? '' : query);
781
+ const p = parseQuery(text);
782
+ const { field, value } = tokenFieldValue(token);
783
+ return editFacets(text, p, ({ remove, rewriteAny }) => {
784
+ for (const item of p.items) {
785
+ if (item.kind === 'leaf' && item.leaf.field === field && item.leaf.value === value) remove(item);
786
+ else if (item.kind === 'any' && item.any.field === field && item.any.values.includes(value)) rewriteAny(item, item.any.members.filter((m) => m.value !== value));
787
+ }
788
+ });
789
+ }
790
+ // Remove every top-level token of a single-valued filter (since:, len:, ...).
791
+ function clearFacet(query, kind) {
792
+ const text = String(query == null ? '' : query);
793
+ const p = parseQuery(text);
794
+ return editFacets(text, p, ({ remove }) => { for (const item of p.items) if (item.kind === 'leaf' && item.leaf.field === kind) remove(item); });
795
+ }
796
+ // One facet token's state in a parsed query: 'include' | 'exclude' | null. A
797
+ // single-valued token without a value ({ kind: 'before' }) asks "is that facet
798
+ // set at all". The options panel paints its chips from this.
799
+ function facetTokenState(parsed, token) {
800
+ const k = token.kind;
801
+ if (SINGLE_FACETS.includes(k)) {
802
+ const cur = parsed[k];
803
+ if (!cur) return null;
804
+ if (token.value == null || token.value === '') return 'include';
805
+ if (BOUND_FACETS.includes(k)) { const b = parseBound(String(token.value)); return b && serializeBound(cur) === serializeBound(b) ? 'include' : null; }
806
+ return String(cur).toLowerCase() === String(token.value).toLowerCase() ? 'include' : null;
807
+ }
808
+ const { field, value } = tokenFieldValue(token);
809
+ const inc = field === 'group' ? parsed.groups : field === 'num' ? parsed.nums : parsed.is;
810
+ const ex = field === 'group' ? parsed.negGroups : field === 'num' ? parsed.negNums : parsed.negIs;
811
+ if (inc.includes(value) || (parsed.anyOf || []).some((g) => g.field === field && g.values.includes(value))) return 'include';
812
+ return ex.includes(value) ? 'exclude' : null;
813
+ }
814
+
815
+ // Every filter value named inside a custom expression (a OR b, -(a b)): its
816
+ // chip is greyed, since a click could not change it there. Census keys.
817
+ function nodeFacetKeys(node, out) {
818
+ if (node.type === 'leaf') {
819
+ const l = node.leaf;
820
+ if (l.field === 'group') out.add(`group:${l.value}`);
821
+ else if (l.field === 'is') out.add(`is:${l.value}`);
822
+ else if (l.field === 'num') out.add(`num:${l.value}`);
823
+ else if (l.field && l.field !== 'content') out.add(`${l.field}:`);
824
+ return out;
825
+ }
826
+ for (const k of node.children) nodeFacetKeys(k, out);
827
+ return out;
828
+ }
829
+ function customFacetKeys(parsed) {
830
+ if (!parsed.compound || !parsed.compound.length) return null;
831
+ if (!parsed.customKeys) Object.defineProperty(parsed, 'customKeys', { value: parsed.compound.reduce((set, n) => nodeFacetKeys(n, set), new Set()), configurable: true });
832
+ return parsed.customKeys;
833
+ }
834
+
835
+ // Chip active/excluded state for the filter bar, derived straight from the query.
836
+ function facetState(parsed) {
837
+ const active = new Set();
838
+ const excluded = new Set();
839
+ for (const g of parsed.groups) active.add(g);
840
+ for (const g of parsed.negGroups) excluded.add(g);
841
+ for (const v of parsed.is) if (IS_TO_BUILTIN[v]) active.add(IS_TO_BUILTIN[v]);
842
+ for (const v of parsed.negIs) if (IS_TO_BUILTIN[v]) excluded.add(IS_TO_BUILTIN[v]);
843
+ for (const g of parsed.anyOf || []) for (const v of g.values) {
844
+ if (g.field === 'group') active.add(v);
845
+ else if (g.field === 'is' && IS_TO_BUILTIN[v]) active.add(IS_TO_BUILTIN[v]);
846
+ }
847
+ if (parsed.nums.length || parsed.is.includes('numpad') || (parsed.anyOf || []).some((g) => g.field === 'num')) active.add('__numbered__');
848
+ return { active, excluded };
849
+ }
850
+
851
+ function nodeHasFilter(node) {
852
+ if (node.type === 'leaf') return !!node.leaf.field && node.leaf.field !== 'content';
853
+ return node.children.some(nodeHasFilter);
854
+ }
855
+ function anyFilterActive(parsed) {
856
+ return !!(parsed.groups.length || parsed.negGroups.length || parsed.is.length || parsed.negIs.length ||
857
+ parsed.nums.length || parsed.negNums.length || parsed.since || parsed.before || parsed.len || parsed.lines || parsed.words || parsed.id
858
+ || (parsed.anyOf && parsed.anyOf.length) || (parsed.compound && parsed.compound.some(nodeHasFilter)));
859
+ }
860
+ // Is there anything to search FOR (a word, a phrase, a /regex/), as opposed
861
+ // to filters only?
862
+ function hasSearchTerms(parsed) {
863
+ return !!((parsed.content && parsed.content.length) || (parsed.terms && parsed.terms.length) || (parsed.compound && parsed.compound.some((n) => !nodeHasFilter(n))));
864
+ }
865
+
866
+ // ── time-spec resolution (mirrors Forge resolveTimeMs) ──
867
+ const UNIT_MS = { m: 60000, h: 3600000, d: 86400000, w: 604800000 };
868
+ // A date is only ever an ISO-like date (2026-01-31, 2026-01, an optional time).
869
+ // Never hand anything else to Date.parse: V8 reads '>5', '5-' or a bare '7' as a
870
+ // date in 2001, so a typo would silently filter on it.
871
+ const DATE_RE = /^\d{4}-\d{1,2}(?:-\d{1,2})?(?:[T ]\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/i;
872
+ function resolveTimeMs(spec, now) {
873
+ const s = String(spec || '').trim();
874
+ if (!s) return null;
875
+ const m = TIME_RE.exec(s);
876
+ if (m) return (now || Date.now()) - parseInt(m[1], 10) * UNIT_MS[m[2].toLowerCase()];
877
+ if (!DATE_RE.test(s)) return null;
878
+ const t = Date.parse(s);
879
+ return Number.isNaN(t) ? null : t;
880
+ }
881
+
882
+ // ── matching ──
883
+ // ONE compile primitive for a literal or a regex term, shared by search, the
884
+ // row highlight and the editor's find bar. compileTerm(value, { regex,
885
+ // caseSensitive }) -> { regex, valid, error, lower, test(text, isLower),
886
+ // find(text, from) -> { start, end } | null, all(text, limit) -> spans }.
887
+ // Case-insensitive unless caseSensitive; `.` never crosses a line break (no
888
+ // s flag); an invalid regex is valid:false and matches nothing. test's
889
+ // isLower says the text is ALREADY lowercased (the precomputed haystack), so
890
+ // a literal term never lowercases it again.
891
+ const RE_META_G = /[.*+?^${}()|[\]\\]/g;
892
+ function escapeRegExp(s) { return String(s).replace(RE_META_G, '\\$&'); }
893
+ function regexError(value) {
894
+ try { new RegExp(value, 'i'); return null; } catch (e) { return String(e && e.message || 'invalid').replace(/^Invalid regular expression: \/.*\/[a-z]*: /, ''); }
895
+ }
896
+ function compileTerm(value, opts) {
897
+ const o = opts || {};
898
+ const src = String(value == null ? '' : value);
899
+ const cs = !!o.caseSensitive;
900
+ let re = null;
901
+ let error = null;
902
+ if (o.regex) { try { re = new RegExp(src, cs ? 'g' : 'gi'); } catch { error = regexError(src); } }
903
+ else if (src) re = new RegExp(escapeRegExp(src), cs ? 'g' : 'gi');
904
+ const lower = o.regex ? '' : cs ? src : src.toLowerCase();
905
+ const str = (t) => (t == null ? '' : String(t));
906
+ const test = o.regex
907
+ ? (t) => { if (!re) return false; re.lastIndex = 0; return re.test(str(t)); }
908
+ : (t, isLower) => (cs ? str(t) : isLower && typeof t === 'string' ? t : str(t).toLowerCase()).includes(lower);
909
+ const find = (t, from) => {
910
+ if (!re) return null;
911
+ const text = str(t);
912
+ re.lastIndex = from || 0;
913
+ let m;
914
+ while ((m = re.exec(text))) {
915
+ if (m[0] !== '') return { start: m.index, end: m.index + m[0].length };
916
+ re.lastIndex += 1; // an empty match (a*) shows nothing; look on
917
+ if (re.lastIndex > text.length) break;
918
+ }
919
+ return null;
920
+ };
921
+ const all = (t, limit) => {
922
+ const spans = [];
923
+ const cap = limit || 100000;
924
+ let at = 0;
925
+ let hit;
926
+ while (spans.length < cap && (hit = find(t, at))) { spans.push(hit); at = hit.end; }
927
+ return spans;
928
+ };
929
+ return { regex: !!o.regex, valid: !error, error, source: src, lower, test, find, all };
930
+ }
931
+ // Compiled once per term object (a query compiles each term once, not once
932
+ // per clip).
933
+ const compiled = typeof WeakMap === 'function' ? new WeakMap() : null;
934
+ function termMatcher(term) {
935
+ let m = compiled && compiled.get(term);
936
+ if (!m) { m = compileTerm(term.value, { regex: !!term.regex }); if (compiled) compiled.set(term, m); }
937
+ return m;
938
+ }
939
+ // One matcher per content term, built once per query (not once per clip).
940
+ function compileContent(parsed) {
941
+ return parsed.content.map(termMatcher);
942
+ }
943
+ // Where lowercase `v` first occurs in the clip's BODY, read from the lowercased
944
+ // combined haystack ([title, body, type, groups...].join(' ')) instead of
945
+ // lowercasing the body again: a 31 MB clip made every keystroke pay for that.
946
+ // Falls back to the body itself when the haystack does not line up.
947
+ function bodyIndexOf(doc, hayLower, v) {
948
+ const body = doc.body;
949
+ if (!body) return -1;
950
+ const start = doc.title.length + 1;
951
+ if (hayLower != null && hayLower.length >= start + body.length && hayLower.charCodeAt(start - 1) === 32) {
952
+ const idx = hayLower.indexOf(v, start);
953
+ return idx >= 0 && idx + v.length <= start + body.length ? idx - start : -1;
954
+ }
955
+ return body.toLowerCase().indexOf(v);
956
+ }
957
+ function lenSatisfies(len, cond) {
958
+ switch (cond.op) {
959
+ case '>': return len > cond.n;
960
+ case '<': return len < cond.n;
961
+ case '>=': return len >= cond.n;
962
+ case '<=': return len <= cond.n;
963
+ case 'range': return len >= cond.n && len <= cond.m;
964
+ default: return len === cond.n;
965
+ }
966
+ }
967
+ function contentHit(c, m, doc, hayLower) {
968
+ if (c.scope === 'title') return m.test(doc.title, false);
969
+ if (c.scope === 'body') return m.regex ? m.test(doc.body, false) : bodyIndexOf(doc, hayLower, m.lower) >= 0;
970
+ return m.test(hayLower, true);
971
+ }
972
+ function facetValueOk(doc, field, v) {
973
+ if (field === 'group') return docInGroup(doc, v);
974
+ if (field === 'num') return doc.numpad === v;
975
+ return docHasIs(doc, v);
976
+ }
977
+ // One leaf of a custom expression (its sign included). A negated
978
+ // single-valued filter is ignored (always true), as at the top level.
979
+ function leafOk(leaf, doc, hayLower, now) {
980
+ let hit;
981
+ switch (leaf.field) {
982
+ case 'content': hit = contentHit(leaf, termMatcher(leaf), doc, hayLower); break;
983
+ case 'group': case 'is': case 'num': hit = facetValueOk(doc, leaf.field, leaf.value); break;
984
+ case 'since': case 'before': {
985
+ if (leaf.neg) return true;
986
+ const b = resolveTimeMs(leaf.value, now);
987
+ return b == null || (leaf.field === 'since' ? doc.ts * 1000 >= b : doc.ts * 1000 <= b);
988
+ }
989
+ case 'len': case 'lines': case 'words': {
990
+ if (leaf.neg) return true;
991
+ return lenSatisfies(leaf.field === 'len' ? doc.len : leaf.field === 'lines' ? docLines(doc) : docWords(doc), leaf.bound);
992
+ }
993
+ case 'id': return leaf.neg || doc.id.toLowerCase().includes(String(leaf.value).toLowerCase());
994
+ default: return true;
995
+ }
996
+ return leaf.neg ? !hit : hit;
997
+ }
998
+ function evalNode(node, doc, hayLower, now) {
999
+ if (node.type === 'leaf') return leafOk(node.leaf, doc, hayLower, now);
1000
+ if (node.type === 'or') return node.children.some((k) => evalNode(k, doc, hayLower, now));
1001
+ const all = node.children.every((k) => evalNode(k, doc, hayLower, now));
1002
+ return node.neg ? !all : all;
1003
+ }
1004
+ // Strict filter: every top-level term, filter, OR of filter values and custom expression
1005
+ // must hold. `opts`: { now, searchText? (precomputed combined haystack,
1006
+ // LOWERCASED), matchers? (compileContent of this query) }.
1007
+ function matchDoc(doc, parsed, opts) {
1008
+ if (!doc) return false;
1009
+ const o = opts || {};
1010
+ const matchers = o.matchers || compileContent(parsed);
1011
+ let any = o.searchText;
1012
+ for (let k = 0; k < parsed.content.length; k += 1) {
1013
+ const c = parsed.content[k];
1014
+ const m = matchers[k];
1015
+ if (c.scope === 'any' && any == null) any = docSearchText(doc).toLowerCase();
1016
+ const hit = c.scope === 'title' ? m.test(doc.title, false) : contentHit(c, m, doc, any);
1017
+ if (c.neg ? hit : !hit) return false;
1018
+ }
1019
+ for (const g of parsed.groups) if (!docInGroup(doc, g)) return false;
1020
+ for (const g of parsed.negGroups) if (docInGroup(doc, g)) return false;
1021
+ for (const v of parsed.is) if (!docHasIs(doc, v)) return false;
1022
+ for (const v of parsed.negIs) if (docHasIs(doc, v)) return false;
1023
+ for (const n of parsed.nums) if (doc.numpad !== n) return false;
1024
+ for (const n of parsed.negNums) if (doc.numpad === n) return false;
1025
+ if (parsed.since != null) { const b = resolveTimeMs(parsed.since, o.now); if (b != null && doc.ts * 1000 < b) return false; }
1026
+ if (parsed.before != null) { const b = resolveTimeMs(parsed.before, o.now); if (b != null && doc.ts * 1000 > b) return false; }
1027
+ if (parsed.len && !lenSatisfies(doc.len, parsed.len)) return false;
1028
+ if (parsed.lines && !lenSatisfies(docLines(doc), parsed.lines)) return false;
1029
+ if (parsed.words && !lenSatisfies(docWords(doc), parsed.words)) return false;
1030
+ if (parsed.id && !doc.id.toLowerCase().includes(String(parsed.id).toLowerCase())) return false;
1031
+ for (const g of parsed.anyOf || []) if (!g.values.some((v) => facetValueOk(doc, g.field, v))) return false;
1032
+ if (parsed.compound && parsed.compound.length) {
1033
+ if (any == null) any = docSearchText(doc).toLowerCase();
1034
+ for (const n of parsed.compound) if (!evalNode(n, doc, any, o.now || Date.now())) return false;
1035
+ }
1036
+ return true;
1037
+ }
1038
+ function docHasIs(doc, v) {
1039
+ if (v === 'pinned') return doc.pinned;
1040
+ if (v === 'image') return doc.type === 'image';
1041
+ if (v === 'text') return doc.type === 'text';
1042
+ if (v === 'numpad') return doc.numpad != null;
1043
+ if (v === 'url') return docUrl(doc);
1044
+ if (v === 'multiline') return docLines(doc) > 1;
1045
+ if (v === 'rich') return !!doc.rich;
1046
+ return false;
1047
+ }
1048
+ // Every span the query's terms match in `text` (positive terms only, the
1049
+ // ones inside an OR included), merged where they touch: the row highlight
1050
+ // and the preview window. scope: 'title' | 'body' (which terms apply).
1051
+ function termSpans(parsed, text, scope, limit) {
1052
+ const raw = String(text == null ? '' : text);
1053
+ const spans = [];
1054
+ for (const t of (parsed && parsed.terms) || []) {
1055
+ if (scope === 'title' ? t.scope === 'body' : t.scope === 'title') continue;
1056
+ const m = termMatcher(t);
1057
+ if (!m.valid || (!m.regex && !m.lower)) continue;
1058
+ spans.push(...m.all(raw, limit || 100));
1059
+ }
1060
+ if (spans.length < 2) return spans;
1061
+ spans.sort((a, b) => a.start - b.start || b.end - a.end);
1062
+ const merged = [spans[0]];
1063
+ for (const s of spans.slice(1)) {
1064
+ const last = merged[merged.length - 1];
1065
+ if (s.start <= last.end) last.end = Math.max(last.end, s.end); else merged.push({ ...s });
1066
+ }
1067
+ return merged;
1068
+ }
1069
+ // Where the query first matches in `text` (-1: nowhere), for a preview
1070
+ // window around it.
1071
+ function firstMatchIndex(parsed, text) {
1072
+ let best = -1;
1073
+ for (const t of (parsed && parsed.terms) || []) {
1074
+ if (t.scope === 'title') continue;
1075
+ const m = termMatcher(t);
1076
+ const hit = m.valid && (m.regex || m.lower) ? m.find(text, 0) : null;
1077
+ if (hit && (best < 0 || hit.start < best)) best = hit.start;
1078
+ }
1079
+ return best;
1080
+ }
1081
+
1082
+ // ── relevance scoring (for the strict-filter list; rank survivors) ──
1083
+ const RECENCY_SCALE_MS = 3 * 86400 * 1000; // 3 days
1084
+ const RECENCY_WEIGHT = 30; // max recency contribution vs relevance
1085
+ const GROUP_USE_WEIGHT = 8; // max nudge for a clip of your most-used group
1086
+ function recencyScore(doc, now) {
1087
+ return RECENCY_WEIGHT * decayWeight((now || Date.now()) - doc.ts * 1000, RECENCY_SCALE_MS);
1088
+ }
1089
+ function normalizedPhrase(v) { return String(v == null ? '' : v).trim().toLowerCase(); }
1090
+ // The terms a relevance score reads (every term that can match, the ones
1091
+ // inside an OR included), normalised once per query (not once per clip):
1092
+ // [{ scope, v (lowercase phrase), spaced } | { scope, re (compiled regex) }].
1093
+ function relevanceTerms(parsed) {
1094
+ const out = [];
1095
+ for (const c of parsed.terms || []) {
1096
+ if (c.regex) { const m = termMatcher(c); if (m.valid) out.push({ scope: c.scope, re: m }); continue; }
1097
+ const v = normalizedPhrase(c.value);
1098
+ if (v) out.push({ scope: c.scope, v, spaced: /\s/.test(v) });
1099
+ }
1100
+ return out;
1101
+ }
1102
+ function docTitleLower(doc) {
1103
+ if (doc.titleLower === undefined) doc.titleLower = doc.title.toLowerCase();
1104
+ return doc.titleLower;
1105
+ }
1106
+ function relevanceScore(doc, parsed, opts) {
1107
+ const o = opts || {};
1108
+ let score = 0;
1109
+ // opts.hay: the clip's lowercased combined haystack; body positions come
1110
+ // from it (bodyIndexOf) instead of lowercasing the whole body per keystroke.
1111
+ // opts.terms: relevanceTerms(parsed), precomputed by a caller scoring many clips.
1112
+ const titleLower = docTitleLower(doc);
1113
+ for (const t of (o.terms || relevanceTerms(parsed))) {
1114
+ const wantTitle = t.scope !== 'body';
1115
+ const wantBody = t.scope !== 'title';
1116
+ if (t.re) {
1117
+ if (wantTitle && doc.title && t.re.test(doc.title)) score += 22;
1118
+ if (wantBody && doc.body && t.re.test(doc.body)) score += 12;
1119
+ continue;
1120
+ }
1121
+ const v = t.v;
1122
+ // exact / prefix / substring in title
1123
+ if (wantTitle && doc.title) {
1124
+ if (titleLower === v) score += 60;
1125
+ else if (titleLower.startsWith(v)) score += 34;
1126
+ else if (titleLower.includes(v)) score += 22;
1127
+ else { const fm = fuzzyMatch(v, doc.title); if (fm && fm.score >= fuzzyFloor(v.length)) score += 10 + Math.min(14, fm.score / 6); }
1128
+ }
1129
+ if (wantBody && doc.body) {
1130
+ const idx = bodyIndexOf(doc, o.hay, v);
1131
+ if (idx >= 0) { score += 12; score += Math.max(0, 6 - idx / 200); } // earlier = a touch better
1132
+ // multi-word phrase already covered by includes; word tokens add a little
1133
+ if (t.spaced && idx >= 0) score += 6;
1134
+ }
1135
+ if (t.scope === 'any') {
1136
+ // group-name hit is weak signal
1137
+ if (doc.groups.length && doc.groups.some((g) => g.toLowerCase().includes(v))) score += 4;
1138
+ }
1139
+ }
1140
+ // small structural nudges: pinned, and a group you use (o.groupUse:
1141
+ // groupWeights of the whole history)
1142
+ if (doc.pinned) score += 3;
1143
+ const use = o.groupUse;
1144
+ if (use && use.max > 0 && doc.groups.length) {
1145
+ let best = 0;
1146
+ for (const g of doc.groups) { const w = use.get(g) || 0; if (w > best) best = w; } // stored names are normalised
1147
+ score += GROUP_USE_WEIGHT * (best / use.max);
1148
+ }
1149
+ return score;
1150
+ }
1151
+
1152
+ // Filter + rank -> array of ORIGINAL indexes. `opts`: { now, sortMode ('best'|'new'),
1153
+ // docs? (prebuilt), searchTextLower? (precomputed combined haystacks, lowercased) }.
1154
+ // Ranking mode for a parsed query: an explicit sortMode (the Best/Recent toggle) or
1155
+ // `sort:` token wins; else relevance ('best') when there is something to search for;
1156
+ // else the caller's ORIGINAL order ('none' - the popup's history order, which is newest
1157
+ // first). 'new' and 'none' are both time-ordered lists; the popup's keep-your-place
1158
+ // rules (Core.resolveListAnchor) key off exactly this, so it has ONE definition.
1159
+ function rankMode(parsed, sortMode) {
1160
+ const p = parsed || {};
1161
+ return sortMode || (p.sort ? p.sort : (hasSearchTerms(p) ? 'best' : 'none'));
1162
+ }
1163
+
1164
+ // Incremental refinement: typing more of a term can only narrow the result,
1165
+ // so the next keystroke tests the previous matches instead of every clip.
1166
+ // Valid when the docs + haystacks are the same arrays, the query is a plain
1167
+ // AND of literal terms (no /regex/, no custom expression: neither narrows by
1168
+ // growing), the facets are identical (no before:, whose relative bound widens
1169
+ // as time passes), and every earlier content term is kept (same scope and
1170
+ // sign) with a positive term only growing (new includes old) and a negative
1171
+ // one unchanged; terms may be appended. `cache` is a caller-owned object
1172
+ // (one per list).
1173
+ function refineState(parsed) {
1174
+ return {
1175
+ facets: facetKey(parsed),
1176
+ before: !!parsed.before,
1177
+ strict: !(parsed.compound && parsed.compound.length) && !parsed.content.some((c) => c.regex),
1178
+ terms: parsed.content.map((c) => ({ scope: c.scope, neg: !!c.neg, lower: String(c.value).toLowerCase() })),
1179
+ };
1180
+ }
1181
+ function canRefine(prev, next) {
1182
+ if (!prev || !prev.strict || !next.strict || prev.before || next.before || prev.facets !== next.facets) return false;
1183
+ if (next.terms.length < prev.terms.length) return false;
1184
+ for (let k = 0; k < prev.terms.length; k += 1) {
1185
+ const a = prev.terms[k];
1186
+ const b = next.terms[k];
1187
+ if (a.scope !== b.scope || a.neg !== b.neg) return false;
1188
+ if (a.neg ? a.lower !== b.lower : !b.lower.includes(a.lower)) return false;
1189
+ }
1190
+ return true;
1191
+ }
1192
+ function filterRankIndexes(items, parsed, opts) {
1193
+ const o = opts || {};
1194
+ const now = o.now || Date.now();
1195
+ const docs = o.docs || (items || []).map(clipToDoc);
1196
+ const hay = o.searchTextLower || null;
1197
+ const hasContent = hasSearchTerms(parsed);
1198
+ const mode = rankMode(parsed, o.sortMode);
1199
+ const scored = [];
1200
+ // One options object and one compiled matcher set for the whole pass.
1201
+ const matchOpts = { now, searchText: undefined, matchers: compileContent(parsed) };
1202
+ const relOpts = { ...o, hay: undefined, terms: relevanceTerms(parsed), groupUse: mode === 'best' && hasContent ? groupWeights(docs, now) : null };
1203
+ const cache = o.cache || null;
1204
+ const state = cache ? refineState(parsed) : null;
1205
+ const prev = cache && cache.docs === docs && cache.hay === hay ? cache.state : null;
1206
+ const candidates = prev && canRefine(prev, state) ? cache.matched : null;
1207
+ const matched = cache ? [] : null;
1208
+ const total = candidates ? candidates.length : docs.length;
1209
+ for (let k = 0; k < total; k++) {
1210
+ const i = candidates ? candidates[k] : k;
1211
+ const doc = docs[i];
1212
+ if (!doc) continue;
1213
+ matchOpts.searchText = hay ? hay[i] : undefined;
1214
+ if (!matchDoc(doc, parsed, matchOpts)) continue;
1215
+ if (matched) matched.push(i);
1216
+ relOpts.hay = matchOpts.searchText;
1217
+ const rel = mode === 'best' && hasContent ? relevanceScore(doc, parsed, relOpts) : 0;
1218
+ scored.push({ i, total: rel + recencyScore(doc, now), ts: doc.ts });
1219
+ }
1220
+ if (cache) { cache.docs = docs; cache.hay = hay; cache.state = state; cache.matched = matched; cache.refined = !!candidates; }
1221
+ if (mode === 'best') scored.sort((a, b) => b.total - a.total || b.ts - a.ts);
1222
+ else if (mode === 'new') scored.sort((a, b) => b.ts - a.ts || b.i - a.i);
1223
+ // mode 'none' -> leave in original (caller/history) order
1224
+ return scored.map((s) => s.i);
1225
+ }
1226
+
1227
+ // ── IntelliJ camel-hump fuzzy matcher (ported from Forge src/renderer/lib/fuzzy.ts,
1228
+ // React stripped). `sdi` -> "Sync Data-loss Incident". Returns {score, positions}|null. ──
1229
+ const F_BONUS_WORD_START = 24, F_BONUS_STRING_START = 12, F_BONUS_CONSECUTIVE = 16, F_BONUS_CASE = 2, F_PENALTY_GAP = -2, F_PENALTY_LEAD = -1, F_LEAD_CAP = -6;
1230
+ const fIsSep = (c) => c === ' ' || c === '-' || c === '_' || c === '/' || c === '\\' || c === '.' || c === ':';
1231
+ const fLower = (c) => c >= 'a' && c <= 'z';
1232
+ const fUpper = (c) => c >= 'A' && c <= 'Z';
1233
+ const fDigit = (c) => c >= '0' && c <= '9';
1234
+ function wordStarts(t) {
1235
+ const out = new Uint8Array(t.length);
1236
+ for (let i = 0; i < t.length; i++) {
1237
+ const c = t[i];
1238
+ if (fIsSep(c)) continue;
1239
+ const p = i === 0 ? '' : t[i - 1];
1240
+ if (i === 0 || fIsSep(p)) out[i] = 1;
1241
+ else if (fUpper(c) && fLower(p)) out[i] = 1;
1242
+ else if (fDigit(c) !== fDigit(p) && !fIsSep(p)) out[i] = 1;
1243
+ }
1244
+ return out;
1245
+ }
1246
+ function fuzzyMatch(query, target) {
1247
+ const q = String(query || '');
1248
+ const n = q.length;
1249
+ const m = String(target || '').length;
1250
+ if (!n || n > m) return null;
1251
+ const ql = q.toLowerCase();
1252
+ const tl = String(target).toLowerCase();
1253
+ for (let i = 0, j = 0; i < n; i++) { j = tl.indexOf(ql[i], j); if (j < 0) return null; j++; }
1254
+ const starts = wordStarts(String(target));
1255
+ const NEG = -Infinity;
1256
+ let prev = new Float64Array(m).fill(NEG);
1257
+ let cur = new Float64Array(m).fill(NEG);
1258
+ const parent = new Int32Array(n * m).fill(-1);
1259
+ for (let i = 0; i < n; i++) {
1260
+ cur.fill(NEG);
1261
+ let bestCarry = NEG, bestCarryIdx = -1;
1262
+ for (let j = 0; j < m; j++) {
1263
+ if (i > 0 && j > 0) { const cand = prev[j - 1] - F_PENALTY_GAP * (j - 1); if (cand > bestCarry) { bestCarry = cand; bestCarryIdx = j - 1; } }
1264
+ if (tl[j] !== ql[i]) continue;
1265
+ const boundary = starts[j] === 1;
1266
+ let base, par = -1;
1267
+ if (i === 0) { if (!boundary) continue; base = Math.max(F_PENALTY_LEAD * j, F_LEAD_CAP); }
1268
+ else {
1269
+ const adj = j > 0 ? prev[j - 1] : NEG;
1270
+ if (boundary) {
1271
+ const gen = bestCarry === NEG ? NEG : bestCarry + F_PENALTY_GAP * (j - 1);
1272
+ if (adj !== NEG && adj + F_BONUS_CONSECUTIVE >= gen) { base = adj + F_BONUS_CONSECUTIVE; par = j - 1; }
1273
+ else if (gen !== NEG) { base = gen; par = bestCarryIdx; }
1274
+ else continue;
1275
+ } else { if (adj === NEG) continue; base = adj + F_BONUS_CONSECUTIVE; par = j - 1; }
1276
+ }
1277
+ let s = base + 8;
1278
+ if (starts[j]) s += F_BONUS_WORD_START + (j === 0 ? F_BONUS_STRING_START : 0);
1279
+ if (String(target)[j] === q[i]) s += F_BONUS_CASE;
1280
+ if (s > cur[j]) { cur[j] = s; parent[i * m + j] = par; }
1281
+ }
1282
+ const t = prev; prev = cur; cur = t;
1283
+ }
1284
+ let best = NEG, bestJ = -1;
1285
+ for (let j = 0; j < m; j++) if (prev[j] > best) { best = prev[j]; bestJ = j; }
1286
+ if (bestJ < 0) return null;
1287
+ const positions = new Array(n);
1288
+ for (let i = n - 1, j = bestJ; i >= 0; i--) { positions[i] = j; j = parent[i * m + j]; }
1289
+ return { score: best, positions };
1290
+ }
1291
+ function fuzzyFloor(queryLen) { return 8 * queryLen + 4; }
1292
+
1293
+ // ── query syntax lexer (presentational, for the highlight overlay) ──
1294
+ // Walks the EXACT raw text (whitespace + quotes preserved, concat(text) === input) into
1295
+ // typed segments: prefix | value | neg | quote | regex | op | unknown | ws. parseQuery
1296
+ // stays the semantic authority; this only decides colors, from the same scanner.
1297
+ const REGEX_META = /[[\]().*+?|^$\\{}]/;
1298
+ function pushValueSegs(segs, value, regexAware, plainQuotes) { // plainQuotes: a quote is a pattern character
1299
+ let buf = '';
1300
+ let kind = null;
1301
+ const flush = () => { if (buf && kind) segs.push({ kind, text: buf }); buf = ''; kind = null; };
1302
+ for (const ch of String(value)) {
1303
+ const k = ch === '"' && !plainQuotes ? 'quote' : (regexAware && REGEX_META.test(ch)) ? 'regex' : 'value';
1304
+ if (k !== kind) flush();
1305
+ buf += ch; kind = k;
1306
+ }
1307
+ flush();
1308
+ }
1309
+ // What is INVALID comes from validateQuery (ONE rule): its problemRanges are
1310
+ // painted 'unknown', exactly the bad key or value and nothing around it.
1311
+ // opts: { groups, problems (the caller's validateQuery result, e.g. with
1312
+ // pending ones left out; else every non-pending problem of the text) }.
1313
+ function lexQuery(text, opts) {
1314
+ const o = opts || {};
1315
+ const segs = lexSegments(text, o);
1316
+ const problems = o.problems || validateQuery(text, { groups: o.groups }).filter((p) => !p.pending);
1317
+ return problems.length ? markRanges(segs, problemRanges(problems), 'unknown') : segs;
1318
+ }
1319
+ // Split segments at range edges and give the covered parts `kind`.
1320
+ function markRanges(segs, ranges, kind) {
1321
+ const out = [];
1322
+ let pos = 0;
1323
+ for (const seg of segs) {
1324
+ const a = pos;
1325
+ const b = pos + seg.text.length;
1326
+ pos = b;
1327
+ const cuts = new Set([a, b]);
1328
+ for (const r of ranges) { if (r.start > a && r.start < b) cuts.add(r.start); if (r.end > a && r.end < b) cuts.add(r.end); }
1329
+ const pts = [...cuts].sort((x, y) => x - y);
1330
+ for (let k = 0; k < pts.length - 1; k += 1) {
1331
+ const from = pts[k];
1332
+ const to = pts[k + 1];
1333
+ const bad = seg.kind !== 'ws' && ranges.some((r) => r.start <= from && r.end >= to);
1334
+ out.push({ kind: bad ? kind : seg.kind, text: seg.text.slice(from - a, to - a) });
1335
+ }
1336
+ }
1337
+ return out;
1338
+ }
1339
+ function lexSegments(text, opts) {
1340
+ const s = String(text || '');
1341
+ const segs = [];
1342
+ let pos = 0;
1343
+ for (const tok of scanQuery(s)) {
1344
+ if (tok.start > pos) segs.push({ kind: 'ws', text: s.slice(pos, tok.start) });
1345
+ pos = tok.end;
1346
+ if (tok.t === 'lp') { if (tok.neg) segs.push({ kind: 'neg', text: '-' }); segs.push({ kind: 'op', text: '(' }); continue; }
1347
+ if (tok.t !== 'term') { segs.push({ kind: 'op', text: s.slice(tok.start, tok.end) }); continue; }
1348
+ let body = tok.text;
1349
+ let at = tok.start;
1350
+ if (tok.neg) { segs.push({ kind: 'neg', text: '-' }); body = body.slice(1); at += 1; }
1351
+ if (tok.regex) {
1352
+ if (tok.regex.open > at) segs.push({ kind: 'prefix', text: s.slice(at, tok.regex.open) });
1353
+ segs.push({ kind: 'regex', text: '/' });
1354
+ pushValueSegs(segs, tok.regex.pattern, true, true);
1355
+ segs.push({ kind: 'regex', text: '/' });
1356
+ continue;
1357
+ }
1358
+ // A bare known key (`title:`, its value still to type) is already a key.
1359
+ const m = body[0] !== '"' ? KEY_TOKEN_RE.exec(body) : null;
1360
+ if (m && m[2][0] !== '/' && m[2][0] !== '\\' && RECOGNIZED_PREFIXES.has(m[1].toLowerCase())) {
1361
+ segs.push({ kind: 'prefix', text: body.slice(0, m[1].length + 1) });
1362
+ if (m[2]) pushValueSegs(segs, m[2], false);
1363
+ continue;
1364
+ }
1365
+ pushValueSegs(segs, body, false);
1366
+ }
1367
+ if (pos < s.length) segs.push({ kind: 'ws', text: s.slice(pos) });
1368
+ return segs;
1369
+ }
1370
+
1371
+ // ── what each filter and value means: ONE table, keyed by prefix AND value.
1372
+ // The autocomplete rows read it (each row its own hint, never one hint
1373
+ // repeated down the list), the "Did you mean" / valid-values feedback lists
1374
+ // its values, and the options panel's field keys (OPTION_FIELDS) take their
1375
+ // tooltips from it, so a hint reads the same everywhere. `short` = the
1376
+ // short alias (PREFIX_ALIASES has them all).
1377
+ const FIELD_INFO = {
1378
+ title: { desc: 'match the clip title only', short: 't' },
1379
+ text: { desc: 'match the clip body only', short: 'b' },
1380
+ group: { desc: 'in a group (and its sub-groups)', short: 'g' },
1381
+ is: { desc: 'kind of clip', values: { pinned: 'starred (pinned) clips', image: 'a picture', text: 'text, not a picture', numpad: 'on a numpad key', url: 'the clip is a link', multiline: 'spans several lines', rich: 'has HTML or RTF formatting' } },
1382
+ num: { desc: 'on numpad key 1-9', short: 'n' },
1383
+ since: { desc: 'newer than (1h, 7d or a date)', short: 's', values: { '1h': 'the last hour', '24h': 'the last day', '7d': 'the last week', '30d': 'the last month' } },
1384
+ before: { desc: 'older than (1h, 7d or a date)', short: 'bf', values: { '1h': 'an hour ago', '24h': 'a day ago', '7d': 'a week ago', '30d': 'a month ago' } },
1385
+ len: { desc: 'character count (>100, 50-200)', short: 'l', values: { '>100': 'more than 100 characters', '>500': 'more than 500 characters', '<80': 'under 80 characters', '50-200': '50 to 200 characters' } },
1386
+ lines: { desc: 'line count (>3)', short: 'ln', values: { '>1': 'more than one line', '>10': 'more than 10 lines', '<3': 'under 3 lines', '2-5': '2 to 5 lines' } },
1387
+ words: { desc: 'word count (<20)', short: 'wd', values: { '<20': 'under 20 words', '>100': 'more than 100 words', '10-50': '10 to 50 words' } },
1388
+ id: { desc: 'clip id contains' },
1389
+ sort: { desc: 'order results: newest or best match first', short: 'o', values: { new: 'newest first', best: 'best match first' } },
1390
+ };
1391
+ // An example value per key a prompt chip types (its tooltip).
1392
+ const FIELD_EXAMPLE = { since: '3d, 12h or 2026-01-31', before: '3d, 12h or 2026-01-31', len: '>200, <80 or 50-200', lines: '>10, <3 or 2-5', words: '>100, <20 or 10-50' };
1393
+ const SINCE_PRESETS = Object.keys(FIELD_INFO.since.values);
1394
+ // A derived view of FIELD_INFO (an export): 'title:' -> its description.
1395
+ const PREFIX_HINTS = Object.fromEntries(Object.entries(FIELD_INFO).map(([k, f]) => [`${k}:`, f.desc]));
1396
+
1397
+ // The search options panel's facet rows: the less-used filters, one click each.
1398
+ // Chips write tokens through applyFacet (the query text stays the single source
1399
+ // of truth) and paint their state from facetTokenState. `prompt` options take
1400
+ // a value of the user's: their prefix (with `prompt.op`, a size direction:
1401
+ // len:> / len:<) goes into the field for typing and the autocomplete offers
1402
+ // values; promptOptionState lights them, `prompt.active` is the lit label.
1403
+ // `dim`: the row's values are one OR dimension (its or / and toggle). ONE table: the panel and the
1404
+ // availability census both read it. A filter the chip bar already owns (is:pinned,
1405
+ // is:image, is:numpad = BUILTIN_TO_IS) never appears here: one filter, one place,
1406
+ // and the panel's toggle lights only for the panel's own filters.
1407
+ const OPTION_FACETS = [
1408
+ { id: 'date', label: 'Date', options: [
1409
+ { label: 'Last 24h', token: { kind: 'since', value: '24h' } },
1410
+ { label: 'Last 7 days', token: { kind: 'since', value: '7d' } },
1411
+ { label: 'Last 30 days', token: { kind: 'since', value: '30d' } },
1412
+ { label: 'Since...', token: { kind: 'since' }, prompt: { active: 'Since {v}' } },
1413
+ { label: 'Before...', token: { kind: 'before' }, prompt: { active: 'Before {v}' } },
1414
+ ] },
1415
+ { id: 'type', label: 'Type', dim: 'type', options: [
1416
+ { label: 'Text', token: { kind: 'is', value: 'text' } },
1417
+ { label: 'Link', token: { kind: 'is', value: 'url' } },
1418
+ { label: 'Multi-line', token: { kind: 'is', value: 'multiline' } },
1419
+ { label: 'Rich', token: { kind: 'is', value: 'rich' } },
1420
+ ] },
1421
+ { id: 'size', label: 'Size', options: [
1422
+ { label: 'Under 80 chars', token: { kind: 'len', value: '<80' } },
1423
+ { label: 'Over 500 chars', token: { kind: 'len', value: '>500' } },
1424
+ { label: 'Over 10 lines', token: { kind: 'lines', value: '>10' } },
1425
+ { label: 'Longer than...', token: { kind: 'len' }, prompt: { op: '>', active: 'Longer than {v}' } },
1426
+ { label: 'Shorter than...', token: { kind: 'len' }, prompt: { op: '<', active: 'Shorter than {v}' } },
1427
+ { label: 'Lines...', token: { kind: 'lines' }, prompt: { active: 'Lines {v}' } },
1428
+ { label: 'Words...', token: { kind: 'words' }, prompt: { active: 'Words {v}' } },
1429
+ ] },
1430
+ ];
1431
+ // A prompt chip lights for a value of its kind that no preset of its row
1432
+ // holds (and, for a size direction, one going that way): 'include' | null.
1433
+ // Its label then shows the value (active, {v}).
1434
+ function promptOptionState(parsed, row, opt) {
1435
+ const k = opt.token.kind;
1436
+ const cur = parsed[k];
1437
+ if (!cur || !opt.prompt) return null;
1438
+ if (row.options.some((o) => !o.prompt && o.token.kind === k && facetTokenState(parsed, o.token) === 'include')) return null;
1439
+ const op = opt.prompt.op;
1440
+ if (op && !(BOUND_FACETS.includes(k) && cur.op && cur.op[0] === op)) return null;
1441
+ if (!op && row.options.some((o) => o !== opt && o.prompt && o.prompt.op && o.token.kind === k && promptOptionState(parsed, row, o))) return null;
1442
+ return 'include';
1443
+ }
1444
+ function promptOptionLabel(parsed, opt) {
1445
+ const k = opt.token.kind;
1446
+ const cur = parsed[k];
1447
+ if (!cur || !opt.prompt || !opt.prompt.active) return opt.label;
1448
+ const v = BOUND_FACETS.includes(k) ? (opt.prompt.op && cur.op === opt.prompt.op ? String(cur.n) : serializeBound(cur)) : String(cur);
1449
+ return opt.prompt.active.replace('{v}', v);
1450
+ }
1451
+ // The panel teaches the grammar the way Forge's does: a toggle WRITES its
1452
+ // token into the field (Last 7 days -> since:7d), so the format is learned by
1453
+ // using it. What no toggle writes gets one key chip each (OPTION_FIELDS: a
1454
+ // click puts `title:` in the field to type the value), and the few rules no
1455
+ // key shows are one line (SYNTAX_NOTES). Derived: a field a facet row writes
1456
+ // is taught by that row; is: has the chips, sort: the field's own button,
1457
+ // id: is for tools.
1458
+ const PANEL_TAUGHT_ELSEWHERE = new Set(['is', 'sort', 'id']);
1459
+ const OPTION_FIELDS = Object.keys(FIELD_INFO).filter((k) => !PANEL_TAUGHT_ELSEWHERE.has(k)
1460
+ && !OPTION_FACETS.some((row) => row.options.some((opt) => opt.token.kind === k)));
1461
+ // The few rules no key shows: ONE line.
1462
+ const SYNTAX_NOTES = [
1463
+ { code: '-word', text: 'excludes' },
1464
+ { code: '"a phrase"', text: 'exact' },
1465
+ { code: 'a OR b', text: 'either' },
1466
+ { code: '(a b)', text: 'groups' },
1467
+ { code: '/regex/', text: 'a pattern' },
1468
+ ];
1469
+ // The query text a facet token stands for (its chip tooltip).
1470
+ function facetTokenText(token) {
1471
+ if (SINGLE_FACETS.includes(token.kind)) return `${token.kind}:${token.value == null ? '' : token.value}`;
1472
+ if (token.kind === 'group') return `group:${quoteToken(token.value)}`;
1473
+ if (token.kind === 'num') return `num:${token.value}`;
1474
+ return `is:${BUILTIN_TO_IS[token.value] || token.value}`;
1475
+ }
1476
+ // True while the query holds a filter the options panel owns (its toggle lights).
1477
+ function optionFacetsActive(parsed) {
1478
+ return OPTION_FACETS.some((row) => row.options.some((opt) => {
1479
+ const t = SINGLE_FACETS.includes(opt.token.kind) ? { kind: opt.token.kind } : opt.token;
1480
+ return facetTokenState(parsed, t) !== null;
1481
+ }));
1482
+ }
1483
+
1484
+ // ── autocomplete (Forge querySuggest rules) ──
1485
+ // suggestQuery(text, caret, { groups, groupWeights }) -> null, or { kind: 'key' | 'value',
1486
+ // replaceStart, replaceEnd, query (the typed key / value fragment), quoted,
1487
+ // suggestions: [{ text, label, hint, continuation }] }. Rules: nothing on an
1488
+ // empty box or an empty token; only with the caret at the END of a token
1489
+ // (editing inside a query stays native); a value that is already complete
1490
+ // (is:pinned, since:7d, num:3, an exact group) offers nothing more.
1491
+ // `continuation`: the row's text starts with what was typed, so the rest of
1492
+ // it can be painted as a ghost (ghostCompletion) or filled in
1493
+ // (uniqueCompletion). A term may sit inside a group ('(gro' offers
1494
+ // group:), and after a term an 'o' offers OR; a /regex/ offers nothing.
1495
+ function isValidTimeSpec(v) {
1496
+ const s = String(v || '').trim();
1497
+ return resolveTimeMs(s, 0) != null; // exactly the rule resolveTimeMs filters by
1498
+ }
1499
+ // A time value that is still being typed: digits with no unit yet, or a date
1500
+ // part way through (2026-0).
1501
+ function isPendingTimeSpec(v) {
1502
+ const s = String(v || '').trim();
1503
+ return /^\d+$/.test(s) || /^\d{4}-\d{0,2}(-\d{0,2})?$/.test(s);
1504
+ }
1505
+ function completeValue(canon, val, groups) {
1506
+ const v = String(val || '').toLowerCase();
1507
+ if (!v) return false;
1508
+ if (canon === 'is') return IS_VALUES.includes(v);
1509
+ if (canon === 'num') return /^[1-9]$/.test(v);
1510
+ if (canon === 'sort') return ['new', 'best', 'recent', 'relevance'].includes(v);
1511
+ if (canon === 'since' || canon === 'before') return isValidTimeSpec(v) && !isPendingTimeSpec(v);
1512
+ // Group names match exactly (tagMatchesFilter): group:work is NOT complete
1513
+ // when the group is Work, so the list stays up offering the right case.
1514
+ if (canon === 'group') { const n = normalizeTagName(val); return (groups || []).some((g) => normalizeTagName(g) === n); }
1515
+ return false;
1516
+ }
1517
+ function tokenAtCaret(text, caret) {
1518
+ const s = String(text || '');
1519
+ const at = Math.max(0, Math.min(caret == null ? s.length : caret, s.length));
1520
+ if (at < s.length && !/\s/.test(s[at])) return null; // caret inside a token
1521
+ let start = at;
1522
+ while (start > 0 && !/\s/.test(s[start - 1])) start -= 1;
1523
+ return { start, end: at, text: s.slice(start, at) };
1524
+ }
1525
+ function suggestQuery(text, caret, opts) {
1526
+ const o = opts || {};
1527
+ const whole = tokenAtCaret(text, caret);
1528
+ if (!whole) return null;
1529
+ // Group openers before the term are not part of it.
1530
+ const lead = /^(?:-?\()+/.exec(whole.text);
1531
+ const tok = lead ? { start: whole.start + lead[0].length, end: whole.end, text: whole.text.slice(lead[0].length) } : whole;
1532
+ if (!tok.text || tok.text === '-') return null;
1533
+ const neg = tok.text[0] === '-' ? '-' : '';
1534
+ const body = neg ? tok.text.slice(1) : tok.text;
1535
+ const lowerTok = tok.text.toLowerCase();
1536
+ const out = [];
1537
+ const push = (textVal, hint) => {
1538
+ const t = neg + textVal;
1539
+ out.push({ text: t, label: t, hint: hint || '', continuation: t.toLowerCase().startsWith(lowerTok) && t.length > tok.text.length });
1540
+ };
1541
+ const cm = KEY_TOKEN_RE.exec(body);
1542
+ let kind = 'key';
1543
+ let query = body;
1544
+ let quoted = false;
1545
+ if (cm) {
1546
+ const rawKey = cm[1].toLowerCase();
1547
+ const key = PREFIX_ALIASES[rawKey] || rawKey; // fold short aliases (t:/g:/n:...) to canonical
1548
+ kind = 'value';
1549
+ quoted = cm[2][0] === '"';
1550
+ query = cm[2].replace(/^"|"$/g, '');
1551
+ const val = query.toLowerCase();
1552
+ if (completeValue(key, query, o.groups)) return null;
1553
+ const info = FIELD_INFO[key] || {};
1554
+ const hintOf = (v) => (info.values && info.values[v]) || '';
1555
+ if (key === 'group') {
1556
+ // Prefix matches first, then names containing the fragment.
1557
+ const names = (o.groups || []).filter((g) => !val || g.toLowerCase().includes(val));
1558
+ const byUse = compareGroupUse(o.groupWeights);
1559
+ names.sort((a, b) => (b.toLowerCase().startsWith(val) - a.toLowerCase().startsWith(val)) || byUse(a, b));
1560
+ for (const g of names) push(`${cm[1]}:${quoteToken(g)}`, '');
1561
+ } else if (key === 'is') {
1562
+ for (const v of IS_VALUES) if (!val || v.startsWith(val)) push(`${cm[1]}:${v}`, hintOf(v));
1563
+ } else if (key === 'since' || key === 'before') {
1564
+ for (const p of SINCE_PRESETS) if (!val || p.startsWith(val)) push(`${cm[1]}:${p}`, hintOf(p));
1565
+ } else if (key === 'num') {
1566
+ for (let n = 1; n <= 9; n++) if (!val || String(n).startsWith(val)) push(`${cm[1]}:${n}`, '');
1567
+ } else if (key === 'sort') {
1568
+ for (const v of ['new', 'best']) if (!val || v.startsWith(val)) push(`${cm[1]}:${v}`, hintOf(v));
1569
+ } else if (BOUND_FACETS.includes(key) && info.values) {
1570
+ // Comparators and a range, as examples to finish (a number may follow).
1571
+ for (const v of Object.keys(info.values)) if (!val || (v.startsWith(val) && v !== val)) push(`${cm[1]}:${v}`, hintOf(v));
1572
+ }
1573
+ } else {
1574
+ // A bare word: offer prefixes it could start. Match on BOTH the long form
1575
+ // (ti -> title:) AND the short alias (t -> title:, g -> group:), and hint the
1576
+ // short form so both are discoverable. De-dupe so t only appears once.
1577
+ const lower = body.toLowerCase();
1578
+ const seen = new Set();
1579
+ const offer = (canon) => {
1580
+ if (seen.has(canon)) return;
1581
+ seen.add(canon);
1582
+ const f = FIELD_INFO[canon];
1583
+ push(`${canon}:`, f.short ? `${f.desc} · or ${f.short}:` : f.desc);
1584
+ };
1585
+ for (const canon of Object.keys(FIELD_INFO)) if (canon.startsWith(lower) && `${canon}:` !== lower) offer(canon);
1586
+ for (const [alias, canon] of Object.entries(PREFIX_ALIASES)) if (FIELD_INFO[canon] && alias.startsWith(lower)) offer(canon);
1587
+ for (const g of (o.groups || [])) if (g.toLowerCase().startsWith(lower)) push(`group:${quoteToken(g)}`, '');
1588
+ // OR between two terms: offered once a term stands before this one.
1589
+ if (!neg && (lower === 'o' || lower === 'or') && body !== 'OR' && /\S/.test(String(text || '').slice(0, tok.start))) push('OR', 'either side may match');
1590
+ }
1591
+ if (!out.length) return null;
1592
+ // A row that is what was typed in another case (group:work -> group:Work)
1593
+ // leads, then the best continuation (so the ghost and Tab agree with the top row).
1594
+ const caseFix = (s) => s.text.toLowerCase() === lowerTok;
1595
+ out.sort((a, b) => (caseFix(b) - caseFix(a)) || (b.continuation - a.continuation));
1596
+ return { kind, replaceStart: tok.start, replaceEnd: tok.end, query, quoted, suggestions: out.slice(0, 8) };
1597
+ }
1598
+ // The new text and caret after taking suggestion s of result res: a key
1599
+ // keeps the caret after its ':' (a value comes next); a value is a finished
1600
+ // token and gets a trailing space.
1601
+ function applySuggestion(text, res, s) {
1602
+ const v = String(text || '');
1603
+ const tail = v.slice(res.replaceEnd);
1604
+ const space = res.kind === 'value' || !s.text.endsWith(':') ? (/^\s/.test(tail) ? '' : ' ') : '';
1605
+ const next = v.slice(0, res.replaceStart) + s.text + space + tail;
1606
+ return { text: next, caret: res.replaceStart + s.text.length + space.length };
1607
+ }
1608
+ // The rest of the top row as a ghost, painted after the typed text: only with
1609
+ // the caret at the very end of the field and the row continuing what was typed.
1610
+ function ghostCompletion(text, caret, res) {
1611
+ const v = String(text || '');
1612
+ if (!res || caret !== v.length || !res.suggestions.length) return '';
1613
+ const top = res.suggestions[0];
1614
+ return top.continuation ? top.text.slice(res.replaceEnd - res.replaceStart) : '';
1615
+ }
1616
+ // Exactly one VALUE fits what was typed: fill it in with the inserted part
1617
+ // selected, so the next keystroke simply replaces it (Forge uniqueCompletion).
1618
+ // Never for a key (the user picks the value), an empty or quoted fragment, or
1619
+ // bare digits of a time (3 could still become 3h, 3d or 3w).
1620
+ function uniqueCompletion(text, res) {
1621
+ if (!res || res.kind !== 'value' || res.suggestions.length !== 1 || !res.query || res.quoted) return null;
1622
+ const s = res.suggestions[0];
1623
+ if (!s.continuation) return null;
1624
+ if (/^\d+$/.test(res.query) && /^-?(since|s|after|before|bf):/i.test(s.text)) return null;
1625
+ if (/^-?(len|l|lines|ln|words|wd):/i.test(s.text)) return null; // a size is the user's own number
1626
+ const v = String(text || '');
1627
+ const next = v.slice(0, res.replaceStart) + s.text + v.slice(res.replaceEnd);
1628
+ return { text: next, selectionStart: res.replaceEnd, selectionEnd: res.replaceStart + s.text.length };
1629
+ }
1630
+
1631
+ // ── invalid-token feedback (Forge validateQuery) ──
1632
+ // validateQuery(text, { mode, groups, caret }) -> [{ start, end, valueStart,
1633
+ // key, value, kind, message, options?, didYouMean?, pending? }]. A value that
1634
+ // is still a valid prefix (is:pin, since:7, len:>) is never flagged while it is
1635
+ // typed. An unknown key flags its KEY only (its value is still searched as
1636
+ // text); a bad value flags its VALUE only. A broken regex is `pending` while
1637
+ // the caret sits at the end of its token (the caller shows it once typing
1638
+ // pauses). `didYouMean` = the nearest valid value (Levenshtein <= 2), or for
1639
+ // an unknown key the filter it most likely meant (nearKey).
1640
+ function levenshtein(a, b) {
1641
+ const x = String(a).toLowerCase();
1642
+ const y = String(b).toLowerCase();
1643
+ const row = Array.from({ length: y.length + 1 }, (_, i) => i);
1644
+ for (let i = 1; i <= x.length; i++) {
1645
+ let prev = row[0];
1646
+ row[0] = i;
1647
+ for (let j = 1; j <= y.length; j++) {
1648
+ const old = row[j];
1649
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (x[i - 1] === y[j - 1] ? 0 : 1));
1650
+ prev = old;
1651
+ }
1652
+ }
1653
+ return row[y.length];
1654
+ }
1655
+ function closestTo(raw, options, maxDistance) {
1656
+ let best = null;
1657
+ let bestD = Infinity;
1658
+ for (const opt of options) {
1659
+ const d = levenshtein(raw, opt);
1660
+ if (d < bestD || (d === bestD && opt < best)) { best = opt; bestD = d; }
1661
+ }
1662
+ return best != null && bestD <= (maxDistance == null ? 2 : maxDistance) && bestD < String(raw).length ? best : undefined;
1663
+ }
1664
+ // Keys people guess for an existing filter (type:image means is:image).
1665
+ const KEY_GUESSES = { type: 'is', kind: 'is', tag: 'group', tags: 'group', size: 'len', length: 'len', order: 'sort', slot: 'num' };
1666
+ const FIELD_NAMES = Object.keys(FIELD_INFO);
1667
+ // The filter an unknown key most likely meant: a known guess, or a near
1668
+ // spelling of a FULL filter name (titel: -> title:). Never a 1-2 letter alias
1669
+ // (foo: is not o:), a key of 3-4 letters may be off by one letter only, and
1670
+ // it is offered only when the value is valid for that filter, so the
1671
+ // one-click fix never writes a new error or a silently wrong filter.
1672
+ function nearKey(key, rawValue, o) {
1673
+ const guess = KEY_GUESSES[key] || (key.length >= 3 ? closestTo(key, FIELD_NAMES, key.length <= 4 ? 1 : 2) : undefined);
1674
+ if (!guess) return undefined;
1675
+ return validateQuery(`${guess}:${rawValue}`, { groups: o.groups }).length ? undefined : `${guess}:`;
1676
+ }
1677
+ // The inverse a negated single-valued filter means (-since:7d = before:7d,
1678
+ // -len:>5 = len:<=5), or undefined where there is none to offer.
1679
+ const INVERSE_OP = { '>': '<=', '>=': '<', '<': '>=', '<=': '>' };
1680
+ function negatedFilterFix(field, value) {
1681
+ if (field === 'since') return `before:${quoteToken(value)}`;
1682
+ if (field === 'before') return `since:${quoteToken(value)}`;
1683
+ if (BOUND_FACETS.includes(field)) {
1684
+ const b = parseBound(value);
1685
+ return b && INVERSE_OP[b.op] ? `${field}:${INVERSE_OP[b.op]}${b.n}` : undefined;
1686
+ }
1687
+ return undefined;
1688
+ }
1689
+ // validateQuery(text, { groups, caret }). A problem may carry `fix`
1690
+ // ({ start, end, text }: the one-click repair the hint offers) beside its
1691
+ // `didYouMean` label.
1692
+ function validateQuery(text, opts) {
1693
+ const o = opts || {};
1694
+ const s = String(text || '');
1695
+ const caret = o.caret == null ? null : o.caret;
1696
+ // Every group path a group: value can name (Work/Clients also offers Work).
1697
+ const groupPaths = [...new Set((o.groups || []).flatMap((g) => normalizeTagName(g).split('/').filter(Boolean).map((_, i, a) => a.slice(0, i + 1).join('/'))))];
1698
+ const problems = [];
1699
+ const numsSeen = [];
1700
+ for (const tok of scanQuery(s)) {
1701
+ if (tok.t !== 'term') continue;
1702
+ const neg = tok.neg ? 1 : 0;
1703
+ const body = tok.text.slice(neg);
1704
+ const atCaret = caret != null && caret === tok.end;
1705
+ const base = { start: tok.start, end: tok.end, token: tok.text };
1706
+ if (tok.regex) {
1707
+ const err = regexError(tok.regex.pattern);
1708
+ if (err) problems.push({ ...base, valueStart: tok.regex.open, key: '', value: tok.regex.pattern, kind: 'invalid-regex', message: `Not a valid regular expression: ${err}.`, pending: atCaret });
1709
+ continue;
1710
+ }
1711
+ const m = body[0] !== '"' ? KEY_TOKEN_RE.exec(body) : null;
1712
+ const key = m ? m[1].toLowerCase() : '';
1713
+ const canon = PREFIX_ALIASES[key];
1714
+ // Searched as plain text, decided exactly as leafOf does: no key, no
1715
+ // value, a path or URL, a URL scheme, or a key too long to be a filter.
1716
+ if (!m || !m[2] || ((m[2][0] === '/' || m[2][0] === '\\') && !canon) || NON_FILTER_SCHEMES.has(key) || (!canon && !UNKNOWN_KEY_RE.test(key))) continue;
1717
+ const valueStart = tok.start + neg + m[1].length + 1;
1718
+ const value = m[2].replace(/^"|"$/g, '');
1719
+ if (!canon) {
1720
+ const near = nearKey(key, m[2], o);
1721
+ problems.push({ ...base, valueStart, key, value, kind: 'unknown-key', message: `${key}: is not a filter, so "${value}" is searched as text.`, didYouMean: near });
1722
+ continue;
1723
+ }
1724
+ const lower = value.toLowerCase();
1725
+ const bad = (message, options, didYouMean) => problems.push({ ...base, valueStart, key, value, kind: 'invalid-value', message, options, didYouMean });
1726
+ // A single-valued filter cannot be excluded: the hint offers the inverse.
1727
+ if (neg && (SINGLE_FIELDS.has(canon))) {
1728
+ const fix = negatedFilterFix(canon, value);
1729
+ problems.push({ ...base, valueStart: tok.start, key, value, kind: 'negated-filter', message: `${key}: cannot be excluded, so this filter is ignored.`, didYouMean: fix, fix: fix ? { start: tok.start, end: tok.end, text: fix } : undefined });
1730
+ continue;
1731
+ }
1732
+ if (canon === 'title' || canon === 'text') continue;
1733
+ if (canon === 'is') {
1734
+ if (!IS_VALUES.some((v) => v.startsWith(lower))) { const near = closestTo(lower, IS_VALUES); bad(`${key}:${value} is not a kind of clip.`, IS_VALUES, near ? `${key}:${near}` : undefined); }
1735
+ } else if (canon === 'num') {
1736
+ if (!/^[1-9]$/.test(lower)) bad(`${key}: takes a numpad slot from 1 to 9.`);
1737
+ else if (!neg) numsSeen.push(tok);
1738
+ } else if (canon === 'sort') {
1739
+ const vals = ['new', 'best', 'recent', 'relevance'];
1740
+ if (!vals.some((v) => v.startsWith(lower))) { const near = closestTo(lower, ['new', 'best']); bad(`${key}:${value} is not a sort order.`, ['new', 'best'], near ? `${key}:${near}` : undefined); }
1741
+ } else if (canon === 'since' || canon === 'before') {
1742
+ if (!isValidTimeSpec(lower) && !isPendingTimeSpec(lower)) bad(`${key}:${value} is not a time. Try 1h, 24h, 7d, 30d or a date (2026-01-31).`);
1743
+ } else if (canon === 'len' || canon === 'lines' || canon === 'words') {
1744
+ const pending = /^(>=|<=|>|<|=)?$/.test(value) || /^\d+\s*(-|\.\.?)$/.test(value);
1745
+ if (!parseBound(value) && !pending) bad(`${key}:${value} is not a count. Use >N, <N, >=N, <=N, N or a range N-M.`);
1746
+ } else if (canon === 'group' && groupPaths.length) {
1747
+ // Names match exactly (tagMatchesFilter: Work is not work). A prefix of
1748
+ // a name in any case is fine while it is typed (the list offers the
1749
+ // right case), but a whole name in the wrong case finds nothing, so it
1750
+ // is flagged with the right one.
1751
+ const n = normalizeTagName(value);
1752
+ const nl = n.toLowerCase();
1753
+ const sameName = groupPaths.find((g) => g.toLowerCase() === nl);
1754
+ const typing = !sameName && groupPaths.some((g) => g.toLowerCase().startsWith(nl));
1755
+ if (n && !groupPaths.some((g) => g === n || g.startsWith(n)) && !typing) {
1756
+ const shown = sameName || closestTo(n, groupPaths, 3);
1757
+ bad(`There is no group named "${value}".`, undefined, shown ? `${key}:${quoteToken(shown)}` : undefined);
1758
+ }
1759
+ }
1760
+ }
1761
+ // Two numpad keys side by side ask for a clip on both: it has one key.
1762
+ // Flagged only where both stand at the top level (an OR is fine); the fix
1763
+ // puts OR between them.
1764
+ if (numsSeen.length > 1) {
1765
+ const top = parseQuery(s);
1766
+ if (top.nums.length > 1) {
1767
+ const second = numsSeen[1];
1768
+ problems.push({ start: second.start, end: second.end, token: second.text, valueStart: second.start, key: 'num', value: second.text, kind: 'num-and',
1769
+ message: 'A clip sits on one numpad key, so these keys together find nothing.', didYouMean: `OR ${second.text}`, fix: { start: second.start, end: second.start, text: 'OR ' } });
1770
+ }
1771
+ }
1772
+ // Structure: an OR with nothing on one side, a group never closed. Both
1773
+ // are pending while the caret is still at their end.
1774
+ const syntax = parseQuery(s).syntax;
1775
+ for (const p of syntax) {
1776
+ const pending = caret != null && (p.kind === 'unclosed' ? caret === s.length : caret === p.end);
1777
+ const message = p.kind === 'unclosed' ? 'This group is never closed with ")".' : 'OR needs a term on each side.';
1778
+ problems.push({ start: p.start, end: p.end, token: s.slice(p.start, p.end), valueStart: p.start, key: '', value: '', kind: p.kind, message, pending });
1779
+ }
1780
+ return problems;
1781
+ }
1782
+ // The one-line hint for a problem: what is wrong, the valid values, the fix.
1783
+ function describeProblem(p) {
1784
+ if (!p) return '';
1785
+ const options = p.options && p.options.length && !p.didYouMean ? ` Valid: ${p.options.join(', ')}.` : '';
1786
+ const fix = p.didYouMean ? ` Did you mean ${p.didYouMean}?` : '';
1787
+ return `${p.message}${options}${fix}`;
1788
+ }
1789
+ // The exact spans to paint: an unknown key's KEY (with its ':'), a bad
1790
+ // value's VALUE (the recognised prefix keeps its colour), a broken regex's text.
1791
+ function problemRanges(problems) {
1792
+ return (problems || []).map((p) => (p.kind === 'unknown-key'
1793
+ ? { start: p.start + (p.token[0] === '-' && p.token.length > 1 ? 1 : 0), end: p.valueStart }
1794
+ : { start: p.valueStart, end: p.end }));
1795
+ }
1796
+
1797
+ // ── facet availability (Forge facetCensus / facetOptionVerdict) ──
1798
+ // For every facet option a chip offers, how many clips it would show under
1799
+ // all the OTHER active filters, in ONE pass over the docs. Each structural
1800
+ // token of the query is a check with a dimension (dim); a single-valued
1801
+ // facet (since, before, len, lines, words, id) holds its whole dim open, a
1802
+ // multi-valued AND facet (group:, is:) only its own token. facetFailures:
1803
+ // the dims a doc fails, capped at two (FAILURE_CAP: two failures and no
1804
+ // single held-open dim can rescue it). A doc failing nothing counts for
1805
+ // every option; failing exactly dim d, only for the options of dim d.
1806
+ // Free text is NOT part of the census (callers key it on facetKey, so typing
1807
+ // words never recomputes it).
1808
+ const FAILURE_CAP = 2;
1809
+ // Lines, counted only as far as `cap` (64): enough for every "lines over N"
1810
+ // option without walking a 31 MB clip, and exact below the cap.
1811
+ const LINES_CAP = 64;
1812
+ function docLinesCapped(doc) {
1813
+ if (doc.lines !== undefined) return Math.min(doc.lines, LINES_CAP);
1814
+ if (doc.linesCapped === undefined) {
1815
+ const s = doc.type === 'image' ? '' : doc.body;
1816
+ if (s.indexOf('\r') >= 0) doc.linesCapped = Math.min(docLines(doc), LINES_CAP);
1817
+ else {
1818
+ let n = s ? 1 : 0;
1819
+ let at = s.indexOf('\n');
1820
+ while (at >= 0 && n < LINES_CAP) { n += 1; at = s.indexOf('\n', at + 1); }
1821
+ doc.linesCapped = n;
1822
+ }
1823
+ }
1824
+ return doc.linesCapped;
1825
+ }
1826
+ function docBoundOk(doc, kind, bound) {
1827
+ if (kind === 'len') return lenSatisfies(doc.len, bound);
1828
+ if (kind === 'words') return lenSatisfies(docWords(doc), bound);
1829
+ // lines: the capped count answers every bound below the cap.
1830
+ const top = bound.op === 'range' ? bound.m : bound.n;
1831
+ const n = docLinesCapped(doc);
1832
+ if (n < LINES_CAP) return lenSatisfies(n, bound);
1833
+ if (top < LINES_CAP - 1) return bound.op === '>' || bound.op === '>='; // 64+ lines: past any such bound
1834
+ return lenSatisfies(docLines(doc), bound);
1835
+ }
1836
+ function docIsFast(doc, v) {
1837
+ if (v === 'multiline') return docLinesCapped(doc) > 1;
1838
+ return docHasIs(doc, v);
1839
+ }
1840
+ // The OR dimensions whose next value goes in with an OR (applyFacet's rule:
1841
+ // one value there under the default "or", or an OR already): dim -> the check dim the census
1842
+ // holds open for that dimension's options ('any:group').
1843
+ function openDims(parsed, joins) {
1844
+ const open = new Map();
1845
+ const j = normalizeJoins(joins);
1846
+ for (const dim of OR_DIMS) {
1847
+ if ((parsed.anyOf || []).some((g) => g.dim === dim)) { open.set(dim, `any:${dim}`); continue; }
1848
+ if (j[dim] === 'and') continue; // the next value would AND in: the lone value is a plain filter
1849
+ const n = dim === 'group' ? parsed.groups.length : dim === 'num' ? parsed.nums.length : parsed.is.filter((v) => TYPE_IS.has(v)).length;
1850
+ if (n === 1) open.set(dim, `any:${dim}`);
1851
+ }
1852
+ return open;
1853
+ }
1854
+ // Every top-level filter as a census / relaxation check: { dim, ok(doc, i),
1855
+ // label, tokens (applyFacet steps that remove it), time }. A lone value of
1856
+ // an open OR dimension is that dimension's check (its options would OR in).
1857
+ // A custom expression is one check, never relaxed (no tokens), and last, so
1858
+ // the failure cap usually stops before it is evaluated. hay: the lowercased
1859
+ // haystacks, by doc index (a custom expression may hold words).
1860
+ function facetChecks(parsed, now, hay, joins) {
1861
+ const checks = [];
1862
+ const open = openDims(parsed, joins);
1863
+ const anyDims = new Set((parsed.anyOf || []).map((g) => g.dim));
1864
+ const lone = (dim, own) => (open.has(dim) && !anyDims.has(dim) ? open.get(dim) : own);
1865
+ for (const g of parsed.groups) checks.push({ dim: lone('group', `group:${g}`), ok: (d) => docInGroup(d, g), label: `group:${quoteToken(g)}`, tokens: [[{ kind: 'group', value: g }, 'include']] });
1866
+ for (const g of parsed.negGroups) checks.push({ dim: `-group:${g}`, ok: (d) => !docInGroup(d, g), label: `-group:${quoteToken(g)}`, tokens: [[{ kind: 'group', value: g }, 'exclude']] });
1867
+ for (const v of parsed.is) checks.push({ dim: TYPE_IS.has(v) ? lone('type', `is:${v}`) : `is:${v}`, ok: (d) => docIsFast(d, v), label: `is:${v}`, tokens: [[{ kind: 'is', value: v }, 'include']] });
1868
+ for (const v of parsed.negIs) checks.push({ dim: `-is:${v}`, ok: (d) => !docIsFast(d, v), label: `-is:${v}`, tokens: [[{ kind: 'is', value: v }, 'exclude']] });
1869
+ for (const n of parsed.nums) checks.push({ dim: lone('num', `num:${n}`), ok: (d) => d.numpad === n, label: `num:${n}`, tokens: [[{ kind: 'num', value: n }, 'include']] });
1870
+ for (const n of parsed.negNums) checks.push({ dim: `-num:${n}`, ok: (d) => d.numpad !== n, label: `-num:${n}`, tokens: [[{ kind: 'num', value: n }, 'exclude']] });
1871
+ for (const g of parsed.anyOf || []) {
1872
+ checks.push({ dim: `any:${g.dim}`, ok: (d) => g.values.some((v) => (g.field === 'is' ? docIsFast(d, v) : facetValueOk(d, g.field, v))), label: anyOfText(g), tokens: g.values.map((v) => [{ kind: g.field, value: v }, 'include']) });
1873
+ }
1874
+ for (const k of ['since', 'before']) {
1875
+ if (parsed[k] == null) continue;
1876
+ const b = resolveTimeMs(parsed[k], now);
1877
+ if (b != null) checks.push({ dim: k, ok: k === 'since' ? (d) => d.ts * 1000 >= b : (d) => d.ts * 1000 <= b, label: `${k}:${quoteToken(parsed[k])}`, time: true, tokens: [[{ kind: k, value: parsed[k] }, 'include']] });
1878
+ }
1879
+ for (const k of BOUND_FACETS) if (parsed[k]) { const bound = parsed[k]; checks.push({ dim: k, ok: (d) => docBoundOk(d, k, bound), label: `${k}:${serializeBound(bound)}`, tokens: [[{ kind: k, value: serializeBound(bound) }, 'include']] }); }
1880
+ if (parsed.id) { const id = String(parsed.id).toLowerCase(); checks.push({ dim: 'id', ok: (d) => d.id.toLowerCase().includes(id), label: `id:${quoteToken(parsed.id)}`, tokens: [[{ kind: 'id', value: parsed.id }, 'include']] }); }
1881
+ (parsed.compound || []).forEach((node, n) => checks.push({ dim: `compound:${n}`, ok: (d, i) => evalNode(node, d, hay && hay[i] != null ? hay[i] : docSearchText(d).toLowerCase(), now) }));
1882
+ return checks;
1883
+ }
1884
+ function facetFailures(doc, checks, out, i) {
1885
+ out.length = 0;
1886
+ for (let k = 0; k < checks.length; k += 1) {
1887
+ if (checks[k].ok(doc, i)) continue;
1888
+ out.push(checks[k].dim);
1889
+ if (out.length >= FAILURE_CAP) break;
1890
+ }
1891
+ return out;
1892
+ }
1893
+ // A chip's token -> its census key: 'is:image', 'group:Work', 'since:7d',
1894
+ // 'before:' (the prompt option), 'len:<80'.
1895
+ function censusKey(token) {
1896
+ const k = token.kind;
1897
+ if (k === 'group') return `group:${normalizeTagName(token.value)}`;
1898
+ if (k === 'builtin' || k === 'is') return `is:${BUILTIN_TO_IS[token.value] || token.value}`;
1899
+ if (BOUND_FACETS.includes(k)) return `${k}:${serializeBound(parseBound(String(token.value == null ? '' : token.value)))}`;
1900
+ return `${k}:${token.value == null ? '' : String(token.value).toLowerCase()}`;
1901
+ }
1902
+ // The dim an option belongs to under this query (null = none held open):
1903
+ // an excluded value its own, a value of an open OR dimension that
1904
+ // dimension's (selecting it would OR it in), a selected value its own.
1905
+ function optionDim(parsed, token, joins) {
1906
+ const k = token.kind;
1907
+ if (SINGLE_FACETS.includes(k)) return k;
1908
+ const { field, value } = tokenFieldValue(token);
1909
+ const neg = field === 'group' ? parsed.negGroups : field === 'num' ? parsed.negNums : parsed.negIs;
1910
+ if (neg.includes(value)) return `-${field}:${value}`;
1911
+ const open = openDims(parsed, joins);
1912
+ const dim = facetDim(field, value);
1913
+ if (open.has(dim)) return open.get(dim);
1914
+ const pos = field === 'group' ? parsed.groups : field === 'num' ? parsed.nums : parsed.is;
1915
+ return pos.includes(value) ? `${field}:${value}` : null;
1916
+ }
1917
+ // facetCensus(docs, parsed, { now, groups, searchTextLower }) -> { permissive, total,
1918
+ // count(key), present(key) }. Options counted: every is: value, every
1919
+ // OPTION_FACETS token, and every group (with its parent paths) in `groups`.
1920
+ // `present` = the option exists somewhere in history, filters ignored (an
1921
+ // absent kind is hidden, never greyed). No docs (history still loading) =
1922
+ // permissive: nothing greys.
1923
+ function facetCensus(docs, parsed, opts) {
1924
+ const o = opts || {};
1925
+ const now = o.now || Date.now();
1926
+ const list = docs || [];
1927
+ const permissive = !list.length;
1928
+ const options = [];
1929
+ for (const v of IS_VALUES) options.push({ key: `is:${v}`, token: { kind: 'is', value: v }, test: (d) => docIsFast(d, v) });
1930
+ for (const row of OPTION_FACETS) {
1931
+ for (const opt of row.options) {
1932
+ const t = opt.token;
1933
+ if (t.kind === 'is') continue; // counted above
1934
+ const key = censusKey(t);
1935
+ let test;
1936
+ if (t.kind === 'since' || t.kind === 'before') {
1937
+ const b = t.value ? resolveTimeMs(t.value, now) : null;
1938
+ test = b == null ? () => true : t.kind === 'since' ? (d) => d.ts * 1000 >= b : (d) => d.ts * 1000 <= b;
1939
+ } else if (BOUND_FACETS.includes(t.kind)) {
1940
+ const bound = parseBound(String(t.value));
1941
+ test = bound ? (d) => docBoundOk(d, t.kind, bound) : () => false;
1942
+ } else continue;
1943
+ options.push({ key, token: t, test });
1944
+ }
1945
+ }
1946
+ for (const o2 of options) o2.dim = optionDim(parsed, o2.token, o.joins);
1947
+ // Groups: bumped from each doc's own groups (and their parents), not tested
1948
+ // one by one against every group.
1949
+ const groupKeys = new Set();
1950
+ for (const g of (o.groups || [])) {
1951
+ const parts = normalizeTagName(g).split('/').filter(Boolean);
1952
+ for (let i = 1; i <= parts.length; i += 1) groupKeys.add(parts.slice(0, i).join('/'));
1953
+ }
1954
+ const groupDim = new Map();
1955
+ for (const g of groupKeys) groupDim.set(g, optionDim(parsed, { kind: 'group', value: g }, o.joins));
1956
+ const counts = new Map();
1957
+ const present = new Map();
1958
+ const bump = (m, key) => m.set(key, (m.get(key) || 0) + 1);
1959
+ const checks = facetChecks(parsed, now, o.searchTextLower, o.joins);
1960
+ const failed = [];
1961
+ const seen = new Set();
1962
+ for (let i = 0; i < list.length; i += 1) {
1963
+ const doc = list[i];
1964
+ if (!doc) continue;
1965
+ facetFailures(doc, checks, failed, i);
1966
+ const only = failed.length === 1 ? failed[0] : null;
1967
+ const inBase = failed.length === 0;
1968
+ for (const opt of options) {
1969
+ const hit = opt.test(doc);
1970
+ if (!hit) continue;
1971
+ bump(present, opt.key);
1972
+ if (inBase || (only !== null && opt.dim === only)) bump(counts, opt.key);
1973
+ }
1974
+ if (doc.groups.length) {
1975
+ seen.clear();
1976
+ for (const g of doc.groups) {
1977
+ const parts = normalizeTagName(g).split('/');
1978
+ for (let i = 1; i <= parts.length; i += 1) {
1979
+ const p = parts.slice(0, i).join('/');
1980
+ if (seen.has(p) || !groupKeys.has(p)) continue;
1981
+ seen.add(p);
1982
+ bump(present, `group:${p}`);
1983
+ if (inBase || (only !== null && groupDim.get(p) === only)) bump(counts, `group:${p}`);
1984
+ }
1985
+ }
1986
+ }
1987
+ }
1988
+ return {
1989
+ permissive,
1990
+ total: list.length,
1991
+ groupWeights: groupWeights(list, now),
1992
+ open: openDims(parsed, o.joins),
1993
+ count: (key) => counts.get(key) || 0,
1994
+ present: (key) => (present.get(key) || 0) > 0,
1995
+ };
1996
+ }
1997
+ // Why an option can never match together with the active filters, whatever
1998
+ // the history holds (structural), or null. An image has no text: no length,
1999
+ // no lines, no link; a link is one line; a numpad clip is pinned. open: the
2000
+ // census's open dimensions (openDims): a kind of clip picked while the kinds
2001
+ // join with OR is OR'ed in, so the kinds already picked never block it.
2002
+ function structuralReason(parsed, token, open) {
2003
+ const k = token.kind;
2004
+ const v = k === 'builtin' || k === 'is' ? (BUILTIN_TO_IS[token.value] || token.value) : null;
2005
+ const blocker = (text) => `Never matches with ${text}`;
2006
+ const is = v && TYPE_IS.has(v) && open && open.has('type') ? parsed.is.filter((x) => !TYPE_IS.has(x)) : parsed.is;
2007
+ const imageOnly = is.includes('image') ? 'is:image' : parsed.negIs.includes('text') ? '-is:text' : null;
2008
+ const textOnly = ['text', 'url', 'multiline', 'rich'].map((x) => (is.includes(x) ? `is:${x}` : null)).find(Boolean)
2009
+ || (parsed.negIs.includes('image') ? '-is:image' : null)
2010
+ || BOUND_FACETS.map((b) => (parsed[b] && !lenSatisfies(0, parsed[b]) ? `${b}:${serializeBound(parsed[b])}` : null)).find(Boolean);
2011
+ const linesAllow = (n) => !parsed.lines || lenSatisfies(n, parsed.lines);
2012
+ if (v === 'image' && textOnly) return blocker(textOnly);
2013
+ if (['text', 'url', 'multiline', 'rich'].includes(v) && imageOnly) return blocker(imageOnly);
2014
+ if (v === 'url') {
2015
+ if (is.includes('multiline')) return blocker('is:multiline');
2016
+ if (!linesAllow(1)) return blocker(`lines:${serializeBound(parsed.lines)}`);
2017
+ }
2018
+ if (v === 'multiline') {
2019
+ if (is.includes('url')) return blocker('is:url');
2020
+ const L = parsed.lines;
2021
+ const most = !L ? Infinity : L.op === '<' ? L.n - 1 : (L.op === '<=' || L.op === '=') ? L.n : L.op === 'range' ? L.m : Infinity;
2022
+ if (most < 2) return blocker(`lines:${serializeBound(L)}`);
2023
+ }
2024
+ if (v === 'numpad' && parsed.negIs.includes('pinned')) return blocker('-is:pinned');
2025
+ if (BOUND_FACETS.includes(k)) {
2026
+ const bound = parseBound(String(token.value == null ? '' : token.value));
2027
+ if (bound && imageOnly && !lenSatisfies(0, bound)) return blocker(imageOnly);
2028
+ if (bound && k === 'lines' && parsed.is.includes('url') && !lenSatisfies(1, bound)) return blocker('is:url');
2029
+ }
2030
+ return null;
2031
+ }
2032
+ // Should this option be offered, and if not why (Forge facetOptionVerdict):
2033
+ // { enabled, hidden?, kind?: 'transient' | 'structural', reason?, count }.
2034
+ // The current selection is never disabled (it stays visible to be cleared).
2035
+ // A categorical option (an is: kind) absent from the whole history is hidden;
2036
+ // anything else is greyed: 'structural' (can never match with the active
2037
+ // filters) before 'transient' (nothing matches right now).
2038
+ function facetOptionVerdict(census, parsed, token, opts) {
2039
+ const o = opts || {};
2040
+ const key = censusKey(token);
2041
+ const count = census ? census.count(key) : 0;
2042
+ const probe = token.kind === 'before' && !token.value ? { kind: 'before' } : token;
2043
+ const selected = o.selected != null ? !!o.selected : facetTokenState(parsed, probe) !== null;
2044
+ if (selected) return { enabled: true, count };
2045
+ // A value a custom expression names (a OR b): a click could not change it there.
2046
+ const custom = customFacetKeys(parsed);
2047
+ if (custom && (custom.has(key) || (SINGLE_FACETS.includes(token.kind) && custom.has(`${token.kind}:`)))) return { enabled: false, kind: 'structural', reason: 'Part of a custom filter in the search: change it there', count: 0 };
2048
+ if (!census || census.permissive) return { enabled: true, count };
2049
+ const categorical = token.kind === 'builtin' || token.kind === 'is';
2050
+ if (categorical && !census.present(key)) return { enabled: false, hidden: true, count: 0 };
2051
+ const structural = structuralReason(parsed, token, census.open);
2052
+ if (structural) return { enabled: false, kind: 'structural', reason: structural, count: 0 };
2053
+ // A group with no clips stays a live chip while nothing else filters (its
2054
+ // click lands on the "No clips in X yet" state, which says what to do); it
2055
+ // greys only when other active filters are what leave it empty.
2056
+ if (token.kind === 'group' && !census.present(key)) return anyFilterActive(parsed) ? { enabled: false, kind: 'transient', reason: 'No clips in this group yet', count: 0 } : { enabled: true, count: 0 };
2057
+ if (!count) return { enabled: false, kind: 'transient', reason: 'No matches with the current filters', count: 0 };
2058
+ return { enabled: true, count };
2059
+ }
2060
+
2061
+ // ── the empty-result nudge (Forge queryRelaxations, one pass) ──
2062
+ // When the filters leave nothing, the ONE facet whose removal brings back the
2063
+ // most clips: { dim, label (the token text), count, time, query }. Content
2064
+ // terms must still match (they are never relaxed); a doc failing exactly one
2065
+ // facet counts for that facet. `query` is the text with that facet removed
2066
+ // through applyFacet (everything else, free text included, stays as typed).
2067
+ function bestRelaxation(docs, query, opts) {
2068
+ const o = opts || {};
2069
+ const parsed = typeof query === 'string' ? parseQuery(query) : query;
2070
+ const now = o.now || Date.now();
2071
+ const list = docs || [];
2072
+ const hay = o.searchTextLower || null;
2073
+ const checks = facetChecks(parsed, now, hay);
2074
+ const dims = checks.filter((c) => c.tokens);
2075
+ if (!dims.length) return null;
2076
+ const matchers = compileContent(parsed);
2077
+ const contentOnly = { ...emptyParsed(''), content: parsed.content };
2078
+ const matchOpts = { now, searchText: undefined, matchers };
2079
+ const failed = [];
2080
+ const counts = new Map();
2081
+ // o.cache (caller-owned, one per list): typing more of the same words only
2082
+ // re-tests the clips that counted last time (filterRankIndexes' refine
2083
+ // rule), so a nudge shown while typing does not rescan the whole history.
2084
+ const cache = o.cache || null;
2085
+ const state = cache ? refineState(parsed) : null;
2086
+ const prev = cache && cache.docs === list && cache.hay === hay ? cache.state : null;
2087
+ const candidates = prev && canRefine(prev, state) ? cache.matched : null;
2088
+ const matched = cache ? [] : null;
2089
+ const total = candidates ? candidates.length : list.length;
2090
+ for (let k = 0; k < total; k += 1) {
2091
+ const i = candidates ? candidates[k] : k;
2092
+ const doc = list[i];
2093
+ if (!doc) continue;
2094
+ facetFailures(doc, checks, failed, i);
2095
+ if (failed.length !== 1) continue;
2096
+ matchOpts.searchText = hay ? hay[i] : undefined;
2097
+ if (parsed.content.length && !matchDoc(doc, contentOnly, matchOpts)) continue;
2098
+ if (matched) matched.push(i);
2099
+ counts.set(failed[0], (counts.get(failed[0]) || 0) + 1);
2100
+ }
2101
+ if (cache) { cache.docs = list; cache.hay = hay; cache.state = state; cache.matched = matched; }
2102
+ let best = null;
2103
+ for (const d of dims) {
2104
+ const count = counts.get(d.dim) || 0;
2105
+ if (count > 0 && (!best || count > best.count)) best = { ...d, count };
2106
+ }
2107
+ if (!best) return null;
2108
+ let next = typeof query === 'string' ? query : serializeQuery(parsed);
2109
+ for (const [token, intent] of best.tokens) next = applyFacet(next, token, intent);
2110
+ return { dim: best.dim, label: best.label, count: best.count, time: !!best.time, query: next };
2111
+ }
2112
+
2113
+ // ── the Regex toggle: typing makes /regex/ terms, visibly ──
2114
+ // The query text is the ONE source of truth (no hidden mode): with the toggle
2115
+ // on, what is typed at a fresh spot is wrapped in /.../ with the caret
2116
+ // inside, so the field shows (and teaches) exactly what it searches. The
2117
+ // toggle itself wraps or unwraps the word at the caret. All pure, so the box
2118
+ // only applies the { text, caret } they return.
2119
+ // Would this bare word read as exactly itself (a literal term)?
2120
+ function isLiteralWord(word) {
2121
+ const p = parseQuery(word);
2122
+ const c = p.content[0];
2123
+ return p.items.length === 1 && p.content.length === 1 && !c.neg && !c.regex && c.scope === 'any' && c.value === word && !p.unknown.length && !p.syntax.length;
2124
+ }
2125
+ // Slashes escaped for a /regex/ (ONE rule for the toggle and the typing): an
2126
+ // escaped \/ stays as it is (opts.escaped: the text follows an open
2127
+ // backslash, so its first character is escaped already); complete text (a
2128
+ // paste, a wrapped word) has a trailing lone backslash doubled, so it can
2129
+ // never escape the closing slash. A single typed '\' stays one: the next key
2130
+ // completes it.
2131
+ function escapeSlashes(text, opts) {
2132
+ const s = String(text);
2133
+ const o = opts || {};
2134
+ let out = '';
2135
+ let i = 0;
2136
+ if (o.escaped && s.length) { out += s[0]; i = 1; }
2137
+ for (; i < s.length; i += 1) {
2138
+ if (s[i] === '\\') {
2139
+ if (i + 1 < s.length) { out += s.slice(i, i + 2); i += 1; } else out += o.complete ? '\\\\' : '\\';
2140
+ continue;
2141
+ }
2142
+ out += s[i] === '/' ? '\\/' : s[i];
2143
+ }
2144
+ return out;
2145
+ }
2146
+ // Text -> one /regex/ term.
2147
+ function regexToQuery(text) {
2148
+ const s = String(text || '');
2149
+ return s.trim() ? `/${escapeSlashes(s, { complete: true })}/` : '';
2150
+ }
2151
+ // A pattern as plain text: its escaped slashes back to slashes.
2152
+ function regexText(pattern) { return String(pattern || '').replace(/\\\//g, '/'); }
2153
+ // The scanner's term covering the caret (or ending at it).
2154
+ function termAt(text, caret) {
2155
+ for (const tok of scanQuery(text)) if (tok.t === 'term' && caret >= tok.start && caret <= tok.end) return tok;
2156
+ return null;
2157
+ }
2158
+ const CONTENT_KEY_BEFORE = /(?:^|[\s(])-?(?:title|t|text|b|body):$/i;
2159
+ // What typing `typed` at a collapsed caret does with the toggle on:
2160
+ // { text, caret }, or null (the field inserts it as usual). Inside a /regex/
2161
+ // a typed '/' is escaped, and at the closing slash it steps over it; a ':'
2162
+ // that ends a filter key (/group/ + ':') turns the term back into that key,
2163
+ // so filters are typed as always. At a fresh spot (after a space, a '(', a
2164
+ // lone '-', a title: / text: key, or right after another regex) the text
2165
+ // starts a new /.../ term; a lone '-', '(', ')' or '"' typed there is the
2166
+ // language's own (exclude, a bracket, a phrase), so it is typed as usual.
2167
+ function regexTypingEdit(text, caret, typed) {
2168
+ const s = String(text || '');
2169
+ const t = String(typed || '');
2170
+ if (!t || /[\r\n]/.test(t)) return null;
2171
+ const tok = termAt(s, caret);
2172
+ if (tok && tok.regex && caret > tok.regex.open && caret <= tok.regex.close) {
2173
+ if (t === '/' && caret === tok.regex.close) return { text: s, caret: caret + 1 };
2174
+ const pattern = tok.regex.pattern;
2175
+ const key = t === ':' && caret === tok.regex.close && tok.regex.scope === 'any' ? KEY_TOKEN_RE.exec(`${pattern}:`) : null;
2176
+ if (key && !key[2] && RECOGNIZED_PREFIXES.has(key[1].toLowerCase())) {
2177
+ return { text: s.slice(0, tok.regex.open) + pattern + ':' + s.slice(tok.regex.close + 1), caret: tok.regex.open + pattern.length + 1 };
2178
+ }
2179
+ let slashes = 0;
2180
+ for (let k = caret - 1; k >= 0 && s[k] === '\\'; k -= 1) slashes += 1;
2181
+ const ins = escapeSlashes(t, { escaped: slashes % 2 === 1, complete: t.length > 1 && caret === tok.regex.close });
2182
+ return ins === t ? null : { text: s.slice(0, caret) + ins + s.slice(caret), caret: caret + ins.length };
2183
+ }
2184
+ if (/^\s+$/.test(t) || ['-', '(', ')', '"'].includes(t)) return null;
2185
+ let before = s.slice(0, caret);
2186
+ const after = s.slice(caret);
2187
+ if (insideQuote(s, caret)) return null;
2188
+ const lead = /^\s/.test(t) ? t.match(/^\s+/)[0] : '';
2189
+ const body = t.slice(lead.length).replace(/\s+$/, '');
2190
+ if (!body) return null;
2191
+ const afterRegex = tok && tok.regex && caret === tok.end;
2192
+ const fresh = afterRegex || !before || /[\s(]$/.test(before) || /(?:^|[\s(])-$/.test(before) || CONTENT_KEY_BEFORE.test(before);
2193
+ if (!fresh || (after && !/^[\s)]/.test(after))) return null;
2194
+ if (afterRegex) before += ' ';
2195
+ // A typed '/' opens an empty pair (the next key goes inside).
2196
+ const term = body === '/' ? '//' : `/${escapeSlashes(body, { complete: t.length > 1 })}/`;
2197
+ const next = before + lead + term + after;
2198
+ return { text: next, caret: before.length + lead.length + term.length - 1 };
2199
+ }
2200
+ // Backspace with the toggle on: an emptied term (//) goes as a pair.
2201
+ function regexBackspaceEdit(text, caret) {
2202
+ const s = String(text || '');
2203
+ if (s[caret - 1] !== '/' || s[caret] !== '/') return null;
2204
+ const okBefore = caret - 1 === 0 || /[\s(-]/.test(s[caret - 2]) || CONTENT_KEY_BEFORE.test(s.slice(0, caret - 1));
2205
+ const okAfter = caret + 1 === s.length || /[\s)]/.test(s[caret + 1]);
2206
+ return okBefore && okAfter ? { text: s.slice(0, caret - 1) + s.slice(caret + 1), caret: caret - 1 } : null;
2207
+ }
2208
+ // The toggle on the word at the caret: on wraps a plain word in /.../ (the
2209
+ // text as typed, now read as a pattern; title:foo -> title:/foo/), off
2210
+ // unwraps a /regex/ to its text (quoted where it would read as syntax).
2211
+ // { text, caret }, or null when the caret is on no such word. Off with the
2212
+ // caret in an empty // pair (a '/' just typed) takes the pair away.
2213
+ function toggleRegexAt(text, caret, on) {
2214
+ const s = String(text || '');
2215
+ const pair = on ? null : regexBackspaceEdit(s, caret);
2216
+ if (pair) return pair;
2217
+ const tok = termAt(s, caret);
2218
+ if (!tok) return null;
2219
+ const neg = tok.neg ? '-' : '';
2220
+ if (on) {
2221
+ if (tok.regex) return null;
2222
+ const body = tok.body;
2223
+ const m = body[0] !== '"' ? KEY_TOKEN_RE.exec(body) : null;
2224
+ const canon = m ? PREFIX_ALIASES[m[1].toLowerCase()] : null;
2225
+ if (m && m[2] && !CONTENT_KEYS.has(canon)) return null; // a filter, not words
2226
+ const key = m && CONTENT_KEYS.has(canon) ? `${m[1]}:` : '';
2227
+ const value = (key ? m[2] : body).replace(/"/g, '');
2228
+ if (!value) return null;
2229
+ const term = `${neg}${key}${regexToQuery(value)}`;
2230
+ return { text: s.slice(0, tok.start) + term + s.slice(tok.end), caret: tok.start + term.length - 1 };
2231
+ }
2232
+ if (!tok.regex) return null;
2233
+ const key = s.slice(tok.start + neg.length, tok.regex.open);
2234
+ const plain = regexText(tok.regex.pattern);
2235
+ const lit = isLiteralWord(plain) ? plain : `"${plain.replace(/"/g, '')}"`;
2236
+ const term = `${neg}${key}${lit}`;
2237
+ return { text: s.slice(0, tok.start) + term + s.slice(tok.end), caret: tok.start + term.length };
2238
+ }
2239
+ // The old regex flag (an AI tool's regex: true): the query's plain words,
2240
+ // read as ONE regex (filters and other terms stay as they are).
2241
+ function legacyRegexQuery(query) {
2242
+ const q = String(query == null ? '' : query);
2243
+ const p = parseQuery(q);
2244
+ const words = p.items.filter((it) => it.kind === 'leaf' && it.leaf.field === 'content' && !it.leaf.neg && !it.leaf.regex && it.leaf.scope === 'any');
2245
+ if (!words.length) return q;
2246
+ const term = regexToQuery(words.map((w) => w.leaf.value).join(' '));
2247
+ return editFacets(q, p, ({ remove, edits }) => {
2248
+ edits.push({ start: words[0].start, end: words[0].end, text: term });
2249
+ words.slice(1).forEach(remove);
2250
+ });
2251
+ }
2252
+ // ── the or / and of one OR dimension (groups, kinds of clip, numpad keys):
2253
+ // the chip row's leading toggle and the panel's ──
2254
+ // The kinds whose or / and the device remembers. Numpad keys are not one
2255
+ // (a clip has one key: "and" always shows nothing).
2256
+ const JOIN_DIMS = ['group', 'type'];
2257
+ function normalizeJoins(joins) {
2258
+ const out = {};
2259
+ for (const dim of JOIN_DIMS) if (joins && joins[dim] === 'and') out[dim] = 'and';
2260
+ return out;
2261
+ }
2262
+ // 'or' (an OR of its values), 'and' (two or more side by side), or null
2263
+ // (nothing to join). With joins, ONE value under a remembered "and" reads
2264
+ // 'and' too: that join greys what it would empty, so the toggle shows (and
2265
+ // a click on it is the way back to "or").
2266
+ function dimConnective(parsed, dim, joins) {
2267
+ const any = (parsed.anyOf || []).filter((g) => g.dim === dim);
2268
+ const leaves = (parsed.items || []).filter((it) => it.kind === 'leaf' && !it.leaf.neg && facetDim(it.leaf.field, it.leaf.value) === dim);
2269
+ const n = any.reduce((sum, g) => sum + g.values.length, 0) + leaves.length;
2270
+ if (n === 1 && !any.length && normalizeJoins(joins)[dim] === 'and') return 'and';
2271
+ if (n < 2) return null;
2272
+ return any.length ? 'or' : 'and';
2273
+ }
2274
+ // Every top-level value of that dimension joined one way, written where the
2275
+ // first one was: group:A OR group:B <-> group:A group:B.
2276
+ function setDimConnective(query, dim, conn) {
2277
+ const text = String(query == null ? '' : query);
2278
+ const p = parseQuery(text);
2279
+ const items = p.items.filter((it) => (it.kind === 'leaf' && !it.leaf.neg && facetDim(it.leaf.field, it.leaf.value) === dim) || (it.kind === 'any' && it.any.dim === dim));
2280
+ const parts = items.flatMap((it) => (it.kind === 'any' ? it.any.members.map((m) => m.text) : [it.text]));
2281
+ if (parts.length < 2) return text;
2282
+ return editFacets(text, p, ({ remove, edits }) => {
2283
+ edits.push({ start: items[0].start, end: items[0].end, text: parts.join(conn === 'and' ? ' ' : ' OR ') });
2284
+ items.slice(1).forEach(remove);
2285
+ });
2286
+ }
2287
+
2288
+ // ── paste: a multi-word plain-text snippet searches as ONE phrase (Forge
2289
+ // quotePastedText). null = paste it as it is: one word, or text that is
2290
+ // query syntax (a recognised filter or a field-scoped term), or text whose
2291
+ // words are not split by single spaces: a phrase is a plain substring test, so
2292
+ // a line break, a tab or a double space inside it would stop it matching the
2293
+ // very clip it was copied from (pasted raw, its words are AND-ed instead).
2294
+ function quotePastedText(text) {
2295
+ const collapsed = String(text || '').trim();
2296
+ if (!/\s/.test(collapsed) || /[^ \S]| {2}/.test(collapsed)) return null;
2297
+ const p = parseQuery(collapsed);
2298
+ if (anyFilterActive(p) || p.sort || p.compound.length || p.content.some((c) => c.scope !== 'any' || c.regex || c.neg)) return null;
2299
+ return `"${collapsed.replace(/"/g, '')}"`;
2300
+ }
2301
+ // Is position `at` inside an open quote? Asked of the scanner's term there
2302
+ // (a plain term with an odd number of quotes before `at`), so a quote inside
2303
+ // a /regex/ or an earlier term never counts.
2304
+ function insideQuote(text, at) {
2305
+ const s = String(text || '');
2306
+ const tok = termAt(s, at);
2307
+ if (!tok || tok.regex) return false;
2308
+ let n = 0;
2309
+ for (let i = tok.start; i < at; i += 1) if (s[i] === '"') n += 1;
2310
+ return n % 2 === 1;
2311
+ }
2312
+
2313
+ return {
2314
+ clipToDoc, docSearchText, normalizeTagName, tagMatchesFilter, docInGroup,
2315
+ tokenizeQuery, quoteToken, parseQuery, scanQuery, serializeQuery, applyFacet, stripFacet, clearFacet, facetState, facetTokenState,
2316
+ anyFilterActive, hasSearchTerms, resolveTimeMs,
2317
+ matchDoc, relevanceScore, recencyScore, decayWeight, groupWeights, compareGroupUse, rankMode, filterRankIndexes, bodyIndexOf,
2318
+ compileTerm, escapeRegExp, termSpans, firstMatchIndex,
2319
+ regexTypingEdit, regexBackspaceEdit, toggleRegexAt, legacyRegexQuery, dimConnective, setDimConnective, normalizeJoins,
2320
+ promptOptionState, promptOptionLabel, FIELD_EXAMPLE,
2321
+ fuzzyMatch, fuzzyFloor,
2322
+ lexQuery, suggestQuery,
2323
+ BUILTIN_TO_IS, IS_TO_BUILTIN, IS_VALUES, RECOGNIZED_PREFIXES, NON_FILTER_SCHEMES,
2324
+ PREFIX_HINTS, OPTION_FACETS, OPTION_FIELDS, SYNTAX_NOTES, facetTokenText, optionFacetsActive,
2325
+ FIELD_INFO, facetKey, applySuggestion, ghostCompletion, uniqueCompletion,
2326
+ validateQuery, describeProblem, problemRanges, levenshtein,
2327
+ facetCensus, facetOptionVerdict, structuralReason, censusKey, FAILURE_CAP,
2328
+ bestRelaxation, quotePastedText, insideQuote,
2329
+ };
2330
+ });