@tobq/boardclip 0.0.0-stage → 2.1.107
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +170 -2
- package/assets/boardclip-icon.png +0 -0
- package/bin/boardclip.js +43 -0
- package/editor-preload.js +71 -0
- package/editor.html +220 -0
- package/icon.png +0 -0
- package/icon@2x.png +0 -0
- package/iconTemplate.png +0 -0
- package/iconTemplate@2x.png +0 -0
- package/index.html +1339 -0
- package/lib/appearance.js +183 -0
- package/lib/auto-update.js +306 -0
- package/lib/backup-worker.js +46 -0
- package/lib/backup.js +275 -0
- package/lib/blob-field-store.js +275 -0
- package/lib/blob-store.js +68 -0
- package/lib/build-info.js +89 -0
- package/lib/clip-blob-store.js +161 -0
- package/lib/clip-revision.js +81 -0
- package/lib/clipboard-capture.js +95 -0
- package/lib/clipboard-follow.js +98 -0
- package/lib/clipboard-model.js +1235 -0
- package/lib/cloud-accounts.js +250 -0
- package/lib/conflict-model.js +186 -0
- package/lib/control-client.js +87 -0
- package/lib/control-server.js +149 -0
- package/lib/diagnostics.js +121 -0
- package/lib/drag-files.js +48 -0
- package/lib/ensure-directory.js +56 -0
- package/lib/fork-names.js +35 -0
- package/lib/fs-probe.js +132 -0
- package/lib/history-feed.js +48 -0
- package/lib/hmac-auth.js +78 -0
- package/lib/macos-hotkey.js +297 -0
- package/lib/macos-paste.js +112 -0
- package/lib/mcp-core.js +270 -0
- package/lib/mcp-installers.js +284 -0
- package/lib/mcp-paths.js +99 -0
- package/lib/p2p-crypto.js +54 -0
- package/lib/p2p-discovery.js +191 -0
- package/lib/quick-paste.js +187 -0
- package/lib/retention.js +43 -0
- package/lib/sync-delta.js +227 -0
- package/lib/sync-journal.js +181 -0
- package/lib/sync-paths.js +73 -0
- package/lib/tailscale.js +80 -0
- package/lib/text-blob-store.js +38 -0
- package/lib/windows-clipboard.js +213 -0
- package/lib/windows-console.js +63 -0
- package/lib/windows-dwm.js +183 -0
- package/lib/windows-hook-worker.js +236 -0
- package/lib/windows-hook.js +91 -0
- package/lib/windows-paste.js +185 -0
- package/main.js +7540 -0
- package/mcp/boardclip-mcp.js +255 -0
- package/mcp-approval-preload.js +22 -0
- package/mcp-approval.html +218 -0
- package/package.json +55 -4
- package/preload.js +106 -0
- package/site/index.html +1624 -0
- package/site/shared/clip-search.js +2330 -0
- package/site/shared/clipboard-popup.css +1046 -0
- package/site/shared/clipboard-tokens.css +326 -0
- package/site/shared/clipboard-ui-core.js +6921 -0
- package/site/shared/clipboard-window.css +36 -0
- package/site/shared/vendor/cm5/README.md +52 -0
- package/site/shared/vendor/cm5/codemirror.css +344 -0
- package/site/shared/vendor/cm5/codemirror.js +9884 -0
- package/site/shared/vendor/cm5/diff-match-patch.js +2228 -0
- package/site/shared/vendor/cm5/merge.css +119 -0
- package/site/shared/vendor/cm5/merge.js +1182 -0
- package/site/shared/vendor/fonts/README.md +19 -0
- package/site/shared/vendor/fonts/material-symbols-rounded.woff2 +0 -0
- package/viewer-preload.js +51 -0
- 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
|
+
});
|