@tobq/boardclip 2.1.113 → 2.1.114
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/README.md +5 -2
- package/editor-preload.js +2 -0
- package/editor.html +12 -0
- package/index.html +94 -48
- package/lib/clipboard-model.js +588 -23
- package/lib/mcp-core.js +240 -69
- package/lib/sync-delta.js +5 -2
- package/lib/windows-hook-policy.js +127 -0
- package/lib/windows-hook-worker.js +18 -77
- package/lib/windows-hook.js +20 -9
- package/main.js +812 -188
- package/mcp/boardclip-mcp.js +68 -39
- package/mcp-approval.html +4 -2
- package/package.json +1 -1
- package/preload.js +4 -2
- package/site/index.html +108 -37
- package/site/shared/clip-search.js +291 -134
- package/site/shared/clipboard-popup.css +8 -20
- package/site/shared/clipboard-ui-core.js +405 -329
- package/viewer.html +1 -1
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
// `x a OR b` = x AND (a OR b)); `or` is a plain word
|
|
22
22
|
// ( ... ) -( ... ) grouping at any depth; `-` negates a term or a group
|
|
23
23
|
// title:VALUE text:VALUE field-scoped content (text:/body: = the clip body only)
|
|
24
|
-
// group:NAME g:NAME group membership
|
|
24
|
+
// group:NAME g:NAME tag (group) membership: flat, case-insensitive (a '/' is
|
|
25
|
+
// part of the name); several side by side = AND
|
|
25
26
|
// is:pinned is:image is:text is:numpad boolean facets
|
|
26
27
|
// num:N numpad slot 1-9 (alias for is:numpad + that slot)
|
|
27
28
|
// since:SPEC before:SPEC time bounds ("24h" | "7d" | "2026-01-01" | ISO)
|
|
@@ -105,17 +106,109 @@
|
|
|
105
106
|
return [doc.title, doc.type === 'image' ? 'image' : doc.body, doc.type, doc.groups.join(' ')].join(' ');
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
// ──
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
// ── tags (groups): ONE rule for every surface (the model, sync, MCP, the UI, the demo).
|
|
110
|
+
// Tags are flat and case-insensitive: "Forge" and "forge" are one tag, which has ONE
|
|
111
|
+
// stored spelling. tagKey is the identity; a '/' is an ordinary character. Commas
|
|
112
|
+
// separate several tags typed (or sent by an AI tool) in one go: splitTagInput.
|
|
113
|
+
// Groups nested with '/' before 14 Oct 2026 were split once into their parts; those
|
|
114
|
+
// exact retired names stay mapped to the parts (settings.group_splits, applied by
|
|
115
|
+
// expandTagNames), so an old habit or an old device still lands flat. ──
|
|
116
|
+
function cleanTagName(name) { return String(name == null ? '' : name).trim(); }
|
|
117
|
+
function tagKey(name) { return cleanTagName(name).toLowerCase(); }
|
|
118
|
+
function sameTag(a, b) { const k = tagKey(a); return !!k && k === tagKey(b); }
|
|
119
|
+
// The names typed (or sent) in one go: comma-separated, trimmed, one per tag.
|
|
120
|
+
function splitTagInput(text) {
|
|
121
|
+
const seen = new Set();
|
|
122
|
+
const out = [];
|
|
123
|
+
for (const part of String(text == null ? '' : text).split(',')) {
|
|
124
|
+
const name = cleanTagName(part);
|
|
125
|
+
const k = name.toLowerCase();
|
|
126
|
+
if (!name || seen.has(k)) continue;
|
|
127
|
+
seen.add(k);
|
|
128
|
+
out.push(name);
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
// How a nested name ('todo/forge') splits into its parts: the 14 Oct cutover, its
|
|
133
|
+
// retired-name record, and validateQuery's fix for a query written the old way.
|
|
134
|
+
// A '/' in a name made after the cutover is literal.
|
|
135
|
+
function nestedNameParts(name) {
|
|
136
|
+
return String(name == null ? '' : name).split('/').map((p) => p.trim()).filter(Boolean);
|
|
137
|
+
}
|
|
138
|
+
// Two spellings of one tag (a sync of copies made apart): the one with the fewest
|
|
139
|
+
// capitals, then code-point order - the same answer on every device.
|
|
140
|
+
function capitalCount(s) { let n = 0; for (const ch of s) if (ch !== ch.toLowerCase()) n += 1; return n; }
|
|
141
|
+
function preferredSpelling(a, b) {
|
|
142
|
+
if (!a) return b;
|
|
143
|
+
if (!b) return a;
|
|
144
|
+
const ca = capitalCount(a);
|
|
145
|
+
const cb = capitalCount(b);
|
|
146
|
+
if (ca !== cb) return ca < cb ? a : b;
|
|
147
|
+
return a <= b ? a : b;
|
|
148
|
+
}
|
|
149
|
+
// key -> stored spelling over every name in `lists` (arrays of names).
|
|
150
|
+
function tagSpellings(lists) {
|
|
151
|
+
const map = new Map();
|
|
152
|
+
for (const list of lists || []) {
|
|
153
|
+
for (const raw of list || []) {
|
|
154
|
+
const name = cleanTagName(raw);
|
|
155
|
+
if (!name) continue;
|
|
156
|
+
const k = name.toLowerCase();
|
|
157
|
+
map.set(k, preferredSpelling(map.get(k), name));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return map;
|
|
161
|
+
}
|
|
162
|
+
// A list of tag names in its stored form: retired nested names replaced by their
|
|
163
|
+
// parts (splits: Map key -> parts), each name in its stored spelling (spellings: Map
|
|
164
|
+
// key -> name), one entry per tag, first occurrence first. Pure: returns `names`
|
|
165
|
+
// itself when nothing changes, so an untouched clip keeps its exact JSON.
|
|
166
|
+
function expandTagNames(names, splits, spellings) {
|
|
167
|
+
const list = Array.isArray(names) ? names : [];
|
|
168
|
+
const out = [];
|
|
169
|
+
const seen = new Set();
|
|
170
|
+
let changed = false;
|
|
171
|
+
for (const raw of list) {
|
|
172
|
+
const name = typeof raw === 'string' ? raw : cleanTagName(raw);
|
|
173
|
+
const parts = splits && splits.size && splits.has(tagKey(name)) ? splits.get(tagKey(name)) : null;
|
|
174
|
+
if (parts) changed = true;
|
|
175
|
+
for (const part of parts || [name]) {
|
|
176
|
+
const clean = cleanTagName(part);
|
|
177
|
+
if (clean !== part) changed = true;
|
|
178
|
+
const k = clean.toLowerCase();
|
|
179
|
+
if (!clean || seen.has(k)) { changed = true; continue; }
|
|
180
|
+
seen.add(k);
|
|
181
|
+
const stored = (spellings && spellings.get(k)) || clean;
|
|
182
|
+
if (stored !== part) changed = true;
|
|
183
|
+
out.push(stored);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return changed ? out : list;
|
|
111
187
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
188
|
+
// The tags typed or sent in one go (New group, a picker, an AI tool's group param), in
|
|
189
|
+
// stored form: an existing tag named in full is that tag (even with a comma in its
|
|
190
|
+
// name); else the comma-separated names, a retired nested name as its parts, each in
|
|
191
|
+
// its stored spelling. groups = the tag list. The app (clipboardModel.tagInputNames,
|
|
192
|
+
// which adds the AI tag's fixed spelling) and the demo both read input through it.
|
|
193
|
+
function inputTagNames(input, groups, splits, spellings) {
|
|
194
|
+
if (typeof input === 'string') {
|
|
195
|
+
const whole = (groups || []).find((g) => sameTag(g, input));
|
|
196
|
+
if (whole) return [whole];
|
|
197
|
+
}
|
|
198
|
+
const typed = Array.isArray(input) ? input.flatMap((v) => splitTagInput(v)) : splitTagInput(input);
|
|
199
|
+
return expandTagNames(typed, splits, spellings);
|
|
200
|
+
}
|
|
201
|
+
// The tag toggle (a row's picker, an AI tool's assign_group): several tags at once are
|
|
202
|
+
// ONE decision. The missing ones are added; a clip that has them all loses them all.
|
|
203
|
+
function tagTogglePlan(clipTags, names) {
|
|
204
|
+
const missing = names.filter((name) => !(clipTags || []).some((g) => sameTag(g, name)));
|
|
205
|
+
return missing.length ? { add: missing, remove: [] } : { add: [], remove: names };
|
|
116
206
|
}
|
|
117
207
|
function docInGroup(doc, filter) {
|
|
118
|
-
|
|
208
|
+
const k = tagKey(filter);
|
|
209
|
+
if (!k) return false;
|
|
210
|
+
for (const g of doc.groups) if (tagKey(g) === k) return true;
|
|
211
|
+
return false;
|
|
119
212
|
}
|
|
120
213
|
|
|
121
214
|
// ── ONE recency decay (usage x recency, "frecency"): a weight that falls off
|
|
@@ -125,8 +218,8 @@
|
|
|
125
218
|
return Math.exp(-Math.max(0, ageMs) / scaleMs);
|
|
126
219
|
}
|
|
127
220
|
// 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
|
|
129
|
-
//
|
|
221
|
+
// was copied or used (ts moves on every paste), on a two-week scale, keyed by
|
|
222
|
+
// tagKey (tags are flat and case-insensitive). ONE score orders the chip
|
|
130
223
|
// row, the group pickers and the group autocomplete, and nudges the ranking
|
|
131
224
|
// (clips of the groups you use rank a little higher). list: clips or search
|
|
132
225
|
// docs (both carry ts in seconds); kept per list and per hour. The map's
|
|
@@ -147,13 +240,10 @@
|
|
|
147
240
|
const w = decayWeight(nowMs - (Number(x.ts) || 0) * 1000, GROUP_USAGE_SCALE_MS);
|
|
148
241
|
const seen = new Set();
|
|
149
242
|
for (const g of gs) {
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
seen.add(path);
|
|
155
|
-
weights.set(path, (weights.get(path) || 0) + w);
|
|
156
|
-
}
|
|
243
|
+
const k = tagKey(g);
|
|
244
|
+
if (!k || seen.has(k)) continue;
|
|
245
|
+
seen.add(k);
|
|
246
|
+
weights.set(k, (weights.get(k) || 0) + w);
|
|
157
247
|
}
|
|
158
248
|
}
|
|
159
249
|
let max = 0;
|
|
@@ -162,9 +252,9 @@
|
|
|
162
252
|
if (groupWeightCache) groupWeightCache.set(arr, { hour, weights });
|
|
163
253
|
return weights;
|
|
164
254
|
}
|
|
165
|
-
// Order
|
|
255
|
+
// Order tags by use (groupWeights), then by name.
|
|
166
256
|
function compareGroupUse(weights) {
|
|
167
|
-
return (a, b) => ((weights && (weights.get(
|
|
257
|
+
return (a, b) => ((weights && (weights.get(tagKey(b)) || 0)) - (weights && (weights.get(tagKey(a)) || 0)))
|
|
168
258
|
|| a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true });
|
|
169
259
|
}
|
|
170
260
|
|
|
@@ -377,7 +467,7 @@
|
|
|
377
467
|
if ((m[2][0] === '/' || m[2][0] === '\\') && !RECOGNIZED_PREFIXES.has(rawKey)) return content(plain);
|
|
378
468
|
if (key === 'title') return content(val, 'title');
|
|
379
469
|
if (key === 'text') return content(val, 'body');
|
|
380
|
-
if (key === 'group') return { field: 'group', value:
|
|
470
|
+
if (key === 'group') return { field: 'group', value: cleanTagName(val), neg };
|
|
381
471
|
if (key === 'is') { const v = val.toLowerCase(); if (IS_VALUES.includes(v)) return { field: 'is', value: v, neg }; }
|
|
382
472
|
if (key === 'num') { const n = parseInt(val, 10); if (n >= 1 && n <= 9 && /^\d$/.test(val)) return { field: 'num', value: n, neg }; }
|
|
383
473
|
if (key === 'since' || key === 'before' || key === 'id') return { field: key, value: val, neg };
|
|
@@ -417,7 +507,8 @@
|
|
|
417
507
|
// Which OR dimension a filter value belongs to: every group is one, the
|
|
418
508
|
// kinds of clip (image, text, link, multi-line, rich) are one, numpad keys
|
|
419
509
|
// are one; pinned and "on a key" are each their own (pinned OR image is not
|
|
420
|
-
// one question). A second chip of
|
|
510
|
+
// one question). A second chip of a dimension joins the first the dimension's
|
|
511
|
+
// way (dimJoin: tags AND, kinds and numpad keys OR, unless remembered).
|
|
421
512
|
const TYPE_IS = new Set(['image', 'text', 'url', 'multiline', 'rich']);
|
|
422
513
|
const OR_DIMS = new Set(['group', 'type', 'num']);
|
|
423
514
|
function facetDim(field, value) {
|
|
@@ -425,6 +516,10 @@
|
|
|
425
516
|
if (field === 'is') return TYPE_IS.has(value) ? 'type' : value;
|
|
426
517
|
return null;
|
|
427
518
|
}
|
|
519
|
+
// One filter value is the same as another: a tag in any case (sameTag),
|
|
520
|
+
// anything else exactly. valueKey: the key it is counted and compared by.
|
|
521
|
+
function sameValue(field, a, b) { return field === 'group' ? sameTag(a, b) : a === b; }
|
|
522
|
+
function valueKey(field, value) { return field === 'group' ? tagKey(value) : value; }
|
|
428
523
|
// A top-level OR whose sides are all positive values of ONE OR dimension
|
|
429
524
|
// (group:A OR group:B): an anyOf (the chip row's or / and). null for any other OR.
|
|
430
525
|
function anyOfGroup(node) {
|
|
@@ -437,7 +532,7 @@
|
|
|
437
532
|
if (!d || !OR_DIMS.has(d) || (dim && d !== dim)) return null;
|
|
438
533
|
dim = d;
|
|
439
534
|
field = k.leaf.field;
|
|
440
|
-
if (!members.some((x) => x.value
|
|
535
|
+
if (!members.some((x) => sameValue(field, x.value, k.leaf.value))) members.push({ value: k.leaf.value, text: k.text, start: k.start, end: k.end });
|
|
441
536
|
}
|
|
442
537
|
return { dim, field, values: members.map((x) => x.value), members, start: node.start, end: node.end };
|
|
443
538
|
}
|
|
@@ -639,16 +734,19 @@
|
|
|
639
734
|
// already there (FACET_ORDER + value order, so chips clicked in any order
|
|
640
735
|
// write the same text), and everything else - the words, their quotes, an
|
|
641
736
|
// unknown prefix, the order the user typed, a custom (a OR b) - stays exactly
|
|
642
|
-
// as typed. A value
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
//
|
|
737
|
+
// as typed. A second value of a dimension joins the first the dimension's
|
|
738
|
+
// way (dimJoin): a second tag goes in beside it (group:A group:B, AND), a
|
|
739
|
+
// second kind of clip or numpad key with OR (is:text OR is:url), unless the
|
|
740
|
+
// device remembered the other join for that kind (opts.joins); an existing
|
|
741
|
+
// OR or two values side by side take a third the way they are written. A
|
|
742
|
+
// tag is matched in any case, so a chip never adds one the query already
|
|
743
|
+
// names in another spelling. Only the top level is edited: a value inside a
|
|
744
|
+
// custom expression is left alone (its chip is greyed).
|
|
647
745
|
const SINGLE_FACETS = ['since', 'before', 'len', 'lines', 'words', 'id'];
|
|
648
746
|
const BOUND_FACETS = ['len', 'lines', 'words'];
|
|
649
747
|
// A chip token -> { field, value } (builtin chip ids are is: kinds).
|
|
650
748
|
function tokenFieldValue(token) {
|
|
651
|
-
if (token.kind === 'group') return { field: 'group', value:
|
|
749
|
+
if (token.kind === 'group') return { field: 'group', value: cleanTagName(token.value) };
|
|
652
750
|
if (token.kind === 'num') return { field: 'num', value: Number(token.value) };
|
|
653
751
|
return { field: 'is', value: BUILTIN_TO_IS[token.value] || token.value };
|
|
654
752
|
}
|
|
@@ -704,8 +802,9 @@
|
|
|
704
802
|
}
|
|
705
803
|
// opts.joins: how a second value of a kind joins the first ({ group, type }:
|
|
706
804
|
// 'or' | 'and', JOIN_DIMS; the device remembers the last or / and the user
|
|
707
|
-
// picked, normalizeJoins
|
|
708
|
-
//
|
|
805
|
+
// picked, normalizeJoins; dimJoin = the default where it remembers nothing).
|
|
806
|
+
// A value joins an existing OR or AND the way it is written; the join only
|
|
807
|
+
// decides when one value is there.
|
|
709
808
|
function applyFacet(query, token, intent, opts) {
|
|
710
809
|
const joins = normalizeJoins(opts && opts.joins);
|
|
711
810
|
const text = String(query == null ? '' : query);
|
|
@@ -722,33 +821,15 @@
|
|
|
722
821
|
const { field, value } = tokenFieldValue(token);
|
|
723
822
|
const part = facetPart(field, value);
|
|
724
823
|
const dim = facetDim(field, value);
|
|
725
|
-
const
|
|
824
|
+
const same = (v) => sameValue(field, v, value);
|
|
825
|
+
const isLeaf = (item, neg) => item.kind === 'leaf' && item.leaf.field === field && !!item.leaf.neg === neg && same(item.leaf.value);
|
|
726
826
|
const pos = p.items.filter((item) => isLeaf(item, false));
|
|
727
827
|
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.
|
|
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
|
-
}
|
|
828
|
+
const holding = p.items.filter((item) => item.kind === 'any' && item.any.field === field && item.any.values.some(same));
|
|
748
829
|
return editFacets(text, p, ({ remove, rewriteAny, insertPart, edits }) => {
|
|
749
830
|
const dropValue = () => {
|
|
750
831
|
pos.forEach(remove);
|
|
751
|
-
for (const item of holding) rewriteAny(item, item.any.members.filter((m) => m.value
|
|
832
|
+
for (const item of holding) rewriteAny(item, item.any.members.filter((m) => !same(m.value)));
|
|
752
833
|
};
|
|
753
834
|
if (intent === 'exclude') {
|
|
754
835
|
if (negs.length) negs.forEach(remove); // already excluded -> clear
|
|
@@ -757,13 +838,14 @@
|
|
|
757
838
|
}
|
|
758
839
|
if (pos.length || holding.length) { dropValue(); return; } // already included -> clear
|
|
759
840
|
if (negs.length) { negs.forEach(remove); return; } // excluded -> clear
|
|
760
|
-
// Add: OR into the dimension's OR, or onto its one value
|
|
761
|
-
//
|
|
841
|
+
// Add: OR into the dimension's OR, or onto its one value when the
|
|
842
|
+
// dimension joins with OR, at its value order (chips clicked in any
|
|
843
|
+
// order write the same text); else a token beside the others (AND).
|
|
762
844
|
if (OR_DIMS.has(dim)) {
|
|
763
845
|
const order = field === 'group' ? byName : field === 'num' ? byNum : byIs;
|
|
764
846
|
const anyItem = p.items.find((item) => item.kind === 'any' && item.any.dim === dim);
|
|
765
847
|
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
|
|
848
|
+
const members = anyItem ? anyItem.any.members : ones.length === 1 && dimJoin(joins, dim) === 'or' ? [{ value: ones[0].leaf.value, start: ones[0].start, end: ones[0].end }] : null;
|
|
767
849
|
if (members) {
|
|
768
850
|
const next = members.find((m) => order(m.value, value) > 0);
|
|
769
851
|
if (next) edits.push({ start: next.start, end: next.start, text: `${part} OR ` });
|
|
@@ -775,15 +857,16 @@
|
|
|
775
857
|
});
|
|
776
858
|
}
|
|
777
859
|
// Remove every top-level token of one filter value (both signs, from an OR
|
|
778
|
-
// too): a deleted group leaves no group: behind.
|
|
860
|
+
// too, a tag in any case): a deleted group leaves no group: behind.
|
|
779
861
|
function stripFacet(query, token) {
|
|
780
862
|
const text = String(query == null ? '' : query);
|
|
781
863
|
const p = parseQuery(text);
|
|
782
864
|
const { field, value } = tokenFieldValue(token);
|
|
865
|
+
const same = (v) => sameValue(field, v, value);
|
|
783
866
|
return editFacets(text, p, ({ remove, rewriteAny }) => {
|
|
784
867
|
for (const item of p.items) {
|
|
785
|
-
if (item.kind === 'leaf' && item.leaf.field === field && item.leaf.value
|
|
786
|
-
else if (item.kind === 'any' && item.any.field === field && item.any.values.
|
|
868
|
+
if (item.kind === 'leaf' && item.leaf.field === field && same(item.leaf.value)) remove(item);
|
|
869
|
+
else if (item.kind === 'any' && item.any.field === field && item.any.values.some(same)) rewriteAny(item, item.any.members.filter((m) => !same(m.value)));
|
|
787
870
|
}
|
|
788
871
|
});
|
|
789
872
|
}
|
|
@@ -806,10 +889,11 @@
|
|
|
806
889
|
return String(cur).toLowerCase() === String(token.value).toLowerCase() ? 'include' : null;
|
|
807
890
|
}
|
|
808
891
|
const { field, value } = tokenFieldValue(token);
|
|
892
|
+
const same = (v) => sameValue(field, v, value);
|
|
809
893
|
const inc = field === 'group' ? parsed.groups : field === 'num' ? parsed.nums : parsed.is;
|
|
810
894
|
const ex = field === 'group' ? parsed.negGroups : field === 'num' ? parsed.negNums : parsed.negIs;
|
|
811
|
-
if (inc.
|
|
812
|
-
return ex.
|
|
895
|
+
if (inc.some(same) || (parsed.anyOf || []).some((g) => g.field === field && g.values.some(same))) return 'include';
|
|
896
|
+
return ex.some(same) ? 'exclude' : null;
|
|
813
897
|
}
|
|
814
898
|
|
|
815
899
|
// Every filter value named inside a custom expression (a OR b, -(a b)): its
|
|
@@ -817,7 +901,7 @@
|
|
|
817
901
|
function nodeFacetKeys(node, out) {
|
|
818
902
|
if (node.type === 'leaf') {
|
|
819
903
|
const l = node.leaf;
|
|
820
|
-
if (l.field === 'group') out.add(`group:${l.value}`);
|
|
904
|
+
if (l.field === 'group') out.add(`group:${tagKey(l.value)}`);
|
|
821
905
|
else if (l.field === 'is') out.add(`is:${l.value}`);
|
|
822
906
|
else if (l.field === 'num') out.add(`num:${l.value}`);
|
|
823
907
|
else if (l.field && l.field !== 'content') out.add(`${l.field}:`);
|
|
@@ -833,15 +917,20 @@
|
|
|
833
917
|
}
|
|
834
918
|
|
|
835
919
|
// Chip active/excluded state for the filter bar, derived straight from the query.
|
|
836
|
-
|
|
920
|
+
// groups (optional): the stored tag names; a tag the query names in another
|
|
921
|
+
// case (group:work for Work) is reported in its stored spelling, the name its
|
|
922
|
+
// chip carries.
|
|
923
|
+
function facetState(parsed, groups) {
|
|
837
924
|
const active = new Set();
|
|
838
925
|
const excluded = new Set();
|
|
839
|
-
|
|
840
|
-
|
|
926
|
+
const stored = tagSpellings([groups]);
|
|
927
|
+
const name = (g) => stored.get(tagKey(g)) || g;
|
|
928
|
+
for (const g of parsed.groups) active.add(name(g));
|
|
929
|
+
for (const g of parsed.negGroups) excluded.add(name(g));
|
|
841
930
|
for (const v of parsed.is) if (IS_TO_BUILTIN[v]) active.add(IS_TO_BUILTIN[v]);
|
|
842
931
|
for (const v of parsed.negIs) if (IS_TO_BUILTIN[v]) excluded.add(IS_TO_BUILTIN[v]);
|
|
843
932
|
for (const g of parsed.anyOf || []) for (const v of g.values) {
|
|
844
|
-
if (g.field === 'group') active.add(v);
|
|
933
|
+
if (g.field === 'group') active.add(name(v));
|
|
845
934
|
else if (g.field === 'is' && IS_TO_BUILTIN[v]) active.add(IS_TO_BUILTIN[v]);
|
|
846
935
|
}
|
|
847
936
|
if (parsed.nums.length || parsed.is.includes('numpad') || (parsed.anyOf || []).some((g) => g.field === 'num')) active.add('__numbered__');
|
|
@@ -1143,7 +1232,7 @@
|
|
|
1143
1232
|
const use = o.groupUse;
|
|
1144
1233
|
if (use && use.max > 0 && doc.groups.length) {
|
|
1145
1234
|
let best = 0;
|
|
1146
|
-
for (const g of doc.groups) { const w = use.get(g) || 0; if (w > best) best = w; }
|
|
1235
|
+
for (const g of doc.groups) { const w = use.get(tagKey(g)) || 0; if (w > best) best = w; }
|
|
1147
1236
|
score += GROUP_USE_WEIGHT * (best / use.max);
|
|
1148
1237
|
}
|
|
1149
1238
|
return score;
|
|
@@ -1410,7 +1499,7 @@
|
|
|
1410
1499
|
const FIELD_INFO = {
|
|
1411
1500
|
title: { desc: 'match the clip title only', short: 't' },
|
|
1412
1501
|
text: { desc: 'match the clip body only', short: 'b' },
|
|
1413
|
-
group: { desc: 'in a group
|
|
1502
|
+
group: { desc: 'in a group', short: 'g' },
|
|
1414
1503
|
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' } },
|
|
1415
1504
|
num: { desc: 'on numpad key 1-9', short: 'n' },
|
|
1416
1505
|
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' } },
|
|
@@ -1496,7 +1585,7 @@
|
|
|
1496
1585
|
{ code: '-word', text: 'excludes' },
|
|
1497
1586
|
{ code: '"a phrase"', text: 'exact' },
|
|
1498
1587
|
{ code: 'a OR b', text: 'either' },
|
|
1499
|
-
{ code: '(a b)', text: '
|
|
1588
|
+
{ code: '(a b)', text: 'brackets' },
|
|
1500
1589
|
{ code: '/regex/', text: 'a pattern' },
|
|
1501
1590
|
];
|
|
1502
1591
|
// The query text a facet token stands for (its chip tooltip).
|
|
@@ -1542,11 +1631,12 @@
|
|
|
1542
1631
|
if (canon === 'num') return /^[1-9]$/.test(v);
|
|
1543
1632
|
if (canon === 'sort') return ['new', 'best', 'recent', 'relevance'].includes(v);
|
|
1544
1633
|
if (canon === 'since' || canon === 'before') return isValidTimeSpec(v) && !isPendingTimeSpec(v);
|
|
1545
|
-
//
|
|
1546
|
-
|
|
1547
|
-
if (canon === 'group') { const n = normalizeTagName(val); return (groups || []).some((g) => normalizeTagName(g) === n); }
|
|
1634
|
+
// A tag matches in any case (sameTag): group:work is complete when the tag is Work.
|
|
1635
|
+
if (canon === 'group') return (groups || []).some((g) => sameTag(g, val));
|
|
1548
1636
|
return false;
|
|
1549
1637
|
}
|
|
1638
|
+
// The stored tag names, each once (one per tag key, in its stored spelling).
|
|
1639
|
+
function tagNames(groups) { return [...tagSpellings([groups]).values()]; }
|
|
1550
1640
|
function tokenAtCaret(text, caret) {
|
|
1551
1641
|
const s = String(text || '');
|
|
1552
1642
|
const at = Math.max(0, Math.min(caret == null ? s.length : caret, s.length));
|
|
@@ -1587,7 +1677,7 @@
|
|
|
1587
1677
|
const hintOf = (v) => (info.values && info.values[v]) || '';
|
|
1588
1678
|
if (key === 'group') {
|
|
1589
1679
|
// Prefix matches first, then names containing the fragment.
|
|
1590
|
-
const names = (o.groups
|
|
1680
|
+
const names = tagNames(o.groups).filter((g) => !val || g.toLowerCase().includes(val));
|
|
1591
1681
|
const byUse = compareGroupUse(o.groupWeights);
|
|
1592
1682
|
names.sort((a, b) => (b.toLowerCase().startsWith(val) - a.toLowerCase().startsWith(val)) || byUse(a, b));
|
|
1593
1683
|
for (const g of names) push(`${cm[1]}:${quoteToken(g)}`, '');
|
|
@@ -1617,15 +1707,13 @@
|
|
|
1617
1707
|
};
|
|
1618
1708
|
for (const canon of Object.keys(FIELD_INFO)) if (canon.startsWith(lower) && `${canon}:` !== lower) offer(canon);
|
|
1619
1709
|
for (const [alias, canon] of Object.entries(PREFIX_ALIASES)) if (FIELD_INFO[canon] && alias.startsWith(lower)) offer(canon);
|
|
1620
|
-
for (const g of (o.groups
|
|
1710
|
+
for (const g of tagNames(o.groups)) if (g.toLowerCase().startsWith(lower)) push(`group:${quoteToken(g)}`, '');
|
|
1621
1711
|
// OR between two terms: offered once a term stands before this one.
|
|
1622
1712
|
if (!neg && (lower === 'o' || lower === 'or') && body !== 'OR' && /\S/.test(String(text || '').slice(0, tok.start))) push('OR', 'either side may match');
|
|
1623
1713
|
}
|
|
1624
1714
|
if (!out.length) return null;
|
|
1625
|
-
//
|
|
1626
|
-
|
|
1627
|
-
const caseFix = (s) => s.text.toLowerCase() === lowerTok;
|
|
1628
|
-
out.sort((a, b) => (caseFix(b) - caseFix(a)) || (b.continuation - a.continuation));
|
|
1715
|
+
// The best continuation leads (so the ghost and Tab agree with the top row).
|
|
1716
|
+
out.sort((a, b) => b.continuation - a.continuation);
|
|
1629
1717
|
return { kind, replaceStart: tok.start, replaceEnd: tok.end, query, quoted, suggestions: out.slice(0, 8) };
|
|
1630
1718
|
}
|
|
1631
1719
|
// The new text and caret after taking suggestion s of result res: a key
|
|
@@ -1719,6 +1807,16 @@
|
|
|
1719
1807
|
}
|
|
1720
1808
|
return undefined;
|
|
1721
1809
|
}
|
|
1810
|
+
// A nested name written as the flat tags it means, all required (ONE rule
|
|
1811
|
+
// for the hint's fix and an AI tool's rewrite): the key as typed ('group:',
|
|
1812
|
+
// 'g:'), group:todo group:forge; negated -(group:todo group:forge); a side
|
|
1813
|
+
// of an OR in brackets.
|
|
1814
|
+
function flatGroupTokens(key, parts, neg, inOr) {
|
|
1815
|
+
const quote = (v) => (/[\s()"]/.test(v) ? `"${String(v).replace(/"/g, '')}"` : String(v));
|
|
1816
|
+
const all = parts.map((p) => key + quote(p)).join(' ');
|
|
1817
|
+
if (parts.length === 1) return (neg ? '-' : '') + all;
|
|
1818
|
+
return neg ? `-(${all})` : inOr ? `(${all})` : all;
|
|
1819
|
+
}
|
|
1722
1820
|
// validateQuery(text, { groups, caret }). A problem may carry `fix`
|
|
1723
1821
|
// ({ start, end, text }: the one-click repair the hint offers) beside its
|
|
1724
1822
|
// `didYouMean` label.
|
|
@@ -1726,11 +1824,14 @@
|
|
|
1726
1824
|
const o = opts || {};
|
|
1727
1825
|
const s = String(text || '');
|
|
1728
1826
|
const caret = o.caret == null ? null : o.caret;
|
|
1729
|
-
//
|
|
1730
|
-
const
|
|
1827
|
+
// The tags a group: value can name (stored names, matched in any case).
|
|
1828
|
+
const groupNames = tagNames(o.groups);
|
|
1829
|
+
const tagOf = (v) => groupNames.find((g) => sameTag(g, v));
|
|
1731
1830
|
const problems = [];
|
|
1732
1831
|
const numsSeen = [];
|
|
1733
|
-
|
|
1832
|
+
const toks = scanQuery(s);
|
|
1833
|
+
for (let ti = 0; ti < toks.length; ti += 1) {
|
|
1834
|
+
const tok = toks[ti];
|
|
1734
1835
|
if (tok.t !== 'term') continue;
|
|
1735
1836
|
const neg = tok.neg ? 1 : 0;
|
|
1736
1837
|
const body = tok.text.slice(neg);
|
|
@@ -1776,19 +1877,32 @@
|
|
|
1776
1877
|
} else if (canon === 'len' || canon === 'lines' || canon === 'words') {
|
|
1777
1878
|
const pending = /^(>=|<=|>|<|=)?$/.test(value) || /^\d+\s*(-|\.\.?)$/.test(value);
|
|
1778
1879
|
if (!parseBound(value) && !pending) bad(`${key}:${value} is not a count. Use >N, <N, >=N, <=N, N or a range N-M.`);
|
|
1779
|
-
} else if (canon === 'group' &&
|
|
1780
|
-
//
|
|
1781
|
-
//
|
|
1782
|
-
//
|
|
1783
|
-
// is flagged with the
|
|
1784
|
-
|
|
1880
|
+
} else if (canon === 'group' && groupNames.length) {
|
|
1881
|
+
// Tags match in any case (sameTag: group:work finds Work), and a
|
|
1882
|
+
// prefix of one is fine while it is typed. A name with '/' that is no
|
|
1883
|
+
// tag but whose parts all are is the old nested habit (todo/forge):
|
|
1884
|
+
// tags are flat, so it is flagged with the tags it means, joined
|
|
1885
|
+
// (AND), bracketed where the token is negated or an OR operand.
|
|
1886
|
+
const n = cleanTagName(value);
|
|
1785
1887
|
const nl = n.toLowerCase();
|
|
1786
|
-
const
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1888
|
+
const typing = (v) => groupNames.some((g) => g.toLowerCase().startsWith(v));
|
|
1889
|
+
if (!n || tagOf(n) || typing(nl)) continue;
|
|
1890
|
+
const parts = n.includes('/') ? nestedNameParts(n) : [];
|
|
1891
|
+
const tags = parts.map(tagOf);
|
|
1892
|
+
if (parts.length && tags.slice(0, -1).every(Boolean)) {
|
|
1893
|
+
// Its last part is still being typed: todo/ or todo/fo (forge is a tag).
|
|
1894
|
+
if (!tags[tags.length - 1]) { if (typing(parts[parts.length - 1].toLowerCase())) continue; }
|
|
1895
|
+
else if (tags.length < 2) continue;
|
|
1896
|
+
}
|
|
1897
|
+
if (parts.length > 1 && tags.every(Boolean)) {
|
|
1898
|
+
const operand = (toks[ti - 1] && toks[ti - 1].t === 'or') || (toks[ti + 1] && toks[ti + 1].t === 'or');
|
|
1899
|
+
const fix = flatGroupTokens(`${m[1]}:`, tags, !!neg, operand);
|
|
1900
|
+
const names = tags.length > 2 ? `${tags.slice(0, -1).join(', ')} and ${tags[tags.length - 1]}` : tags.join(' and ');
|
|
1901
|
+
problems.push({ ...base, valueStart, key, value, kind: 'nested-group', message: `Groups are not nested: this is ${names}.`, didYouMean: fix, fix: { start: tok.start, end: tok.end, text: fix } });
|
|
1902
|
+
continue;
|
|
1791
1903
|
}
|
|
1904
|
+
const shown = closestTo(n, groupNames, 3);
|
|
1905
|
+
bad(`There is no group named "${value}".`, undefined, shown ? `${key}:${quoteToken(shown)}` : undefined);
|
|
1792
1906
|
}
|
|
1793
1907
|
}
|
|
1794
1908
|
// Two numpad keys side by side ask for a clip on both: it has one key.
|
|
@@ -1871,14 +1985,15 @@
|
|
|
1871
1985
|
return docHasIs(doc, v);
|
|
1872
1986
|
}
|
|
1873
1987
|
// The OR dimensions whose next value goes in with an OR (applyFacet's rule:
|
|
1874
|
-
// one value there
|
|
1875
|
-
// holds open for that
|
|
1988
|
+
// one value there while the dimension joins with "or" (dimJoin), or an OR
|
|
1989
|
+
// already): dim -> the check dim the census holds open for that
|
|
1990
|
+
// dimension's options ('any:group'). Under "and" (tags, by default) a lone
|
|
1991
|
+
// value is a plain filter, so a chip "and" would empty is greyed.
|
|
1876
1992
|
function openDims(parsed, joins) {
|
|
1877
1993
|
const open = new Map();
|
|
1878
|
-
const j = normalizeJoins(joins);
|
|
1879
1994
|
for (const dim of OR_DIMS) {
|
|
1880
1995
|
if ((parsed.anyOf || []).some((g) => g.dim === dim)) { open.set(dim, `any:${dim}`); continue; }
|
|
1881
|
-
if (
|
|
1996
|
+
if (dimJoin(joins, dim) === 'and') continue; // the next value would AND in: the lone value is a plain filter
|
|
1882
1997
|
const n = dim === 'group' ? parsed.groups.length : dim === 'num' ? parsed.nums.length : parsed.is.filter((v) => TYPE_IS.has(v)).length;
|
|
1883
1998
|
if (n === 1) open.set(dim, `any:${dim}`);
|
|
1884
1999
|
}
|
|
@@ -1895,8 +2010,8 @@
|
|
|
1895
2010
|
const open = openDims(parsed, joins);
|
|
1896
2011
|
const anyDims = new Set((parsed.anyOf || []).map((g) => g.dim));
|
|
1897
2012
|
const lone = (dim, own) => (open.has(dim) && !anyDims.has(dim) ? open.get(dim) : own);
|
|
1898
|
-
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']] });
|
|
1899
|
-
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']] });
|
|
2013
|
+
for (const g of parsed.groups) checks.push({ dim: lone('group', `group:${tagKey(g)}`), ok: (d) => docInGroup(d, g), label: `group:${quoteToken(g)}`, tokens: [[{ kind: 'group', value: g }, 'include']] });
|
|
2014
|
+
for (const g of parsed.negGroups) checks.push({ dim: `-group:${tagKey(g)}`, ok: (d) => !docInGroup(d, g), label: `-group:${quoteToken(g)}`, tokens: [[{ kind: 'group', value: g }, 'exclude']] });
|
|
1900
2015
|
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']] });
|
|
1901
2016
|
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']] });
|
|
1902
2017
|
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']] });
|
|
@@ -1923,15 +2038,18 @@
|
|
|
1923
2038
|
}
|
|
1924
2039
|
return out;
|
|
1925
2040
|
}
|
|
1926
|
-
// A chip's token -> its census key: 'is:image', 'group:
|
|
1927
|
-
// 'before:' (the prompt
|
|
2041
|
+
// A chip's token -> its census key: 'is:image', 'group:work' (a tag by its
|
|
2042
|
+
// key: one per tag, whatever the case), 'since:7d', 'before:' (the prompt
|
|
2043
|
+
// option), 'len:<80'.
|
|
1928
2044
|
function censusKey(token) {
|
|
1929
2045
|
const k = token.kind;
|
|
1930
|
-
if (k === 'group') return `group:${
|
|
2046
|
+
if (k === 'group') return `group:${tagKey(token.value)}`;
|
|
1931
2047
|
if (k === 'builtin' || k === 'is') return `is:${BUILTIN_TO_IS[token.value] || token.value}`;
|
|
1932
2048
|
if (BOUND_FACETS.includes(k)) return `${k}:${serializeBound(parseBound(String(token.value == null ? '' : token.value)))}`;
|
|
1933
2049
|
return `${k}:${token.value == null ? '' : String(token.value).toLowerCase()}`;
|
|
1934
2050
|
}
|
|
2051
|
+
// A census key with its tag by key ('group:Work' asks for 'group:work').
|
|
2052
|
+
function censusTagKey(key) { return key.startsWith('group:') ? `group:${tagKey(key.slice(6))}` : key; }
|
|
1935
2053
|
// The dim an option belongs to under this query (null = none held open):
|
|
1936
2054
|
// an excluded value its own, a value of an open OR dimension that
|
|
1937
2055
|
// dimension's (selecting it would OR it in), a selected value its own.
|
|
@@ -1939,17 +2057,18 @@
|
|
|
1939
2057
|
const k = token.kind;
|
|
1940
2058
|
if (SINGLE_FACETS.includes(k)) return k;
|
|
1941
2059
|
const { field, value } = tokenFieldValue(token);
|
|
2060
|
+
const same = (v) => sameValue(field, v, value);
|
|
1942
2061
|
const neg = field === 'group' ? parsed.negGroups : field === 'num' ? parsed.negNums : parsed.negIs;
|
|
1943
|
-
if (neg.
|
|
2062
|
+
if (neg.some(same)) return `-${field}:${valueKey(field, value)}`;
|
|
1944
2063
|
const open = openDims(parsed, joins);
|
|
1945
2064
|
const dim = facetDim(field, value);
|
|
1946
2065
|
if (open.has(dim)) return open.get(dim);
|
|
1947
2066
|
const pos = field === 'group' ? parsed.groups : field === 'num' ? parsed.nums : parsed.is;
|
|
1948
|
-
return pos.
|
|
2067
|
+
return pos.some(same) ? `${field}:${valueKey(field, value)}` : null;
|
|
1949
2068
|
}
|
|
1950
|
-
// facetCensus(docs, parsed, { now, groups, searchTextLower }) -> { permissive, total,
|
|
2069
|
+
// facetCensus(docs, parsed, { now, groups, searchTextLower, joins }) -> { permissive, total,
|
|
1951
2070
|
// count(key), present(key) }. Options counted: every is: value, every
|
|
1952
|
-
// OPTION_FACETS token, and every
|
|
2071
|
+
// OPTION_FACETS token, and every tag in `groups` (one per tag key, censusKey).
|
|
1953
2072
|
// `present` = the option exists somewhere in history, filters ignored (an
|
|
1954
2073
|
// absent kind is hidden, never greyed). No docs (history still loading) =
|
|
1955
2074
|
// permissive: nothing greys.
|
|
@@ -1977,15 +2096,10 @@
|
|
|
1977
2096
|
}
|
|
1978
2097
|
}
|
|
1979
2098
|
for (const o2 of options) o2.dim = optionDim(parsed, o2.token, o.joins);
|
|
1980
|
-
//
|
|
1981
|
-
//
|
|
1982
|
-
const groupKeys = new Set();
|
|
1983
|
-
for (const g of (o.groups || [])) {
|
|
1984
|
-
const parts = normalizeTagName(g).split('/').filter(Boolean);
|
|
1985
|
-
for (let i = 1; i <= parts.length; i += 1) groupKeys.add(parts.slice(0, i).join('/'));
|
|
1986
|
-
}
|
|
2099
|
+
// Tags: bumped from each doc's own tags (by key), not tested one by one
|
|
2100
|
+
// against every tag.
|
|
1987
2101
|
const groupDim = new Map();
|
|
1988
|
-
for (const g of
|
|
2102
|
+
for (const g of tagNames(o.groups)) groupDim.set(tagKey(g), optionDim(parsed, { kind: 'group', value: g }, o.joins));
|
|
1989
2103
|
const counts = new Map();
|
|
1990
2104
|
const present = new Map();
|
|
1991
2105
|
const bump = (m, key) => m.set(key, (m.get(key) || 0) + 1);
|
|
@@ -2007,14 +2121,11 @@
|
|
|
2007
2121
|
if (doc.groups.length) {
|
|
2008
2122
|
seen.clear();
|
|
2009
2123
|
for (const g of doc.groups) {
|
|
2010
|
-
const
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
bump(present, `group:${p}`);
|
|
2016
|
-
if (inBase || (only !== null && groupDim.get(p) === only)) bump(counts, `group:${p}`);
|
|
2017
|
-
}
|
|
2124
|
+
const k = tagKey(g);
|
|
2125
|
+
if (seen.has(k) || !groupDim.has(k)) continue;
|
|
2126
|
+
seen.add(k);
|
|
2127
|
+
bump(present, `group:${k}`);
|
|
2128
|
+
if (inBase || (only !== null && groupDim.get(k) === only)) bump(counts, `group:${k}`);
|
|
2018
2129
|
}
|
|
2019
2130
|
}
|
|
2020
2131
|
}
|
|
@@ -2023,8 +2134,8 @@
|
|
|
2023
2134
|
total: list.length,
|
|
2024
2135
|
groupWeights: groupWeights(list, now),
|
|
2025
2136
|
open: openDims(parsed, o.joins),
|
|
2026
|
-
count: (key) => counts.get(key) || 0,
|
|
2027
|
-
present: (key) => (present.get(key) || 0) > 0,
|
|
2137
|
+
count: (key) => counts.get(censusTagKey(key)) || 0,
|
|
2138
|
+
present: (key) => (present.get(censusTagKey(key)) || 0) > 0,
|
|
2028
2139
|
};
|
|
2029
2140
|
}
|
|
2030
2141
|
// Why an option can never match together with the active filters, whatever
|
|
@@ -2284,23 +2395,44 @@
|
|
|
2284
2395
|
}
|
|
2285
2396
|
// ── the or / and of one OR dimension (groups, kinds of clip, numpad keys):
|
|
2286
2397
|
// the chip row's leading toggle and the panel's ──
|
|
2287
|
-
// The kinds whose or / and the device remembers
|
|
2288
|
-
// (a
|
|
2398
|
+
// The kinds whose or / and the device remembers, and how each joins when it
|
|
2399
|
+
// remembers nothing: tags AND (a second tag narrows: todo and forge), kinds
|
|
2400
|
+
// of clip OR (text or link). Numpad keys are not one: they always OR (a
|
|
2401
|
+
// clip has one key, "and" always shows nothing).
|
|
2289
2402
|
const JOIN_DIMS = ['group', 'type'];
|
|
2403
|
+
const DEFAULT_JOINS = { group: 'and', type: 'or' };
|
|
2404
|
+
// Only the joins that differ from the default are kept ({ group: 'or' }),
|
|
2405
|
+
// so a device that never flipped one follows the default.
|
|
2290
2406
|
function normalizeJoins(joins) {
|
|
2291
2407
|
const out = {};
|
|
2292
|
-
for (const dim of JOIN_DIMS)
|
|
2408
|
+
for (const dim of JOIN_DIMS) {
|
|
2409
|
+
const j = joins && joins[dim];
|
|
2410
|
+
if ((j === 'and' || j === 'or') && j !== DEFAULT_JOINS[dim]) out[dim] = j;
|
|
2411
|
+
}
|
|
2293
2412
|
return out;
|
|
2294
2413
|
}
|
|
2414
|
+
// How a second value of the dimension joins the first: 'and' | 'or'.
|
|
2415
|
+
function dimJoin(joins, dim) {
|
|
2416
|
+
const j = joins && joins[dim];
|
|
2417
|
+
if (!JOIN_DIMS.includes(dim)) return 'or';
|
|
2418
|
+
return j === 'and' || j === 'or' ? j : DEFAULT_JOINS[dim];
|
|
2419
|
+
}
|
|
2295
2420
|
// 'or' (an OR of its values), 'and' (two or more side by side), or null
|
|
2296
|
-
// (nothing to join).
|
|
2297
|
-
//
|
|
2298
|
-
//
|
|
2421
|
+
// (nothing to join). ONE value shows the join the next pick would get
|
|
2422
|
+
// whenever that join is "and" (dimJoin: a tag by default): "and" greys every
|
|
2423
|
+
// value it would empty, so the toggle is the way to "or" and must show. Under
|
|
2424
|
+
// "or" nothing is greyed, so a lone value shows it only when "or" is a
|
|
2425
|
+
// remembered non-default (the way back to "and"); kinds of clip OR by default
|
|
2426
|
+
// and show nothing.
|
|
2299
2427
|
function dimConnective(parsed, dim, joins) {
|
|
2300
2428
|
const any = (parsed.anyOf || []).filter((g) => g.dim === dim);
|
|
2301
2429
|
const leaves = (parsed.items || []).filter((it) => it.kind === 'leaf' && !it.leaf.neg && facetDim(it.leaf.field, it.leaf.value) === dim);
|
|
2302
2430
|
const n = any.reduce((sum, g) => sum + g.values.length, 0) + leaves.length;
|
|
2303
|
-
if (n === 1 && !any.length
|
|
2431
|
+
if (n === 1 && !any.length) {
|
|
2432
|
+
if (!JOIN_DIMS.includes(dim)) return null; // numpad keys always OR: nothing to flip
|
|
2433
|
+
const join = dimJoin(joins, dim);
|
|
2434
|
+
return join === 'and' || join !== DEFAULT_JOINS[dim] ? join : null;
|
|
2435
|
+
}
|
|
2304
2436
|
if (n < 2) return null;
|
|
2305
2437
|
return any.length ? 'or' : 'and';
|
|
2306
2438
|
}
|
|
@@ -2343,22 +2475,47 @@
|
|
|
2343
2475
|
return n % 2 === 1;
|
|
2344
2476
|
}
|
|
2345
2477
|
|
|
2478
|
+
// ── a retired nested name in a query (an AI tool's search, a saved habit):
|
|
2479
|
+
// each group:/g: token naming one (splits: Map tagKey -> parts, the model's
|
|
2480
|
+
// groupSplitMap of settings.group_splits) becomes its parts, all required:
|
|
2481
|
+
// group:todo/forge -> group:todo group:forge, negated -> -(group:todo
|
|
2482
|
+
// group:forge), a side of an OR in brackets. The key is kept as typed. ──
|
|
2483
|
+
function flattenRetiredGroupTokens(query, splits) {
|
|
2484
|
+
const s = String(query == null ? '' : query);
|
|
2485
|
+
if (!splits || !splits.size) return { query: s, rewritten: false };
|
|
2486
|
+
const toks = scanQuery(s);
|
|
2487
|
+
const edits = [];
|
|
2488
|
+
toks.forEach((tok, i) => {
|
|
2489
|
+
if (tok.t !== 'term' || tok.regex) return;
|
|
2490
|
+
const leaf = leafOf(tok);
|
|
2491
|
+
const parts = leaf.field === 'group' ? splits.get(tagKey(leaf.value)) : null;
|
|
2492
|
+
if (!parts || !parts.length) return;
|
|
2493
|
+
const key = tok.body.slice(0, tok.body.indexOf(':') + 1);
|
|
2494
|
+
const inOr = (toks[i - 1] && toks[i - 1].t === 'or') || (toks[i + 1] && toks[i + 1].t === 'or');
|
|
2495
|
+
edits.push({ start: tok.start, end: tok.end, text: flatGroupTokens(key, parts, leaf.neg, inOr) });
|
|
2496
|
+
});
|
|
2497
|
+
let out = s;
|
|
2498
|
+
for (let k = edits.length - 1; k >= 0; k -= 1) out = out.slice(0, edits[k].start) + edits[k].text + out.slice(edits[k].end);
|
|
2499
|
+
return { query: out, rewritten: edits.length > 0 };
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2346
2502
|
return {
|
|
2347
|
-
clipToDoc, docSearchText,
|
|
2503
|
+
clipToDoc, docSearchText, docInGroup,
|
|
2504
|
+
cleanTagName, tagKey, sameTag, splitTagInput, nestedNameParts, preferredSpelling, tagSpellings, expandTagNames, inputTagNames, tagTogglePlan,
|
|
2348
2505
|
tokenizeQuery, quoteToken, parseQuery, scanQuery, serializeQuery, applyFacet, stripFacet, clearFacet, facetState, facetTokenState,
|
|
2349
2506
|
anyFilterActive, hasSearchTerms, resolveTimeMs,
|
|
2350
2507
|
matchDoc, relevanceScore, recencyScore, decayWeight, groupWeights, compareGroupUse, rankMode, filterRankIndexes, bodyIndexOf,
|
|
2351
2508
|
hasRegexTerm, searchJob,
|
|
2352
2509
|
compileTerm, escapeRegExp, termSpans, firstMatchIndex,
|
|
2353
|
-
regexTypingEdit, regexBackspaceEdit, toggleRegexAt, legacyRegexQuery, dimConnective, setDimConnective, normalizeJoins,
|
|
2510
|
+
regexTypingEdit, regexBackspaceEdit, toggleRegexAt, legacyRegexQuery, dimConnective, setDimConnective, normalizeJoins, dimJoin, DEFAULT_JOINS,
|
|
2354
2511
|
promptOptionState, promptOptionLabel, FIELD_EXAMPLE,
|
|
2355
2512
|
fuzzyMatch, fuzzyFloor,
|
|
2356
2513
|
lexQuery, suggestQuery,
|
|
2357
2514
|
BUILTIN_TO_IS, IS_TO_BUILTIN, IS_VALUES, RECOGNIZED_PREFIXES, NON_FILTER_SCHEMES,
|
|
2358
2515
|
PREFIX_HINTS, OPTION_FACETS, OPTION_FIELDS, SYNTAX_NOTES, facetTokenText, optionFacetsActive,
|
|
2359
2516
|
FIELD_INFO, facetKey, applySuggestion, ghostCompletion, uniqueCompletion,
|
|
2360
|
-
validateQuery, describeProblem, problemRanges, levenshtein,
|
|
2517
|
+
validateQuery, describeProblem, problemRanges, levenshtein, flatGroupTokens,
|
|
2361
2518
|
facetCensus, facetOptionVerdict, structuralReason, censusKey, FAILURE_CAP,
|
|
2362
|
-
bestRelaxation, quotePastedText, insideQuote,
|
|
2519
|
+
bestRelaxation, quotePastedText, insideQuote, flattenRetiredGroupTokens,
|
|
2363
2520
|
};
|
|
2364
2521
|
});
|