@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.
@@ -1,6 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  const crypto = require('crypto');
4
+ // The ONE tag rule (flat, case-insensitive, comma input, retired nested names) lives in
5
+ // the isomorphic search engine so the popup, the demo, MCP and this model share it.
6
+ const Tags = require('../site/shared/clip-search');
4
7
 
5
8
  const DEFAULT_SETTINGS = {
6
9
  max_age_days: 7,
@@ -42,6 +45,7 @@ const DEFAULT_SETTINGS = {
42
45
  options_panel_height: 0,
43
46
  show_shortcut: '',
44
47
  quick_paste_shortcut: '',
48
+ new_note_shortcut: '',
45
49
  // Quick-paste (numpad slots + panel number keys) behaviour. Per-machine, not
46
50
  // synced — these are about how THIS machine drives the OS paste, not content.
47
51
  // 'clipboard' = the real paste API (set clipboard + Ctrl/Cmd+V + restore),
@@ -52,6 +56,19 @@ const DEFAULT_SETTINGS = {
52
56
  quick_paste_restore: true, // restore the previous clipboard after a quick-paste
53
57
  quick_paste_restore_delay_ms: 400, // floor delay before restore; adapts up under lag
54
58
  groups: [],
59
+ // Tags nested with '/' before the flat-tags cutover (14 Oct 2026), split once into their
60
+ // parts: [{ name, parts, at }]. Synced and permanent, so an old device, a stale cloud copy,
61
+ // an old backup or an agent still writing 'todo/forge' lands as todo + forge.
62
+ group_splits: [],
63
+ // Tags with a '/' in their name made on a flat-tags device, where the '/' is a plain
64
+ // character: [{ name, at }]. Synced and permanent. A device on its first flat start
65
+ // cannot tell such a name from an old nested one and retires it; this record overrules
66
+ // that split everywhere (groupSplitMap); a merge joins the parts back into the name
67
+ // (rejoinLiterals) and that device puts it back on its own clips (unsplitLiterals).
68
+ group_literals: [],
69
+ // When THIS device cut over to flat tags (ms). Per device, never synced: before it, every
70
+ // '/' in a tag name meant nesting; after it, a new name's '/' is a plain character.
71
+ groups_flattened_at: 0,
55
72
  sync_path: '',
56
73
  sync_custom_paths: [],
57
74
  sync_disabled_paths: [],
@@ -73,6 +90,9 @@ const DEFAULT_SETTINGS = {
73
90
  // AI Access (local MCP server). See lib/mcp-core.js and the MCP section in main.js.
74
91
  ai_access_enabled: false,
75
92
  groups_shared_with_ai: [], // group names whose clips are exposed to AI (curation allowlist)
93
+ // Explicit share-switch flips, synced per tag: { [tagKey]: { shared, at } }; the newest flip
94
+ // wins on every device. A device's list keeps its own state for tags never flipped.
95
+ ai_share_stamps: {},
76
96
  ai_always_allow: [], // MCP tool names the user chose to always allow (skip approval modal)
77
97
  ai_approval_timeout_sec: 60, // approval modal deny-by-default countdown
78
98
  mcp_secret: '', // per-machine control-channel HMAC secret (not synced)
@@ -333,11 +353,14 @@ function tombstoneRecord(source, deletedAt) {
333
353
  // stays deleted even when clock skew makes it look newer than the delete.
334
354
  // A genuinely newer copy - edited elsewhere (different rev) or re-added later
335
355
  // (newer clock) - survives.
336
- function tombstoneSuppresses(tombstone, item) {
356
+ // rawRev: the copy's revision before its tags were normalised (a delete made by a build
357
+ // that still held the nested names records the nested rev).
358
+ function tombstoneSuppresses(tombstone, item, rawRev = '') {
337
359
  if (!tombstone || !item) return false;
338
360
  const clock = itemMutationClock(item);
339
361
  if (clock <= (Number(tombstone.deletedAt) || 0)) return true;
340
- return !!tombstone.rev && clipRevision(item) === tombstone.rev && clock <= tombstone.revClock;
362
+ if (!tombstone.rev || clock > tombstone.revClock) return false;
363
+ return clipRevision(item) === tombstone.rev || (!!rawRev && rawRev === tombstone.rev);
341
364
  }
342
365
 
343
366
  function normalizeTombstones(list, now = Date.now()) {
@@ -353,18 +376,144 @@ function normalizeTombstones(list, now = Date.now()) {
353
376
  return [...byId.values()];
354
377
  }
355
378
 
379
+ // Tag deletes, { name, deletedAt, restoredAt?, anyCase? }: the newest delete and the newest
380
+ // re-create of one tag. A flat-tags build writes anyCase (the tag in any spelling). A record
381
+ // without it was written by a build from before 14 Oct 2026, when 'Work' and 'work' were two
382
+ // tags: it deletes that exact spelling only, so a live twin survives. A record stays while
383
+ // its delete or its re-create is under 30 days old.
356
384
  function normalizeGroupTombstones(list, now = Date.now()) {
357
385
  const cutoff = now - TOMBSTONE_MAX_AGE_MS;
358
- const byName = new Map();
386
+ const bySlot = new Map();
359
387
  for (const tombstone of Array.isArray(list) ? list : []) {
360
388
  if (!tombstone || !tombstone.name) continue;
361
- const name = String(tombstone.name);
389
+ const name = Tags.cleanTagName(tombstone.name);
390
+ if (!name) continue;
391
+ const anyCase = tombstone.anyCase === true;
392
+ const slot = anyCase ? `k:${Tags.tagKey(name)}` : `x:${name}`;
362
393
  const deletedAt = Number(tombstone.deletedAt) || 0;
363
- if (deletedAt < cutoff) continue;
364
- const existing = byName.get(name);
365
- if (!existing || deletedAt > existing.deletedAt) byName.set(name, { name, deletedAt });
394
+ const restoredAt = Number(tombstone.restoredAt) || 0;
395
+ const record = bySlot.get(slot) || (anyCase ? { name, deletedAt: 0, anyCase: true } : { name, deletedAt: 0 });
396
+ if (anyCase) record.name = Tags.preferredSpelling(record.name, name);
397
+ record.deletedAt = Math.max(record.deletedAt, deletedAt);
398
+ if (restoredAt > (record.restoredAt || 0)) record.restoredAt = restoredAt;
399
+ bySlot.set(slot, record);
400
+ }
401
+ return [...bySlot.values()].filter(t => Math.max(t.deletedAt, t.restoredAt || 0) >= cutoff);
402
+ }
403
+
404
+ // What the tag deletes do: a delete not re-created since takes the tag off every copy (a
405
+ // re-create is how a deleted tag comes back, registerTags). After a re-create only a copy
406
+ // whose tags were last changed at or before the delete loses it, so a stale pre-delete copy
407
+ // cannot bring the old memberships back. key: tagKey -> state (flat-build records); exact:
408
+ // spelling -> state (older builds' records, applied to a raw name before it is respelled).
409
+ function groupTombstoneIndex(list) {
410
+ const key = new Map();
411
+ const exact = new Map();
412
+ for (const t of normalizeGroupTombstones(list)) {
413
+ const state = { deletedAt: t.deletedAt, recreated: (t.restoredAt || 0) > t.deletedAt };
414
+ if (t.anyCase) key.set(Tags.tagKey(t.name), state);
415
+ else exact.set(t.name, state);
416
+ }
417
+ return { key, exact, size: key.size + exact.size };
418
+ }
419
+
420
+ // Does this delete take a tag off a copy whose tags were last changed at `clock`? A name
421
+ // list has no clock (Infinity): only deletes not re-created apply to it.
422
+ function deleteStrips(state, clock = Infinity) {
423
+ return !!state && (!state.recreated || clock <= state.deletedAt);
424
+ }
425
+
426
+ // The AI tag (always shared with AI) is spelled exactly AI_TAG_NAME. Before 14 Oct 2026 a
427
+ // tag spelled any other way ('ai', 'Ai') was a different, private tag: it keeps its clips
428
+ // as "<its name> (old)" and never turns into the shared AI tag, from any copy on any
429
+ // device. A flat build never writes such a spelling (tagInputNames gives AI_TAG_NAME).
430
+ const AI_TAG_NAME = 'AI';
431
+ const AI_TAG_KEY = AI_TAG_NAME.toLowerCase();
432
+ function renameAiTwins(names) {
433
+ let out = null;
434
+ for (let i = 0; i < names.length; i++) {
435
+ const name = Tags.cleanTagName(names[i]);
436
+ if (name === AI_TAG_NAME || Tags.tagKey(name) !== AI_TAG_KEY) continue;
437
+ if (!out) out = names.slice();
438
+ out[i] = `${name} (old)`;
439
+ }
440
+ return out || names;
441
+ }
442
+
443
+ // A raw tag list (as a clip, a settings file or an older build holds it) before it is put
444
+ // in stored form: exact-spelling deletes applied, AI twins renamed. The same array when
445
+ // nothing changes.
446
+ function rawTagNames(names, ctx, clock = Infinity) {
447
+ let list = Array.isArray(names) ? names : [];
448
+ const exact = ctx && ctx.tombstones && ctx.tombstones.exact;
449
+ if (exact && exact.size && list.length) {
450
+ const kept = list.filter(g => !deleteStrips(exact.get(Tags.cleanTagName(g)), clock));
451
+ if (kept.length !== list.length) list = kept;
452
+ }
453
+ return renameAiTwins(list);
454
+ }
455
+
456
+ // The retired nested names: tagKey(name) -> parts. Permanent; the earliest record of a
457
+ // name wins so every device agrees. A part is never the AI tag: sharing a clip with AI is
458
+ // the AI tag itself, never a sub-group of it ('AI/drafts' is drafts, private as it was).
459
+ function normalizeGroupSplits(list) {
460
+ const byKey = new Map();
461
+ for (const record of Array.isArray(list) ? list : []) {
462
+ if (!record || !record.name) continue;
463
+ const name = String(record.name);
464
+ const key = Tags.tagKey(name);
465
+ if (!key) continue;
466
+ const parts = (Array.isArray(record.parts) ? record.parts : Tags.nestedNameParts(name)).map(Tags.cleanTagName)
467
+ .filter(part => part && Tags.tagKey(part) !== AI_TAG_KEY);
468
+ const at = Number(record.at) || 0;
469
+ const existing = byKey.get(key);
470
+ if (!existing || at < existing.at || (at === existing.at && name < existing.name)) byKey.set(key, { name, parts, at });
471
+ }
472
+ return [...byKey.values()].sort((a, b) => a.at - b.at || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
473
+ }
474
+
475
+ // '/' names made on a flat-tags device (plain '/'), one per tag, the earliest record.
476
+ function normalizeGroupLiterals(list) {
477
+ const byKey = new Map();
478
+ for (const record of Array.isArray(list) ? list : []) {
479
+ const name = Tags.cleanTagName(record && typeof record === 'object' ? record.name : record);
480
+ const key = Tags.tagKey(name);
481
+ if (!key || !name.includes('/')) continue;
482
+ const at = Number(record && record.at) || 0;
483
+ const existing = byKey.get(key);
484
+ if (!existing || at < existing.at || (at === existing.at && name < existing.name)) byKey.set(key, { name, at });
366
485
  }
367
- return [...byName.values()];
486
+ return [...byKey.values()].sort((a, b) => a.at - b.at || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
487
+ }
488
+
489
+ // tagKey(plain '/' name) -> { name, parts }: the parts a late cutover split it into (its
490
+ // own record, else the name's segments).
491
+ function literalPartsMap(settings = {}) {
492
+ const map = new Map();
493
+ const literals = normalizeGroupLiterals(settings.group_literals);
494
+ if (!literals.length) return map;
495
+ const recorded = new Map(normalizeGroupSplits(settings.group_splits).map(r => [Tags.tagKey(r.name), r.parts]));
496
+ for (const { name } of literals) {
497
+ const key = Tags.tagKey(name);
498
+ const parts = recorded.get(key) || Tags.nestedNameParts(name).filter(p => Tags.tagKey(p) !== AI_TAG_KEY);
499
+ if (parts.length) map.set(key, { name, parts });
500
+ }
501
+ return map;
502
+ }
503
+
504
+ // tagKey(retired name) -> parts, without the names a flat device made as plain '/' tags.
505
+ function groupSplitMap(list, literals = []) {
506
+ const literal = new Set(normalizeGroupLiterals(literals).map(record => Tags.tagKey(record.name)));
507
+ const map = new Map();
508
+ for (const record of normalizeGroupSplits(list)) {
509
+ const key = Tags.tagKey(record.name);
510
+ if (!literal.has(key)) map.set(key, record.parts);
511
+ }
512
+ return map;
513
+ }
514
+ // The split map one device's settings give (what tagContext and the MCP rewrite use).
515
+ function splitMapOf(settings = {}) {
516
+ return groupSplitMap(settings.group_splits, settings.group_literals);
368
517
  }
369
518
 
370
519
  function normalizeSupersedes(list, now = Date.now()) {
@@ -440,8 +589,60 @@ function tombstoneMap(list) {
440
589
  return new Map(normalizeTombstones(list).map(t => [t.id, t]));
441
590
  }
442
591
 
443
- function groupTombstoneNames(list) {
444
- return new Set(normalizeGroupTombstones(list).map(t => t.name));
592
+ // What every tag list is normalised against: the retired names, the stored spelling of
593
+ // each tag, and the deleted tags. Built from settings plus any name lists in play (both
594
+ // sides of a merge), so the spelling chosen is the same on every device.
595
+ function tagContext(settings = {}, nameLists = []) {
596
+ const splits = splitMapOf(settings);
597
+ const tombstones = groupTombstoneIndex(settings.group_tombstones);
598
+ const ctx = { splits, tombstones, spellings: null, literals: literalPartsMap(settings) };
599
+ const lists = [settings.groups, ...nameLists].map(list => Tags.expandTagNames(rawTagNames(list, ctx), splits, null));
600
+ ctx.spellings = Tags.tagSpellings(lists);
601
+ return ctx;
602
+ }
603
+
604
+ // Every tag list held by a set of clips (for tagContext).
605
+ function itemTagLists(...histories) {
606
+ const lists = [];
607
+ for (const history of histories) {
608
+ for (const item of Array.isArray(history) ? history : []) {
609
+ if (item && item.pin && Array.isArray(item.pin.groups) && item.pin.groups.length) lists.push(item.pin.groups);
610
+ else if (item && !('pin' in item) && item.group) lists.push([item.group]);
611
+ }
612
+ }
613
+ return lists;
614
+ }
615
+
616
+ // A list of tag names (no clock: settings.groups, typed input, new-note tags) in stored
617
+ // form: retired names split, stored spellings, deleted tags dropped, one per tag.
618
+ function normalizeTagList(names, ctx) {
619
+ return keepUndeletedGroups(Tags.expandTagNames(rawTagNames(names, ctx), ctx.splits, ctx.spellings), Infinity, ctx.tombstones);
620
+ }
621
+
622
+ // The tags typed or sent in one go (New group, a picker, an AI tool's group param):
623
+ // an existing tag named in full is that tag (even with a comma in its name); else the
624
+ // comma-separated names, a retired nested name as its parts, each in its stored
625
+ // spelling. Never filtered by tombstones: typing a deleted tag's name re-creates it.
626
+ function tagInputNames(input, settings = {}) {
627
+ const ctx = tagContext(settings);
628
+ return Tags.inputTagNames(input, settings.groups, ctx.splits, ctx.spellings)
629
+ .map(name => (Tags.sameTag(name, AI_TAG_NAME) ? AI_TAG_NAME : name));
630
+ }
631
+
632
+ // One clip's tags in stored form. PURE: returns the item itself when nothing changes and
633
+ // otherwise a COPY with a new pin object (a merge receives the live history objects, and
634
+ // an in-place change would hide from every change detector). Never moves a clock and never
635
+ // removes the pin itself (an unpinned clip can be age-pruned).
636
+ function normalizeItemTags(item, ctx) {
637
+ if (!item || !item.pin || !Array.isArray(item.pin.groups)) return item;
638
+ const pin = item.pin;
639
+ const clock = groupState(pin).updatedAt;
640
+ const groups = keepUndeletedGroups(Tags.expandTagNames(rawTagNames(pin.groups, ctx, clock), ctx.splits, ctx.spellings), clock, ctx.tombstones);
641
+ if (groups === pin.groups) return item;
642
+ const nextPin = { ...pin };
643
+ if (groups.length) nextPin.groups = groups;
644
+ else delete nextPin.groups;
645
+ return { ...item, pin: nextPin };
445
646
  }
446
647
 
447
648
  function timestamp(value) {
@@ -495,17 +696,38 @@ function numberState(pin, fallback = 0) {
495
696
  };
496
697
  }
497
698
 
498
- function cleanPin(pin, deletedGroups = new Set()) {
699
+ // Stored-form names (already respelled): the any-case deletes. deletedGroups:
700
+ // groupTombstoneIndex. The same array when nothing goes.
701
+ function keepUndeletedGroups(groups, updatedAt, deletedGroups) {
702
+ if (!deletedGroups || !deletedGroups.key.size || !groups.length) return groups;
703
+ const kept = groups.filter(g => !deleteStrips(deletedGroups.key.get(Tags.tagKey(g)), updatedAt));
704
+ return kept.length === groups.length ? groups : kept;
705
+ }
706
+
707
+ function cleanPin(pin, deletedGroups = groupTombstoneIndex([])) {
499
708
  if (!pin) return null;
500
709
  const cleaned = { ...pin };
501
710
  if (typeof cleaned.number !== 'number') delete cleaned.number;
502
711
  if (Array.isArray(cleaned.groups)) {
503
- cleaned.groups = cleaned.groups.filter(g => !deletedGroups.has(g));
712
+ cleaned.groups = keepUndeletedGroups(cleaned.groups, groupState(pin).updatedAt, deletedGroups);
504
713
  if (!cleaned.groups.length) delete cleaned.groups;
505
714
  }
506
715
  return cleaned;
507
716
  }
508
717
 
718
+ // One entry per tag (case-insensitive), first spelling kept.
719
+ function uniqueTags(groups) {
720
+ const seen = new Set();
721
+ const out = [];
722
+ for (const g of groups) {
723
+ const key = Tags.tagKey(g);
724
+ if (!key || seen.has(key)) continue;
725
+ seen.add(key);
726
+ out.push(g);
727
+ }
728
+ return out;
729
+ }
730
+
509
731
  function mergeGroupState(localPin, remotePin, localUpdatedAt, remoteUpdatedAt, deletedGroups) {
510
732
  const local = groupState(localPin, localUpdatedAt);
511
733
  const remote = groupState(remotePin, remoteUpdatedAt);
@@ -526,7 +748,7 @@ function mergeGroupState(localPin, remotePin, localUpdatedAt, remoteUpdatedAt, d
526
748
  updatedAt = remote.updatedAt;
527
749
  }
528
750
 
529
- groups = [...new Set(groups)].filter(g => !deletedGroups.has(g));
751
+ groups = keepUndeletedGroups(uniqueTags(groups), updatedAt, deletedGroups);
530
752
  return { known: local.known || remote.known, groups, updatedAt };
531
753
  }
532
754
 
@@ -548,7 +770,7 @@ function mergeNumberState(localPin, remotePin, localUpdatedAt, remoteUpdatedAt)
548
770
 
549
771
  function mergePins(localPin, remotePin, localUpdatedAt = 0, remoteUpdatedAt = 0, groupTombstones = []) {
550
772
  if (!localPin && !remotePin) return null;
551
- const deletedGroups = groupTombstoneNames(groupTombstones);
773
+ const deletedGroups = groupTombstoneIndex(groupTombstones);
552
774
  const localPinUpdated = pinMetadataUpdatedAt(localPin, localUpdatedAt);
553
775
  const remotePinUpdated = pinMetadataUpdatedAt(remotePin, remoteUpdatedAt);
554
776
 
@@ -850,11 +1072,13 @@ function migrateLegacyNumpadSlots(history, oldSlots, options = {}) {
850
1072
  return { changed, migrated, created, skipped };
851
1073
  }
852
1074
 
1075
+ // A new clip's tags (a new note, a fork, Keep both): callers pass them in stored form
1076
+ // (main's normalizeTagNames); deleted tags are dropped here as well.
853
1077
  function groupsOnlyPin(groups, updatedAt, groupTombstones = []) {
854
- const deleted = groupTombstoneNames(groupTombstones);
855
- const cleanGroups = [...new Set((Array.isArray(groups) ? groups : [])
856
- .map(group => String(group || '').trim())
857
- .filter(group => group && !deleted.has(group)))];
1078
+ const deleted = groupTombstoneIndex(groupTombstones);
1079
+ const cleanGroups = keepUndeletedGroups(uniqueTags((Array.isArray(groups) ? groups : [])
1080
+ .map(group => Tags.cleanTagName(group))
1081
+ .filter(Boolean)), Infinity, deleted);
858
1082
  if (!cleanGroups.length) return null;
859
1083
  const ts = timestamp(updatedAt) || Date.now();
860
1084
  return { groups: cleanGroups, updatedAt: ts, groupsUpdatedAt: ts };
@@ -1066,6 +1290,38 @@ function mergeHistories(local, remote, settings = {}, report = null) {
1066
1290
  ensureItemId(item);
1067
1291
  allKeys.add(itemKey(item));
1068
1292
  }
1293
+ // Tags in stored form on both sides: a copy from an old build or a stale provider, a
1294
+ // restored backup, a tag deleted on another device. By copy (these are the live history
1295
+ // objects), and the raw rev kept so a delete recorded against the old form still matches.
1296
+ const ctx = tagContext(settings, itemTagLists(local, remote));
1297
+ const rawRevs = new Map();
1298
+ const normaliseSide = (list, isLocal) => list.map(item => {
1299
+ if (!item) return item;
1300
+ const next = normalizeItemTags(item, ctx);
1301
+ if (next !== item) {
1302
+ rawRevs.set(next, clipRevision(item));
1303
+ if (isLocal && report) report.push({ event: 'normalised', id: itemKey(item), split: groupsOf(item).some(g => ctx.splits.has(Tags.tagKey(g))) });
1304
+ }
1305
+ return next;
1306
+ });
1307
+ local = normaliseSide(local, true);
1308
+ remote = normaliseSide(remote, false);
1309
+ if (ctx.literals.size) {
1310
+ const localAt = new Map();
1311
+ local.forEach((item, i) => { if (item) localAt.set(itemKey(item), i); });
1312
+ remote = remote.map(item => {
1313
+ const i = item ? localAt.get(itemKey(item)) : undefined;
1314
+ if (i === undefined) return item;
1315
+ const [l, r] = rejoinLiterals(local[i], item, ctx.literals);
1316
+ if (l !== local[i]) {
1317
+ rawRevs.set(l, rawRevs.get(local[i]) || clipRevision(local[i]));
1318
+ if (report) report.push({ event: 'normalised', id: itemKey(l), split: false });
1319
+ local[i] = l;
1320
+ }
1321
+ if (r !== item) rawRevs.set(r, rawRevs.get(item) || clipRevision(item));
1322
+ return r;
1323
+ });
1324
+ }
1069
1325
  const merged = new Map();
1070
1326
  const pendingStaleByTarget = new Map();
1071
1327
 
@@ -1093,7 +1349,7 @@ function mergeHistories(local, remote, settings = {}, report = null) {
1093
1349
  // mutation clock is at/older than the delete. A legitimately NEWER re-add
1094
1350
  // or edit of the same content-hash id beats the tombstone (no surprise
1095
1351
  // cross-device clobber); a stale pre-delete copy stays deleted.
1096
- if (tombstoneSuppresses(deleted.get(key), item)) return;
1352
+ if (tombstoneSuppresses(deleted.get(key), item, rawRevs.get(item))) return;
1097
1353
  }
1098
1354
 
1099
1355
  if (targetKey && allKeys.has(targetKey)) {
@@ -1169,6 +1425,293 @@ function mergeHistories(local, remote, settings = {}, report = null) {
1169
1425
  return result;
1170
1426
  }
1171
1427
 
1428
+ function sameNames(a, b) {
1429
+ const x = Array.isArray(a) ? a : [];
1430
+ const y = Array.isArray(b) ? b : [];
1431
+ return x.length === y.length && x.every((v, i) => v === y[i]);
1432
+ }
1433
+
1434
+ // The flat-tags state of one device's files, PURE: the app runs it at load, the MCP helper
1435
+ // at every read (so it agrees with the app even mid-update), and tests and the live-data
1436
+ // replay call it directly. Items must already be through migrateItemPin.
1437
+ // Before this device's cutover (settings.groups_flattened_at unset) a '/' in a tag name
1438
+ // meant nesting, so each such name is retired into its parts (group_splits); after it, only
1439
+ // the retired names are split (a name another device made with a plain '/' never is:
1440
+ // group_literals). Then every tag list is put in stored form (normalizeTagList,
1441
+ // normalizeItemTags). At the cutover the AI share list follows the unanimous rule: a tag is
1442
+ // shared only if every name it came from was shared. The AI tag never comes from a split
1443
+ // or another spelling (normalizeGroupSplits, renameAiTwins), so no clip can become
1444
+ // AI-visible through it; a clip made visible through another shared tag unshares that tag.
1445
+ // Returns { history, settings (patch), changed, report, undo }. history holds the very
1446
+ // same objects for clips that did not change; undo lists each changed clip's old tags.
1447
+ function planTagState(history, settings = {}, options = {}) {
1448
+ const now = Number(options.now) || Date.now();
1449
+ const aiName = AI_TAG_NAME;
1450
+ const aiKey = AI_TAG_KEY;
1451
+ const items = Array.isArray(history) ? history : [];
1452
+ const cutover = !(Number(settings.groups_flattened_at) > 0);
1453
+ const oldShared = Array.isArray(settings.groups_shared_with_ai) ? settings.groups_shared_with_ai.map(String) : [];
1454
+ const oldGroups = Array.isArray(settings.groups) ? settings.groups.map(String) : [];
1455
+ const itemLists = itemTagLists(items);
1456
+ let splits = normalizeGroupSplits(settings.group_splits);
1457
+ const retired = [];
1458
+ if (cutover) {
1459
+ const known = new Set([...splits, ...normalizeGroupLiterals(settings.group_literals)].map(r => Tags.tagKey(r.name)));
1460
+ for (const list of [oldGroups, ...itemLists, oldShared]) {
1461
+ for (const raw of list) {
1462
+ const name = String(raw == null ? '' : raw);
1463
+ const key = Tags.tagKey(name);
1464
+ if (!name.includes('/') || !key || known.has(key)) continue;
1465
+ known.add(key);
1466
+ retired.push({ name: Tags.cleanTagName(name), parts: Tags.nestedNameParts(name), at: now });
1467
+ }
1468
+ }
1469
+ if (retired.length) splits = normalizeGroupSplits([...splits, ...retired]);
1470
+ }
1471
+ const ctx = tagContext({ ...settings, group_splits: splits }, [...itemLists, oldShared]);
1472
+
1473
+ const nextHistory = items.slice();
1474
+ const undo = [];
1475
+ for (let i = 0; i < items.length; i++) {
1476
+ const item = items[i];
1477
+ const next = normalizeItemTags(item, ctx);
1478
+ if (next === item) continue;
1479
+ nextHistory[i] = next;
1480
+ undo.push({ id: itemKey(item), groups: groupsOf(item).slice() });
1481
+ }
1482
+ const groups = normalizeTagList(oldGroups, ctx);
1483
+
1484
+ let shared;
1485
+ const report = { cutover, retired: retired.map(r => r.name), clipsChanged: undo.length, unsharedToProtect: [] };
1486
+ if (cutover) {
1487
+ const oldSet = new Set(oldShared);
1488
+ const sources = new Map();
1489
+ for (const list of [oldGroups, ...itemLists, oldShared]) {
1490
+ for (const raw of list) {
1491
+ const name = String(raw == null ? '' : raw);
1492
+ for (const part of Tags.expandTagNames(renameAiTwins([name]), ctx.splits, null)) {
1493
+ const key = Tags.tagKey(part);
1494
+ if (!key) continue;
1495
+ if (!sources.has(key)) sources.set(key, new Set());
1496
+ sources.get(key).add(name);
1497
+ }
1498
+ }
1499
+ }
1500
+ const unanimous = key => key === aiKey || [...(sources.get(key) || [])].every(name => oldSet.has(name));
1501
+ shared = [];
1502
+ const seen = new Set();
1503
+ for (const raw of oldShared) {
1504
+ for (const name of Tags.expandTagNames(renameAiTwins([raw]), ctx.splits, ctx.spellings)) {
1505
+ const key = Tags.tagKey(name);
1506
+ if (!key || seen.has(key)) continue;
1507
+ seen.add(key);
1508
+ if (unanimous(key)) shared.push(name);
1509
+ }
1510
+ }
1511
+ // Nothing private may become visible: before = the old exact-name rule.
1512
+ const wasVisible = item => groupsOf(item).some(g => g === aiName || oldSet.has(g));
1513
+ let sharedKeys = new Set([aiKey, ...shared.map(Tags.tagKey)]);
1514
+ const isVisible = item => groupsOf(item).some(g => sharedKeys.has(Tags.tagKey(g)));
1515
+ for (let i = 0; i < nextHistory.length; i++) {
1516
+ if (wasVisible(items[i]) || !isVisible(nextHistory[i])) continue;
1517
+ for (const g of groupsOf(nextHistory[i])) {
1518
+ const key = Tags.tagKey(g);
1519
+ if (key !== aiKey && sharedKeys.has(key)) report.unsharedToProtect.push(g);
1520
+ }
1521
+ const drop = new Set(report.unsharedToProtect.map(Tags.tagKey));
1522
+ shared = shared.filter(name => !drop.has(Tags.tagKey(name)));
1523
+ sharedKeys = new Set([aiKey, ...shared.map(Tags.tagKey)]);
1524
+ }
1525
+ report.visibleBefore = items.filter(wasVisible).length;
1526
+ report.visibleAfter = nextHistory.filter(isVisible).length;
1527
+ } else {
1528
+ // After the cutover a retired name has no meaning of its own: never share by it.
1529
+ shared = Tags.expandTagNames(renameAiTwins(oldShared.filter(name => !ctx.splits.has(Tags.tagKey(name)))), null, ctx.spellings);
1530
+ }
1531
+
1532
+ const patch = { groups, group_splits: splits, groups_shared_with_ai: shared };
1533
+ if (cutover) {
1534
+ patch.groups_flattened_at = now;
1535
+ // Several tag chips now join with AND by default: forget a remembered group join once.
1536
+ const joins = settings.facet_joins && typeof settings.facet_joins === 'object' ? settings.facet_joins : null;
1537
+ if (joins && 'group' in joins) {
1538
+ const next = { ...joins };
1539
+ delete next.group;
1540
+ patch.facet_joins = next;
1541
+ }
1542
+ }
1543
+ report.groupsBefore = oldGroups.length;
1544
+ report.groupsAfter = groups.length;
1545
+ report.sharedBefore = oldShared.length;
1546
+ report.sharedAfter = shared.length;
1547
+ report.spellingsMerged = [...ctx.spellings.keys()].length;
1548
+ const changed = cutover || undo.length > 0 || !sameNames(groups, oldGroups)
1549
+ || !sameNames(shared, oldShared) || JSON.stringify(splits) !== JSON.stringify(settings.group_splits || []);
1550
+ return { history: nextHistory, settings: patch, changed, report, undo };
1551
+ }
1552
+
1553
+ // A device on its first flat start cannot tell a name made with a plain '/' on another
1554
+ // device ('AC/DC', recorded in group_literals) from an old nested one, so its cutover split
1555
+ // it, without moving a clock. So two copies of one clip with the SAME tags clock, one with
1556
+ // the name and the other with its parts instead, are one tag list seen two ways (a real
1557
+ // edit moves the clock): the parts go back into the name before the copies merge, on
1558
+ // whichever device meets them first. PURE: returns [a, b], each the same object when
1559
+ // unchanged, else a copy.
1560
+ function rejoinLiterals(a, b, literals) {
1561
+ if (!a || !b || !a.pin || !b.pin || !literals || !literals.size) return [a, b];
1562
+ if (groupState(a.pin).updatedAt !== groupState(b.pin).updatedAt) return [a, b];
1563
+ const oa = groupsOf(a);
1564
+ const ob = groupsOf(b);
1565
+ let ga = oa;
1566
+ let gb = ob;
1567
+ const has = (list, name) => list.some(g => Tags.sameTag(g, name));
1568
+ for (const { name, parts } of literals.values()) {
1569
+ const inA = has(ga, name);
1570
+ if (inA === has(gb, name)) continue;
1571
+ const withName = inA ? ga : gb;
1572
+ const withParts = inA ? gb : ga;
1573
+ if (!parts.every(p => has(withParts, p))) continue;
1574
+ const at = withParts.findIndex(g => parts.some(p => Tags.sameTag(g, p)));
1575
+ const kept = withParts.filter(g => !parts.some(p => Tags.sameTag(g, p)) || has(withName, g));
1576
+ const joined = [...kept.slice(0, at), name, ...kept.slice(at)];
1577
+ if (inA) gb = joined; else ga = joined;
1578
+ }
1579
+ const withGroups = (item, groups) => ({ ...item, pin: { ...item.pin, groups } });
1580
+ return [ga === oa ? a : withGroups(a, ga), gb === ob ? b : withGroups(b, gb)];
1581
+ }
1582
+
1583
+ // The tag list after a late cutover split a plain '/' name: the parts it listed for it
1584
+ // ('AC', 'DC') leave the list once no clip holds them (the list is a union across devices,
1585
+ // so without this they would stay as empty tags everywhere). Only parts of a name some
1586
+ // device actually split (its split record exists). heldNames: every tag on the clips in
1587
+ // play. The same array when nothing goes.
1588
+ function pruneLiteralParts(groups, settings = {}, heldNames = []) {
1589
+ const list = Array.isArray(groups) ? groups : [];
1590
+ const literals = normalizeGroupLiterals(settings.group_literals);
1591
+ if (!literals.length) return list;
1592
+ const splitBy = new Map(normalizeGroupSplits(settings.group_splits).map(r => [Tags.tagKey(r.name), r.parts]));
1593
+ const held = new Set((heldNames || []).map(Tags.tagKey));
1594
+ const listed = new Set(list.map(Tags.tagKey));
1595
+ const drop = new Set();
1596
+ for (const { name } of literals) {
1597
+ const parts = splitBy.get(Tags.tagKey(name));
1598
+ if (!parts || !listed.has(Tags.tagKey(name))) continue;
1599
+ for (const part of parts) if (!held.has(Tags.tagKey(part))) drop.add(Tags.tagKey(part));
1600
+ }
1601
+ if (!drop.size) return list;
1602
+ const kept = list.filter(name => !drop.has(Tags.tagKey(name)));
1603
+ return kept.length === list.length ? list : kept;
1604
+ }
1605
+
1606
+ // The late device's own clips: once the record arrives, the name goes back on the clips its
1607
+ // cutover split, from that cutover's undo ledger ([{ id, groups }]: each changed clip's old
1608
+ // tags), only where a clip still holds exactly what the cutover gave it (rejoinLiterals
1609
+ // needs both copies to meet, and an unchanged copy is never sent again). PURE, never moves
1610
+ // a clock. Returns { history (the same array when nothing changes), ids }.
1611
+ function unsplitLiterals(history, ledger, settings = {}) {
1612
+ const items = Array.isArray(history) ? history : [];
1613
+ const literals = new Set(normalizeGroupLiterals(settings.group_literals).map(r => Tags.tagKey(r.name)));
1614
+ const byId = new Map();
1615
+ for (const entry of Array.isArray(ledger) ? ledger : []) {
1616
+ if (!entry || !entry.id || !Array.isArray(entry.groups) || byId.has(String(entry.id))) continue;
1617
+ if (entry.groups.some(g => literals.has(Tags.tagKey(g)))) byId.set(String(entry.id), entry.groups.map(String));
1618
+ }
1619
+ if (!byId.size) return { history: items, ids: [] };
1620
+ const current = tagContext(settings, itemTagLists(items));
1621
+ const atCutover = { ...current, splits: groupSplitMap(settings.group_splits) };
1622
+ const flattenedAt = Number(settings.groups_flattened_at) || Infinity;
1623
+ const keys = list => list.map(Tags.tagKey).sort().join('\n');
1624
+ let next = null;
1625
+ const ids = [];
1626
+ items.forEach((item, i) => {
1627
+ const old = item && item.pin ? byId.get(itemKey(item)) : null;
1628
+ if (!old || groupState(item.pin).updatedAt > flattenedAt) return;
1629
+ const held = keys(groupsOf(item));
1630
+ if (held !== keys(normalizeTagList(old, atCutover))) return;
1631
+ const groups = normalizeTagList(old, current);
1632
+ if (keys(groups) === held) return;
1633
+ const pin = { ...item.pin };
1634
+ if (groups.length) pin.groups = groups;
1635
+ else delete pin.groups;
1636
+ if (!next) next = items.slice();
1637
+ next[i] = { ...item, pin };
1638
+ ids.push(itemKey(item));
1639
+ });
1640
+ return { history: next || items, ids };
1641
+ }
1642
+
1643
+ // A split record that reaches a device after its own cutover (another device's late
1644
+ // cutover) turns a name held here as one tag into its parts. The cutover's share rule
1645
+ // covers it too: a part stays shared with AI only if the retired name was shared here.
1646
+ // Only unshares, never shares. Returns { shared (the same array when nothing changes),
1647
+ // unshared }.
1648
+ function shareListAfterNewSplits(shared, newSplits, heldNames) {
1649
+ const list = Array.isArray(shared) ? shared : [];
1650
+ const held = new Set((heldNames || []).map(Tags.tagKey));
1651
+ const sharedKeys = new Set(list.map(Tags.tagKey));
1652
+ const drop = new Set();
1653
+ for (const record of newSplits || []) {
1654
+ const key = Tags.tagKey(record && record.name);
1655
+ if (!key || !held.has(key) || sharedKeys.has(key)) continue;
1656
+ for (const part of record.parts || []) if (sharedKeys.has(Tags.tagKey(part))) drop.add(Tags.tagKey(part));
1657
+ }
1658
+ if (!drop.size) return { shared: list, unshared: [] };
1659
+ return {
1660
+ shared: list.filter(name => !drop.has(Tags.tagKey(name))),
1661
+ unshared: list.filter(name => drop.has(Tags.tagKey(name))),
1662
+ };
1663
+ }
1664
+
1665
+ // AI share switches, synced per tag: { [tagKey]: { name, shared, at } }. The newest flip
1666
+ // wins; at the same instant unshared wins (never share by accident).
1667
+ function normalizeShareStamps(value) {
1668
+ const out = {};
1669
+ if (!value || typeof value !== 'object') return out;
1670
+ for (const [rawKey, stamp] of Object.entries(value)) {
1671
+ if (!stamp || typeof stamp !== 'object') continue;
1672
+ const name = Tags.cleanTagName(stamp.name || rawKey);
1673
+ const key = Tags.tagKey(name);
1674
+ const at = Number(stamp.at) || 0;
1675
+ if (!key || !at) continue;
1676
+ const next = { name, shared: !!stamp.shared, at };
1677
+ const existing = out[key];
1678
+ if (!existing || at > existing.at || (at === existing.at && existing.shared && !next.shared)) out[key] = next;
1679
+ }
1680
+ return out;
1681
+ }
1682
+
1683
+ function mergeShareStamps(local, remote) {
1684
+ const a = normalizeShareStamps(local);
1685
+ const b = normalizeShareStamps(remote);
1686
+ const out = { ...a };
1687
+ for (const [key, stamp] of Object.entries(b)) {
1688
+ const existing = out[key];
1689
+ if (!existing || stamp.at > existing.at || (stamp.at === existing.at && existing.shared && !stamp.shared)) out[key] = stamp;
1690
+ }
1691
+ return out;
1692
+ }
1693
+
1694
+ // A share list with every stamped flip applied (spellings: tagKey -> stored name).
1695
+ function applyShareStamps(list, stamps, spellings = null) {
1696
+ const current = Array.isArray(list) ? list : [];
1697
+ const normalized = normalizeShareStamps(stamps);
1698
+ const keys = Object.keys(normalized);
1699
+ if (!keys.length) return current;
1700
+ let next = current.filter(name => {
1701
+ const stamp = normalized[Tags.tagKey(name)];
1702
+ return !stamp || stamp.shared;
1703
+ });
1704
+ const present = new Set(next.map(Tags.tagKey));
1705
+ for (const key of keys) {
1706
+ const stamp = normalized[key];
1707
+ if (stamp.shared && !present.has(key)) {
1708
+ next.push((spellings && spellings.get(key)) || stamp.name);
1709
+ present.add(key);
1710
+ }
1711
+ }
1712
+ return sameNames(next, current) ? current : next;
1713
+ }
1714
+
1172
1715
  function planHistoryPrune(history, settings = {}, options = {}) {
1173
1716
  const now = options.now != null ? Number(options.now) : Date.now() / 1000;
1174
1717
  const maxAgeDays = Number(settings.max_age_days);
@@ -1196,9 +1739,11 @@ function isDestructivePrune(history, plan, options = {}) {
1196
1739
  return count >= minItems && count / total >= fraction;
1197
1740
  }
1198
1741
 
1199
- function mergeGroups(local, remote, groupTombstones = []) {
1200
- const deleted = groupTombstoneNames(groupTombstones);
1201
- return [...new Set([...(local || []), ...(remote || [])])].filter(g => !deleted.has(g));
1742
+ // settings.groups across a merge: one entry per tag in stored form (ctx: tagContext of
1743
+ // the merged settings), local order first. Without ctx: deleted tags dropped, one per tag.
1744
+ function mergeGroups(local, remote, groupTombstones = [], ctx = null) {
1745
+ const all = [...(local || []), ...(remote || [])];
1746
+ return normalizeTagList(all, ctx || tagContext({ group_tombstones: groupTombstones }, [all]));
1202
1747
  }
1203
1748
 
1204
1749
  module.exports = {
@@ -1235,7 +1780,6 @@ module.exports = {
1235
1780
  supersedeMap,
1236
1781
  tombstoneIds,
1237
1782
  tombstoneMap,
1238
- groupTombstoneNames,
1239
1783
  planHistoryPrune,
1240
1784
  isDestructivePrune,
1241
1785
  mergePins,
@@ -1244,6 +1788,27 @@ module.exports = {
1244
1788
  itemMutationClock,
1245
1789
  mergeHistories,
1246
1790
  mergeGroups,
1791
+ uniqueTags,
1792
+ groupTombstoneIndex,
1793
+ deleteStrips,
1794
+ normalizeGroupSplits,
1795
+ normalizeGroupLiterals,
1796
+ splitMapOf,
1797
+ rejoinLiterals,
1798
+ unsplitLiterals,
1799
+ pruneLiteralParts,
1800
+ shareListAfterNewSplits,
1801
+ AI_TAG_NAME,
1802
+ groupSplitMap,
1803
+ tagContext,
1804
+ itemTagLists,
1805
+ normalizeTagList,
1806
+ normalizeItemTags,
1807
+ tagInputNames,
1808
+ planTagState,
1809
+ normalizeShareStamps,
1810
+ mergeShareStamps,
1811
+ applyShareStamps,
1247
1812
  dedupeNumpadSlots,
1248
1813
  migrateLegacyNumpadSlots,
1249
1814
  applyTextEdit,