@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
package/lib/clipboard-model.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
364
|
-
const
|
|
365
|
-
if (
|
|
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 [...
|
|
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
|
-
|
|
444
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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 =
|
|
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 =
|
|
855
|
-
const cleanGroups =
|
|
856
|
-
.map(group =>
|
|
857
|
-
.filter(
|
|
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
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
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,
|