@tobq/boardclip 2.1.113 → 2.1.115

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.
@@ -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 (hierarchical: matches NAME and NAME/child)
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
- // ── 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('/');
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
- function tagMatchesFilter(group, filter) {
113
- const tag = normalizeTagName(group);
114
- const parent = normalizeTagName(filter);
115
- return !!parent && (tag === parent || tag.startsWith(`${parent}/`));
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
- return doc.groups.some((g) => tagMatchesFilter(g, filter));
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. Every
129
- // parent path counts its sub-groups' clips too. ONE score orders the chip
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 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
- }
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 group paths by use (groupWeights), then by name.
255
+ // Order tags by use (groupWeights), then by name.
166
256
  function compareGroupUse(weights) {
167
- return (a, b) => ((weights && (weights.get(normalizeTagName(b)) || 0)) - (weights && (weights.get(normalizeTagName(a)) || 0)))
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: normalizeTagName(val), neg };
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 an OR dimension adds an OR.
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 === k.leaf.value)) members.push({ value: k.leaf.value, text: k.text, start: k.start, end: k.end });
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 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).
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: normalizeTagName(token.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). A value joins an existing OR or AND the way it is
708
- // written; the remembered join only decides when one value is there.
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 isLeaf = (item, neg) => item.kind === 'leaf' && item.leaf.field === field && !!item.leaf.neg === neg && item.leaf.value === value;
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.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
- }
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 !== 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, at its value
761
- // order (chips clicked in any order write the same text); else a token.
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[dim] !== 'and' ? [{ value: ones[0].leaf.value, start: ones[0].start, end: ones[0].end }] : null;
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 === 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));
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.includes(value) || (parsed.anyOf || []).some((g) => g.field === field && g.values.includes(value))) return 'include';
812
- return ex.includes(value) ? 'exclude' : null;
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
- function facetState(parsed) {
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
- for (const g of parsed.groups) active.add(g);
840
- for (const g of parsed.negGroups) excluded.add(g);
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; } // stored names are normalised
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 (and its sub-groups)', short: 'g' },
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: 'groups' },
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
- // Group names match exactly (tagMatchesFilter): group:work is NOT complete
1546
- // when the group is Work, so the list stays up offering the right case.
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 || []).filter((g) => !val || g.toLowerCase().includes(val));
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 || [])) if (g.toLowerCase().startsWith(lower)) push(`group:${quoteToken(g)}`, '');
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
- // A row that is what was typed in another case (group:work -> group:Work)
1626
- // leads, then the best continuation (so the ghost and Tab agree with the top row).
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
- // Every group path a group: value can name (Work/Clients also offers Work).
1730
- const groupPaths = [...new Set((o.groups || []).flatMap((g) => normalizeTagName(g).split('/').filter(Boolean).map((_, i, a) => a.slice(0, i + 1).join('/'))))];
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
- for (const tok of scanQuery(s)) {
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' && groupPaths.length) {
1780
- // Names match exactly (tagMatchesFilter: Work is not work). A prefix of
1781
- // a name in any case is fine while it is typed (the list offers the
1782
- // right case), but a whole name in the wrong case finds nothing, so it
1783
- // is flagged with the right one.
1784
- const n = normalizeTagName(value);
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 sameName = groupPaths.find((g) => g.toLowerCase() === nl);
1787
- const typing = !sameName && groupPaths.some((g) => g.toLowerCase().startsWith(nl));
1788
- if (n && !groupPaths.some((g) => g === n || g.startsWith(n)) && !typing) {
1789
- const shown = sameName || closestTo(n, groupPaths, 3);
1790
- bad(`There is no group named "${value}".`, undefined, shown ? `${key}:${quoteToken(shown)}` : undefined);
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 under the default "or", or an OR already): dim -> the check dim the census
1875
- // holds open for that dimension's options ('any:group').
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 (j[dim] === 'and') continue; // the next value would AND in: the lone value is a plain filter
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:Work', 'since:7d',
1927
- // 'before:' (the prompt option), 'len:<80'.
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:${normalizeTagName(token.value)}`;
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.includes(value)) return `-${field}:${value}`;
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.includes(value) ? `${field}:${value}` : null;
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 group (with its parent paths) in `groups`.
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
- // Groups: bumped from each doc's own groups (and their parents), not tested
1981
- // one by one against every group.
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 groupKeys) groupDim.set(g, optionDim(parsed, { kind: 'group', value: g }, o.joins));
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 parts = normalizeTagName(g).split('/');
2011
- for (let i = 1; i <= parts.length; i += 1) {
2012
- const p = parts.slice(0, i).join('/');
2013
- if (seen.has(p) || !groupKeys.has(p)) continue;
2014
- seen.add(p);
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. Numpad keys are not one
2288
- // (a clip has one key: "and" always shows nothing).
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) if (joins && joins[dim] === 'and') out[dim] = 'and';
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). With joins, ONE value under a remembered "and" reads
2297
- // 'and' too: that join greys what it would empty, so the toggle shows (and
2298
- // a click on it is the way back to "or").
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 && normalizeJoins(joins)[dim] === 'and') return 'and';
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, normalizeTagName, tagMatchesFilter, docInGroup,
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
  });