@tobq/boardclip 0.0.0-stage → 2.1.107
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +170 -2
- package/assets/boardclip-icon.png +0 -0
- package/bin/boardclip.js +43 -0
- package/editor-preload.js +71 -0
- package/editor.html +220 -0
- package/icon.png +0 -0
- package/icon@2x.png +0 -0
- package/iconTemplate.png +0 -0
- package/iconTemplate@2x.png +0 -0
- package/index.html +1339 -0
- package/lib/appearance.js +183 -0
- package/lib/auto-update.js +306 -0
- package/lib/backup-worker.js +46 -0
- package/lib/backup.js +275 -0
- package/lib/blob-field-store.js +275 -0
- package/lib/blob-store.js +68 -0
- package/lib/build-info.js +89 -0
- package/lib/clip-blob-store.js +161 -0
- package/lib/clip-revision.js +81 -0
- package/lib/clipboard-capture.js +95 -0
- package/lib/clipboard-follow.js +98 -0
- package/lib/clipboard-model.js +1235 -0
- package/lib/cloud-accounts.js +250 -0
- package/lib/conflict-model.js +186 -0
- package/lib/control-client.js +87 -0
- package/lib/control-server.js +149 -0
- package/lib/diagnostics.js +121 -0
- package/lib/drag-files.js +48 -0
- package/lib/ensure-directory.js +56 -0
- package/lib/fork-names.js +35 -0
- package/lib/fs-probe.js +132 -0
- package/lib/history-feed.js +48 -0
- package/lib/hmac-auth.js +78 -0
- package/lib/macos-hotkey.js +297 -0
- package/lib/macos-paste.js +112 -0
- package/lib/mcp-core.js +270 -0
- package/lib/mcp-installers.js +284 -0
- package/lib/mcp-paths.js +99 -0
- package/lib/p2p-crypto.js +54 -0
- package/lib/p2p-discovery.js +191 -0
- package/lib/quick-paste.js +187 -0
- package/lib/retention.js +43 -0
- package/lib/sync-delta.js +227 -0
- package/lib/sync-journal.js +181 -0
- package/lib/sync-paths.js +73 -0
- package/lib/tailscale.js +80 -0
- package/lib/text-blob-store.js +38 -0
- package/lib/windows-clipboard.js +213 -0
- package/lib/windows-console.js +63 -0
- package/lib/windows-dwm.js +183 -0
- package/lib/windows-hook-worker.js +236 -0
- package/lib/windows-hook.js +91 -0
- package/lib/windows-paste.js +185 -0
- package/main.js +7540 -0
- package/mcp/boardclip-mcp.js +255 -0
- package/mcp-approval-preload.js +22 -0
- package/mcp-approval.html +218 -0
- package/package.json +55 -4
- package/preload.js +106 -0
- package/site/index.html +1624 -0
- package/site/shared/clip-search.js +2330 -0
- package/site/shared/clipboard-popup.css +1046 -0
- package/site/shared/clipboard-tokens.css +326 -0
- package/site/shared/clipboard-ui-core.js +6921 -0
- package/site/shared/clipboard-window.css +36 -0
- package/site/shared/vendor/cm5/README.md +52 -0
- package/site/shared/vendor/cm5/codemirror.css +344 -0
- package/site/shared/vendor/cm5/codemirror.js +9884 -0
- package/site/shared/vendor/cm5/diff-match-patch.js +2228 -0
- package/site/shared/vendor/cm5/merge.css +119 -0
- package/site/shared/vendor/cm5/merge.js +1182 -0
- package/site/shared/vendor/fonts/README.md +19 -0
- package/site/shared/vendor/fonts/material-symbols-rounded.woff2 +0 -0
- package/viewer-preload.js +51 -0
- package/viewer.html +129 -0
|
@@ -0,0 +1,1235 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_SETTINGS = {
|
|
6
|
+
max_age_days: 7,
|
|
7
|
+
max_size_gb: 10,
|
|
8
|
+
// The search field's Regex toggle (on: what you type becomes /pattern/) and
|
|
9
|
+
// the editor find bar's Regex + match case. Per device, never synced
|
|
10
|
+
// (main's remoteSettingsPayload).
|
|
11
|
+
regex_search: false,
|
|
12
|
+
// How a second value of a kind joins the first in the search ({ group, type }:
|
|
13
|
+
// 'and' when the user last flipped to and; missing = or). Per device.
|
|
14
|
+
facet_joins: {},
|
|
15
|
+
find_regex: false,
|
|
16
|
+
find_case: false,
|
|
17
|
+
// Rich clipboard capture (HTML + RTF alongside the plain text). Per-machine,
|
|
18
|
+
// like the other "how THIS machine drives the OS" knobs, and never synced.
|
|
19
|
+
// Turning it off stops NEW captures carrying formatting; clips that already
|
|
20
|
+
// have it keep it, because the payload is what was on the clipboard at the
|
|
21
|
+
// moment of capture and cannot be re-derived afterwards.
|
|
22
|
+
capture_rich: true,
|
|
23
|
+
theme_mode: 'system', // 'system' | 'light' | 'dark' — popup appearance
|
|
24
|
+
// Appearance (lib/appearance.js validates every key). Per machine: the
|
|
25
|
+
// surface (glass support is hardware-dependent), which windows get the glass,
|
|
26
|
+
// and the audit-only borders axis. Synced, newest change wins per group via
|
|
27
|
+
// appearance_stamps: the accent choice (System resolves per device), density
|
|
28
|
+
// and corners.
|
|
29
|
+
surface_style: 'auto', // 'auto' | 'glass' | 'solid' - auto = glass where supported
|
|
30
|
+
glass_scope: 'popup', // 'popup' | 'all' - glass on the popup only, or every window
|
|
31
|
+
accent_mode: 'system', // 'system' | 'blue' | 'teal' | 'mono' | 'custom'
|
|
32
|
+
accent_custom: '', // '#rrggbb' for accent_mode 'custom'
|
|
33
|
+
ui_density: 'normal', // 'normal' | 'compact'
|
|
34
|
+
ui_corners: 'soft', // 'soft' | 'sharp'
|
|
35
|
+
ui_borders: 'bordered', // 'bordered' | 'borderless' (BOARDCLIP_DEBUG_VARIANTS only)
|
|
36
|
+
appearance_stamps: {}, // { accent, density, corners }: ms of the last local change
|
|
37
|
+
// Height (px) image previews render at in the popup list (Ctrl+wheel /
|
|
38
|
+
// Ctrl+=/-/0 / Settings). Per-machine like the other display knobs, not synced.
|
|
39
|
+
image_preview_height: 60,
|
|
40
|
+
// Max height (px) of the popup's search options panel, dragged on its bottom
|
|
41
|
+
// edge. 0 = the default (~40 % of the popup). Per machine, not synced.
|
|
42
|
+
options_panel_height: 0,
|
|
43
|
+
show_shortcut: '',
|
|
44
|
+
quick_paste_shortcut: '',
|
|
45
|
+
// Quick-paste (numpad slots + panel number keys) behaviour. Per-machine, not
|
|
46
|
+
// synced — these are about how THIS machine drives the OS paste, not content.
|
|
47
|
+
// 'clipboard' = the real paste API (set clipboard + Ctrl/Cmd+V + restore),
|
|
48
|
+
// made robust by lib/quick-paste.js (serialize, verify-the-write-landed,
|
|
49
|
+
// safe + lag-adaptive restore). 'type' injects keystrokes (no clipboard, no
|
|
50
|
+
// restore race) but is slower and turns newlines into real Enter presses, so
|
|
51
|
+
// it is opt-in only. Default is real paste.
|
|
52
|
+
quick_paste_restore: true, // restore the previous clipboard after a quick-paste
|
|
53
|
+
quick_paste_restore_delay_ms: 400, // floor delay before restore; adapts up under lag
|
|
54
|
+
groups: [],
|
|
55
|
+
sync_path: '',
|
|
56
|
+
sync_custom_paths: [],
|
|
57
|
+
sync_disabled_paths: [],
|
|
58
|
+
p2p_enabled: true,
|
|
59
|
+
p2p_device_id: '',
|
|
60
|
+
p2p_secret: '',
|
|
61
|
+
p2p_endpoints: {}, // synced: where each device can be reached (lan/tailnet ips + port)
|
|
62
|
+
p2p_pinned_peers: [], // synced: manual host[:port] entries to announce to / probe
|
|
63
|
+
diagnostics_enabled: false,
|
|
64
|
+
popup_size: null,
|
|
65
|
+
editor_bounds: null, // built-in editor window bounds (per-machine, not synced)
|
|
66
|
+
merge_bounds: null, // unify / conflict window bounds (per-machine, not synced)
|
|
67
|
+
viewer_bounds: null, // built-in image viewer bounds (per-machine, not synced)
|
|
68
|
+
tombstones: [],
|
|
69
|
+
group_tombstones: [],
|
|
70
|
+
// Edit lineage for content-hash ids. Every text edit changes id, so sync needs
|
|
71
|
+
// to know old-id -> new-id is a rename/edit, not a hard delete tombstone.
|
|
72
|
+
supersedes: [],
|
|
73
|
+
// AI Access (local MCP server). See lib/mcp-core.js and the MCP section in main.js.
|
|
74
|
+
ai_access_enabled: false,
|
|
75
|
+
groups_shared_with_ai: [], // group names whose clips are exposed to AI (curation allowlist)
|
|
76
|
+
ai_always_allow: [], // MCP tool names the user chose to always allow (skip approval modal)
|
|
77
|
+
ai_approval_timeout_sec: 60, // approval modal deny-by-default countdown
|
|
78
|
+
mcp_secret: '', // per-machine control-channel HMAC secret (not synced)
|
|
79
|
+
// Source-checkout update mode is developer-only and never synced. Production
|
|
80
|
+
// installs do not expose or persist this escape hatch.
|
|
81
|
+
update_mode: 'production', // 'production' | 'development'
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const TOMBSTONE_MAX_AGE_MS = 30 * 86400 * 1000;
|
|
85
|
+
const TEXT_HASH_RE = /^[a-f0-9]{64}$/i;
|
|
86
|
+
const DEFAULT_DESTRUCTIVE_PRUNE_FRACTION = 0.25;
|
|
87
|
+
|
|
88
|
+
function valueHash(value) {
|
|
89
|
+
return crypto.createHash('sha256').update(String(value || ''), 'utf8').digest('hex');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function textHashForText(text) {
|
|
93
|
+
return valueHash(text);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Field-name vocabulary for ONE content-addressed clipboard payload, so the
|
|
97
|
+
// blob store's mechanics can be written once and instantiated per format.
|
|
98
|
+
// `text` is the IDENTITY payload - a clip's id is the hash of its text - while
|
|
99
|
+
// `html`/`rtf` are auxiliary formats describing that same content.
|
|
100
|
+
function blobFieldNames(field) {
|
|
101
|
+
const name = String(field || '');
|
|
102
|
+
return {
|
|
103
|
+
value: name,
|
|
104
|
+
hash: `${name}Hash`,
|
|
105
|
+
ref: `${name}Ref`,
|
|
106
|
+
size: `${name}Size`,
|
|
107
|
+
preview: `${name}Preview`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const TEXT_BLOB_FIELD_NAMES = blobFieldNames('text');
|
|
112
|
+
const RICH_BLOB_FIELDS = ['html', 'rtf'];
|
|
113
|
+
|
|
114
|
+
function normalizedTextHash(hash) {
|
|
115
|
+
const value = String(hash || '').trim().toLowerCase();
|
|
116
|
+
return TEXT_HASH_RE.test(value) ? value : '';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function itemTextHash(item) {
|
|
120
|
+
if (!item || item.type === 'image') return '';
|
|
121
|
+
return normalizedTextHash(item.textHash) || textHashForText(item.text || '');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function textByteLength(text) {
|
|
125
|
+
return Buffer.byteLength(String(text || ''), 'utf8');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function hasVerifiedFullValue(item, names) {
|
|
129
|
+
if (!item || item.type === 'image') return false;
|
|
130
|
+
const value = String(item[names.value] || '');
|
|
131
|
+
const hash = normalizedTextHash(item[names.hash]);
|
|
132
|
+
if (!item[names.ref] && !hash) return true;
|
|
133
|
+
if (hash) return valueHash(value) === hash;
|
|
134
|
+
const size = Number(item[names.size]) || 0;
|
|
135
|
+
return !size || textByteLength(value) >= size;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function itemHasVerifiedFullText(item) {
|
|
139
|
+
return hasVerifiedFullValue(item, TEXT_BLOB_FIELD_NAMES);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function textPayloadScore(item) {
|
|
143
|
+
if (!item || item.type === 'image') return -1;
|
|
144
|
+
if (itemHasVerifiedFullText(item)) return 3;
|
|
145
|
+
if (item.text && !item.textRef) return 2;
|
|
146
|
+
if (item.textHash || item.textRef) return 1;
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function migrateItemPin(item) {
|
|
151
|
+
if ('pin' in item) return item;
|
|
152
|
+
const pin = {};
|
|
153
|
+
let pinned = false;
|
|
154
|
+
if (typeof item.pinned === 'number') {
|
|
155
|
+
pin.number = item.pinned;
|
|
156
|
+
pinned = true;
|
|
157
|
+
} else if (item.pinned === true) {
|
|
158
|
+
pinned = true;
|
|
159
|
+
}
|
|
160
|
+
if (item.group) {
|
|
161
|
+
pin.groups = [item.group];
|
|
162
|
+
pinned = true;
|
|
163
|
+
}
|
|
164
|
+
item.pin = pinned ? pin : null;
|
|
165
|
+
delete item.pinned;
|
|
166
|
+
delete item.group;
|
|
167
|
+
return item;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isPinned(item) { return item.pin != null; }
|
|
171
|
+
function numpadSlotOf(item) {
|
|
172
|
+
return item.pin && typeof item.pin.number === 'number' ? item.pin.number : null;
|
|
173
|
+
}
|
|
174
|
+
function groupsOf(item) {
|
|
175
|
+
return item.pin && Array.isArray(item.pin.groups) ? item.pin.groups : [];
|
|
176
|
+
}
|
|
177
|
+
function hasNumpadSlot(item, n) { return numpadSlotOf(item) === n; }
|
|
178
|
+
function ensurePin(item) {
|
|
179
|
+
if (!item.pin) item.pin = {};
|
|
180
|
+
return item.pin;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function setInlineText(item, text) {
|
|
184
|
+
if (!item || item.type === 'image') return item;
|
|
185
|
+
item.text = String(text || '');
|
|
186
|
+
delete item.textHash;
|
|
187
|
+
delete item.textRef;
|
|
188
|
+
delete item.textSize;
|
|
189
|
+
delete item.textPreview;
|
|
190
|
+
// The rich payloads describe the text that was just replaced. Keeping them
|
|
191
|
+
// would paste the PRE-EDIT content into any app that prefers HTML/RTF - the
|
|
192
|
+
// edit would look silently ignored. Drop them; plain text is then the only
|
|
193
|
+
// format offered, which is correct for text the user just typed.
|
|
194
|
+
for (const field of RICH_BLOB_FIELDS) {
|
|
195
|
+
const names = blobFieldNames(field);
|
|
196
|
+
delete item[names.value];
|
|
197
|
+
delete item[names.hash];
|
|
198
|
+
delete item[names.ref];
|
|
199
|
+
delete item[names.size];
|
|
200
|
+
}
|
|
201
|
+
return item;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function cleanTitle(value) {
|
|
205
|
+
return String(value == null ? '' : value).replace(/\s+/g, ' ').trim().slice(0, 240);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function titleOf(item) {
|
|
209
|
+
return cleanTitle(item && item.title);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function titleUpdatedAt(item) {
|
|
213
|
+
if (!item || item.type === 'image') return 0;
|
|
214
|
+
return timestamp(item.titleUpdatedAt) || (titleOf(item) ? itemUpdatedAt(item) : 0);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function setTitleMetadata(item, title, updatedAt = Date.now()) {
|
|
218
|
+
if (!item || item.type === 'image') return item;
|
|
219
|
+
const nextTitle = cleanTitle(title);
|
|
220
|
+
const nextUpdatedAt = timestamp(updatedAt) || Date.now();
|
|
221
|
+
if (nextTitle) item.title = nextTitle;
|
|
222
|
+
else delete item.title;
|
|
223
|
+
item.titleUpdatedAt = nextUpdatedAt;
|
|
224
|
+
if (timestamp(item.updatedAt) < nextUpdatedAt) item.updatedAt = nextUpdatedAt;
|
|
225
|
+
return item;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function titleState(item) {
|
|
229
|
+
const title = titleOf(item);
|
|
230
|
+
const updatedAt = titleUpdatedAt(item);
|
|
231
|
+
return { title, updatedAt, known: !!title || updatedAt > 0 };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function mergeTitleMetadata(base, localItem, remoteItem) {
|
|
235
|
+
if (!base || base.type === 'image') return base;
|
|
236
|
+
const local = titleState(localItem);
|
|
237
|
+
const remote = titleState(remoteItem);
|
|
238
|
+
let winner = null;
|
|
239
|
+
if (local.known && remote.known) {
|
|
240
|
+
if (remote.updatedAt > local.updatedAt) winner = remote;
|
|
241
|
+
else if (local.updatedAt > remote.updatedAt) winner = local;
|
|
242
|
+
else winner = remote.title.length >= local.title.length ? remote : local;
|
|
243
|
+
} else if (local.known) winner = local;
|
|
244
|
+
else if (remote.known) winner = remote;
|
|
245
|
+
|
|
246
|
+
if (!winner) {
|
|
247
|
+
delete base.title;
|
|
248
|
+
delete base.titleUpdatedAt;
|
|
249
|
+
return base;
|
|
250
|
+
}
|
|
251
|
+
if (winner.title) base.title = winner.title;
|
|
252
|
+
else delete base.title;
|
|
253
|
+
if (winner.updatedAt) base.titleUpdatedAt = winner.updatedAt;
|
|
254
|
+
else delete base.titleUpdatedAt;
|
|
255
|
+
return base;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function itemTextForConflict(item) {
|
|
259
|
+
if (!item || item.type === 'image') return '';
|
|
260
|
+
return String(item.text != null ? item.text : (item.textPreview || ''));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function conflictSnapshot(item, fallback = {}) {
|
|
264
|
+
const source = item || {};
|
|
265
|
+
return {
|
|
266
|
+
id: (item ? itemKey(source) : '') || fallback.id || '',
|
|
267
|
+
type: source.type === 'image' ? 'image' : 'text',
|
|
268
|
+
title: titleOf(source) || cleanTitle(fallback.title),
|
|
269
|
+
text: itemTextForConflict(source) || String(fallback.text || ''),
|
|
270
|
+
groups: groupsOf(source),
|
|
271
|
+
ts: timestamp(source.ts) || timestamp(fallback.ts),
|
|
272
|
+
updatedAt: timestamp(source.updatedAt) || timestamp(fallback.updatedAt),
|
|
273
|
+
titleUpdatedAt: titleUpdatedAt(source) || timestamp(fallback.titleUpdatedAt),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function titleConflict(localItem, remoteItem) {
|
|
278
|
+
if (!localItem || !remoteItem || localItem.type === 'image' || remoteItem.type === 'image') return false;
|
|
279
|
+
if (itemKey(localItem) !== itemKey(remoteItem)) return false;
|
|
280
|
+
const local = titleState(localItem);
|
|
281
|
+
const remote = titleState(remoteItem);
|
|
282
|
+
return local.known && remote.known && local.title !== remote.title;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function legacyContentKey(item) {
|
|
286
|
+
if (item.type === 'image') return `img:${item.image}`;
|
|
287
|
+
return `txt:${itemTextHash(item)}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function ensureItemId(item) {
|
|
291
|
+
if (!item.id) item.id = legacyContentKey(item);
|
|
292
|
+
return item.id;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function itemKey(item) {
|
|
296
|
+
return item && (item.id || legacyContentKey(item));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// A clip's REVISION: a short hash over everything a mutation can change -
|
|
300
|
+
// content (text hash / image file), title, pin, groups, numpad slot. Derived,
|
|
301
|
+
// never stored, so it cannot go stale; ordering/clock/blob-ref fields are
|
|
302
|
+
// excluded so capture-time corrections and blob hydration never bump it.
|
|
303
|
+
// Mutations carry the rev they read and are refused if it moved (clip-revision).
|
|
304
|
+
function clipRevision(item) {
|
|
305
|
+
if (!item) return '';
|
|
306
|
+
const content = item.type === 'image' ? `img:${item.image || ''}` : `txt:${itemTextHash(item)}`;
|
|
307
|
+
const groups = [...groupsOf(item)].map(String).sort();
|
|
308
|
+
return valueHash(JSON.stringify([content, titleOf(item), isPinned(item), groups, numpadSlotOf(item)])).slice(0, 16);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// A tombstone may carry the rev + mutation clock of the exact copy it deleted.
|
|
312
|
+
// The pair travels together or not at all (legacy tombstones have neither).
|
|
313
|
+
function tombstoneRecord(source, deletedAt) {
|
|
314
|
+
const record = { id: String(source.id), deletedAt };
|
|
315
|
+
const revClock = Number(source.revClock);
|
|
316
|
+
if (typeof source.rev === 'string' && source.rev && Number.isFinite(revClock)) {
|
|
317
|
+
record.rev = source.rev;
|
|
318
|
+
record.revClock = revClock;
|
|
319
|
+
}
|
|
320
|
+
return record;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Does this tombstone suppress this copy of the clip during a merge?
|
|
324
|
+
// - clock rule (all tombstones): a copy not mutated after the delete stays deleted;
|
|
325
|
+
// - version rule (rev-carrying tombstones): the EXACT version that was deleted
|
|
326
|
+
// stays deleted even when clock skew makes it look newer than the delete.
|
|
327
|
+
// A genuinely newer copy - edited elsewhere (different rev) or re-added later
|
|
328
|
+
// (newer clock) - survives.
|
|
329
|
+
function tombstoneSuppresses(tombstone, item) {
|
|
330
|
+
if (!tombstone || !item) return false;
|
|
331
|
+
const clock = itemMutationClock(item);
|
|
332
|
+
if (clock <= (Number(tombstone.deletedAt) || 0)) return true;
|
|
333
|
+
return !!tombstone.rev && clipRevision(item) === tombstone.rev && clock <= tombstone.revClock;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function normalizeTombstones(list, now = Date.now()) {
|
|
337
|
+
const cutoff = now - TOMBSTONE_MAX_AGE_MS;
|
|
338
|
+
const byId = new Map();
|
|
339
|
+
for (const tombstone of Array.isArray(list) ? list : []) {
|
|
340
|
+
if (!tombstone || !tombstone.id) continue;
|
|
341
|
+
const deletedAt = Number(tombstone.deletedAt) || 0;
|
|
342
|
+
if (deletedAt < cutoff) continue;
|
|
343
|
+
const existing = byId.get(tombstone.id);
|
|
344
|
+
if (!existing || deletedAt > existing.deletedAt) byId.set(tombstone.id, tombstoneRecord(tombstone, deletedAt));
|
|
345
|
+
}
|
|
346
|
+
return [...byId.values()];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function normalizeGroupTombstones(list, now = Date.now()) {
|
|
350
|
+
const cutoff = now - TOMBSTONE_MAX_AGE_MS;
|
|
351
|
+
const byName = new Map();
|
|
352
|
+
for (const tombstone of Array.isArray(list) ? list : []) {
|
|
353
|
+
if (!tombstone || !tombstone.name) continue;
|
|
354
|
+
const name = String(tombstone.name);
|
|
355
|
+
const deletedAt = Number(tombstone.deletedAt) || 0;
|
|
356
|
+
if (deletedAt < cutoff) continue;
|
|
357
|
+
const existing = byName.get(name);
|
|
358
|
+
if (!existing || deletedAt > existing.deletedAt) byName.set(name, { name, deletedAt });
|
|
359
|
+
}
|
|
360
|
+
return [...byName.values()];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function normalizeSupersedes(list, now = Date.now()) {
|
|
364
|
+
const cutoff = now - TOMBSTONE_MAX_AGE_MS;
|
|
365
|
+
const byFrom = new Map();
|
|
366
|
+
for (const record of Array.isArray(list) ? list : []) {
|
|
367
|
+
if (!record || !record.from || !record.to || record.from === record.to) continue;
|
|
368
|
+
const updatedAt = Number(record.updatedAt) || 0;
|
|
369
|
+
if (updatedAt < cutoff) continue;
|
|
370
|
+
const from = String(record.from);
|
|
371
|
+
const to = String(record.to);
|
|
372
|
+
const existing = byFrom.get(from);
|
|
373
|
+
if (!existing || updatedAt > existing.updatedAt) byFrom.set(from, { from, to, updatedAt });
|
|
374
|
+
}
|
|
375
|
+
return [...byFrom.values()];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// from -> link record, minus links whose `from` was re-created by a NEWER link
|
|
379
|
+
// into it. Text ids are content hashes, so typing a change and taking it back
|
|
380
|
+
// inside one editor session (A->B->A) records A->B and later B->A: A is the
|
|
381
|
+
// live text again and A->B no longer describes it. Left in, the pair is a cycle
|
|
382
|
+
// that resolves the live head to itself, and the head's own older edit
|
|
383
|
+
// tombstone then dropped it on every merge (2026-09-29: a pinned note vanished
|
|
384
|
+
// within one sync pass and two stale copies took its place).
|
|
385
|
+
function liveSupersedeLinks(list) {
|
|
386
|
+
const records = normalizeSupersedes(list);
|
|
387
|
+
const recreatedAt = new Map();
|
|
388
|
+
for (const record of records) recreatedAt.set(record.to, Math.max(recreatedAt.get(record.to) || 0, record.updatedAt));
|
|
389
|
+
return new Map(records
|
|
390
|
+
.filter(record => !((recreatedAt.get(record.from) || 0) > record.updatedAt))
|
|
391
|
+
.map(record => [record.from, record]));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function resolveSupersedeLinks(links) {
|
|
395
|
+
const resolved = new Map();
|
|
396
|
+
const resolve = (id, seen = new Set()) => {
|
|
397
|
+
const value = String(id || '');
|
|
398
|
+
if (!links.has(value) || seen.has(value)) return value;
|
|
399
|
+
seen.add(value);
|
|
400
|
+
return resolve(links.get(value).to, seen);
|
|
401
|
+
};
|
|
402
|
+
for (const from of links.keys()) {
|
|
403
|
+
const to = resolve(from);
|
|
404
|
+
// A cycle that survives the filter (links in the same ms) has no newest
|
|
405
|
+
// version to point at, so it carries no lineage at all.
|
|
406
|
+
if (to !== from) resolved.set(from, to);
|
|
407
|
+
}
|
|
408
|
+
return resolved;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function supersedeMap(list) {
|
|
412
|
+
return resolveSupersedeLinks(liveSupersedeLinks(list));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// An edit writes its old-id tombstone in the same instant as the supersede
|
|
416
|
+
// link. A tombstone well AFTER the link (or one carrying the deleted copy's
|
|
417
|
+
// rev, which only explicit deletes record) is the user deleting a stale copy
|
|
418
|
+
// that came back; a broken lineage chain must not overrule that delete.
|
|
419
|
+
const EDIT_TOMBSTONE_SLACK_MS = 5000;
|
|
420
|
+
function deletedAfterEdit(tombstone, link) {
|
|
421
|
+
if (!tombstone || !link) return false;
|
|
422
|
+
return !!tombstone.rev || tombstone.deletedAt > link.updatedAt + EDIT_TOMBSTONE_SLACK_MS;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function tombstoneIds(list) {
|
|
426
|
+
return new Set(normalizeTombstones(list).map(t => t.id));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// id -> tombstone record ({ deletedAt, rev?, revClock? }). Lets a merge compare a delete's timestamp against an
|
|
430
|
+
// item's own mutation clock so a legitimately NEWER re-add/edit beats a stale
|
|
431
|
+
// tombstone, while a stale-provider original stays deleted (no resurrection).
|
|
432
|
+
function tombstoneMap(list) {
|
|
433
|
+
return new Map(normalizeTombstones(list).map(t => [t.id, t]));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function groupTombstoneNames(list) {
|
|
437
|
+
return new Set(normalizeGroupTombstones(list).map(t => t.name));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function timestamp(value) {
|
|
441
|
+
const n = Number(value) || 0;
|
|
442
|
+
return Number.isFinite(n) ? n : 0;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function timestampMs(value) {
|
|
446
|
+
const n = timestamp(value);
|
|
447
|
+
if (!n) return 0;
|
|
448
|
+
return n < 100000000000 ? n * 1000 : n;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function pinUpdatedAt(item) {
|
|
452
|
+
if (!item) return 0;
|
|
453
|
+
const pin = item.pin || null;
|
|
454
|
+
return Math.max(
|
|
455
|
+
timestamp(item.pinUpdatedAt),
|
|
456
|
+
pin ? timestamp(pin.updatedAt) : 0,
|
|
457
|
+
pin ? timestamp(pin.numberUpdatedAt) : 0,
|
|
458
|
+
pin ? timestamp(pin.groupsUpdatedAt) : 0
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function pinMetadataUpdatedAt(pin, fallback = 0) {
|
|
463
|
+
return pin ? timestamp(pin.updatedAt) || timestamp(fallback) : timestamp(fallback);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function groupState(pin, fallback = 0) {
|
|
467
|
+
if (!pin) return { known: false, groups: [], updatedAt: 0 };
|
|
468
|
+
const groups = Array.isArray(pin.groups) ? pin.groups : [];
|
|
469
|
+
const explicitUpdatedAt = timestamp(pin.groupsUpdatedAt);
|
|
470
|
+
const updatedAt = explicitUpdatedAt || (groups.length ? pinMetadataUpdatedAt(pin, fallback) : 0);
|
|
471
|
+
return {
|
|
472
|
+
known: groups.length > 0 || explicitUpdatedAt > 0,
|
|
473
|
+
explicit: explicitUpdatedAt > 0,
|
|
474
|
+
groups,
|
|
475
|
+
updatedAt,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function numberState(pin, fallback = 0) {
|
|
480
|
+
if (!pin) return { known: false, number: null, updatedAt: 0 };
|
|
481
|
+
const hasNumber = typeof pin.number === 'number';
|
|
482
|
+
const explicitUpdatedAt = timestamp(pin.numberUpdatedAt);
|
|
483
|
+
const updatedAt = explicitUpdatedAt || (hasNumber ? pinMetadataUpdatedAt(pin, fallback) : 0);
|
|
484
|
+
return {
|
|
485
|
+
known: hasNumber || explicitUpdatedAt > 0,
|
|
486
|
+
number: hasNumber ? pin.number : null,
|
|
487
|
+
updatedAt,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function cleanPin(pin, deletedGroups = new Set()) {
|
|
492
|
+
if (!pin) return null;
|
|
493
|
+
const cleaned = { ...pin };
|
|
494
|
+
if (typeof cleaned.number !== 'number') delete cleaned.number;
|
|
495
|
+
if (Array.isArray(cleaned.groups)) {
|
|
496
|
+
cleaned.groups = cleaned.groups.filter(g => !deletedGroups.has(g));
|
|
497
|
+
if (!cleaned.groups.length) delete cleaned.groups;
|
|
498
|
+
}
|
|
499
|
+
return cleaned;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function mergeGroupState(localPin, remotePin, localUpdatedAt, remoteUpdatedAt, deletedGroups) {
|
|
503
|
+
const local = groupState(localPin, localUpdatedAt);
|
|
504
|
+
const remote = groupState(remotePin, remoteUpdatedAt);
|
|
505
|
+
let groups = [];
|
|
506
|
+
let updatedAt = 0;
|
|
507
|
+
|
|
508
|
+
if (local.known && remote.known) {
|
|
509
|
+
updatedAt = Math.max(local.updatedAt, remote.updatedAt);
|
|
510
|
+
if (!local.explicit && !remote.explicit) groups = [...new Set([...local.groups, ...remote.groups])];
|
|
511
|
+
else if (local.updatedAt > remote.updatedAt) groups = local.groups;
|
|
512
|
+
else if (remote.updatedAt > local.updatedAt) groups = remote.groups;
|
|
513
|
+
else groups = [...new Set([...local.groups, ...remote.groups])];
|
|
514
|
+
} else if (local.known) {
|
|
515
|
+
groups = local.groups;
|
|
516
|
+
updatedAt = local.updatedAt;
|
|
517
|
+
} else if (remote.known) {
|
|
518
|
+
groups = remote.groups;
|
|
519
|
+
updatedAt = remote.updatedAt;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
groups = [...new Set(groups)].filter(g => !deletedGroups.has(g));
|
|
523
|
+
return { known: local.known || remote.known, groups, updatedAt };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function mergeNumberState(localPin, remotePin, localUpdatedAt, remoteUpdatedAt) {
|
|
527
|
+
const local = numberState(localPin, localUpdatedAt);
|
|
528
|
+
const remote = numberState(remotePin, remoteUpdatedAt);
|
|
529
|
+
if (local.known && remote.known) {
|
|
530
|
+
const updatedAt = Math.max(local.updatedAt, remote.updatedAt);
|
|
531
|
+
if (remote.updatedAt > local.updatedAt) return { ...remote, updatedAt };
|
|
532
|
+
if (local.updatedAt > remote.updatedAt) return { ...local, updatedAt };
|
|
533
|
+
if (local.number == null && remote.number != null) return { ...remote, updatedAt };
|
|
534
|
+
if (remote.number == null && local.number != null) return { ...local, updatedAt };
|
|
535
|
+
return (remote.number || 0) > (local.number || 0) ? { ...remote, updatedAt } : { ...local, updatedAt };
|
|
536
|
+
}
|
|
537
|
+
if (local.known) return local;
|
|
538
|
+
if (remote.known) return remote;
|
|
539
|
+
return { known: false, number: null, updatedAt: 0 };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function mergePins(localPin, remotePin, localUpdatedAt = 0, remoteUpdatedAt = 0, groupTombstones = []) {
|
|
543
|
+
if (!localPin && !remotePin) return null;
|
|
544
|
+
const deletedGroups = groupTombstoneNames(groupTombstones);
|
|
545
|
+
const localPinUpdated = pinMetadataUpdatedAt(localPin, localUpdatedAt);
|
|
546
|
+
const remotePinUpdated = pinMetadataUpdatedAt(remotePin, remoteUpdatedAt);
|
|
547
|
+
|
|
548
|
+
if (!localPin && remotePin) return timestamp(localUpdatedAt) > remotePinUpdated ? null : cleanPin(remotePin, deletedGroups);
|
|
549
|
+
if (localPin && !remotePin) return timestamp(remoteUpdatedAt) > localPinUpdated ? null : cleanPin(localPin, deletedGroups);
|
|
550
|
+
|
|
551
|
+
const merged = {};
|
|
552
|
+
const groups = mergeGroupState(localPin, remotePin, localPinUpdated, remotePinUpdated, deletedGroups);
|
|
553
|
+
if (groups.groups.length) merged.groups = groups.groups;
|
|
554
|
+
if (groups.known && groups.updatedAt) merged.groupsUpdatedAt = groups.updatedAt;
|
|
555
|
+
|
|
556
|
+
const number = mergeNumberState(localPin, remotePin, localPinUpdated, remotePinUpdated);
|
|
557
|
+
if (number.number != null) merged.number = number.number;
|
|
558
|
+
if (number.known && number.updatedAt) merged.numberUpdatedAt = number.updatedAt;
|
|
559
|
+
|
|
560
|
+
const updatedAt = Math.max(localPinUpdated, remotePinUpdated, groups.updatedAt, number.updatedAt);
|
|
561
|
+
if (updatedAt) merged.updatedAt = updatedAt;
|
|
562
|
+
return merged;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function mergeItemTimestamp(localItem, remoteItem, base) {
|
|
566
|
+
const localTs = timestamp(localItem.ts);
|
|
567
|
+
const remoteTs = timestamp(remoteItem.ts);
|
|
568
|
+
const localTsUpdated = timestamp(localItem.tsUpdatedAt);
|
|
569
|
+
const remoteTsUpdated = timestamp(remoteItem.tsUpdatedAt);
|
|
570
|
+
if (localTsUpdated || remoteTsUpdated) {
|
|
571
|
+
if (remoteTsUpdated > localTsUpdated) base.ts = remoteTs;
|
|
572
|
+
else if (localTsUpdated > remoteTsUpdated) base.ts = localTs;
|
|
573
|
+
else base.ts = Math.max(localTs, remoteTs); // deterministic clock-collision tie-break
|
|
574
|
+
base.tsUpdatedAt = Math.max(localTsUpdated, remoteTsUpdated) || undefined;
|
|
575
|
+
} else {
|
|
576
|
+
base.ts = Math.max(localTs, remoteTs);
|
|
577
|
+
delete base.tsUpdatedAt;
|
|
578
|
+
}
|
|
579
|
+
return base;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function mergeItems(localItem, remoteItem, groupTombstones = []) {
|
|
583
|
+
migrateItemPin(localItem);
|
|
584
|
+
migrateItemPin(remoteItem);
|
|
585
|
+
ensureItemId(localItem);
|
|
586
|
+
ensureItemId(remoteItem);
|
|
587
|
+
const localTs = localItem.ts || 0;
|
|
588
|
+
const remoteTs = remoteItem.ts || 0;
|
|
589
|
+
const localUpdated = localItem.updatedAt || localTs;
|
|
590
|
+
const remoteUpdated = remoteItem.updatedAt || remoteTs;
|
|
591
|
+
const localPinUpdated = pinUpdatedAt(localItem);
|
|
592
|
+
const remotePinUpdated = pinUpdatedAt(remoteItem);
|
|
593
|
+
const base = remoteUpdated > localUpdated ? { ...remoteItem } : { ...localItem };
|
|
594
|
+
base.id = itemKey(base);
|
|
595
|
+
// tsUpdatedAt is the LWW clock for an intentional capture-time correction or
|
|
596
|
+
// recapture. Legacy replicas have no clock, so a repaired timestamp cannot be
|
|
597
|
+
// re-clobbered by an old provider that still carries the inflated value.
|
|
598
|
+
mergeItemTimestamp(localItem, remoteItem, base);
|
|
599
|
+
base.updatedAt = Math.max(localItem.updatedAt || 0, remoteItem.updatedAt || 0) || undefined;
|
|
600
|
+
base.pinUpdatedAt = Math.max(localPinUpdated, remotePinUpdated) || undefined;
|
|
601
|
+
base.pin = mergePins(
|
|
602
|
+
localItem.pin,
|
|
603
|
+
remoteItem.pin,
|
|
604
|
+
localPinUpdated,
|
|
605
|
+
remotePinUpdated,
|
|
606
|
+
groupTombstones
|
|
607
|
+
);
|
|
608
|
+
mergeTextPayload(base, localItem, remoteItem);
|
|
609
|
+
mergeTitleMetadata(base, localItem, remoteItem);
|
|
610
|
+
return base;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function mergeSupersededStaleIntoTarget(targetItem, staleItem, groupTombstones = []) {
|
|
614
|
+
// The stale item is an older content-hash id in the same edit lineage. It may
|
|
615
|
+
// still carry useful pin/title metadata, but its TEXT must never overwrite the
|
|
616
|
+
// newer target text: that is the exact stale-provider regression this fixes.
|
|
617
|
+
migrateItemPin(targetItem);
|
|
618
|
+
migrateItemPin(staleItem);
|
|
619
|
+
ensureItemId(targetItem);
|
|
620
|
+
ensureItemId(staleItem);
|
|
621
|
+
const base = { ...targetItem };
|
|
622
|
+
const targetPinUpdated = pinUpdatedAt(targetItem);
|
|
623
|
+
const stalePinUpdated = pinUpdatedAt(staleItem);
|
|
624
|
+
base.id = itemKey(targetItem);
|
|
625
|
+
mergeItemTimestamp(targetItem, staleItem, base);
|
|
626
|
+
base.updatedAt = Math.max(targetItem.updatedAt || 0, staleItem.updatedAt || 0) || undefined;
|
|
627
|
+
base.pinUpdatedAt = Math.max(targetPinUpdated, stalePinUpdated) || undefined;
|
|
628
|
+
base.pin = mergePins(targetItem.pin, staleItem.pin, targetPinUpdated, stalePinUpdated, groupTombstones);
|
|
629
|
+
mergeTitleMetadata(base, targetItem, staleItem);
|
|
630
|
+
return base;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function mergeTextPayload(base, localItem, remoteItem) {
|
|
634
|
+
if (!base || base.type === 'image') return base;
|
|
635
|
+
const candidates = [base, localItem, remoteItem].filter(item => item && item.type !== 'image');
|
|
636
|
+
const bestText = candidates
|
|
637
|
+
.slice()
|
|
638
|
+
.sort((a, b) => {
|
|
639
|
+
const score = textPayloadScore(b) - textPayloadScore(a);
|
|
640
|
+
if (score) return score;
|
|
641
|
+
return String(b.text || '').length - String(a.text || '').length;
|
|
642
|
+
})[0];
|
|
643
|
+
const bestMeta = candidates.find(item => item.textHash || item.textRef || item.textSize || item.textPreview);
|
|
644
|
+
|
|
645
|
+
if (bestText && textPayloadScore(bestText) >= 2) {
|
|
646
|
+
base.text = bestText.text || '';
|
|
647
|
+
} else if (!base.text && bestMeta) {
|
|
648
|
+
base.text = bestMeta.textPreview || bestMeta.text || '';
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
for (const field of ['textHash', 'textRef', 'textSize', 'textPreview']) {
|
|
652
|
+
const source = candidates.find(item => item[field] !== undefined);
|
|
653
|
+
if (source) base[field] = source[field];
|
|
654
|
+
else delete base[field];
|
|
655
|
+
}
|
|
656
|
+
for (const field of RICH_BLOB_FIELDS) mergeBlobFieldGroup(base, candidates, blobFieldNames(field));
|
|
657
|
+
return base;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function blobFieldGroupPresent(item, names) {
|
|
661
|
+
return !!(item && (item[names.value] || item[names.ref] || item[names.hash]));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// A rich payload is taken as ONE GROUP from a SINGLE source item, never
|
|
665
|
+
// resolved field-by-field like the text metadata above. Two clips share an id
|
|
666
|
+
// exactly when they share their text, which forces all their TEXT metadata to
|
|
667
|
+
// agree - that is what makes the per-field loop safe there. It buys nothing
|
|
668
|
+
// here: the same words can be copied twice with different formatting, so an
|
|
669
|
+
// inline value from one item beside a ref/hash from another would describe
|
|
670
|
+
// content that exists nowhere. Candidate order is [base, local, remote], so the
|
|
671
|
+
// LWW winner is preferred and a one-sided payload still survives.
|
|
672
|
+
function mergeBlobFieldGroup(base, candidates, names) {
|
|
673
|
+
const source = candidates.find(item => blobFieldGroupPresent(item, names));
|
|
674
|
+
for (const key of [names.value, names.hash, names.ref, names.size]) {
|
|
675
|
+
if (source && source[key] !== undefined) base[key] = source[key];
|
|
676
|
+
else delete base[key];
|
|
677
|
+
}
|
|
678
|
+
return base;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function itemUpdatedAt(item) {
|
|
682
|
+
if (!item) return 0;
|
|
683
|
+
return timestamp(item.updatedAt) || timestampMs(item.ts);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Newest ms-clock across every mutation an item can carry (capture/edit, pin,
|
|
687
|
+
// title, capture-time correction). Compared against a tombstone's deletedAt to
|
|
688
|
+
// tell a genuinely newer re-add/edit apart from a stale pre-delete copy.
|
|
689
|
+
function itemMutationClock(item) {
|
|
690
|
+
if (!item) return 0;
|
|
691
|
+
return Math.max(
|
|
692
|
+
itemUpdatedAt(item),
|
|
693
|
+
pinUpdatedAt(item),
|
|
694
|
+
titleUpdatedAt(item),
|
|
695
|
+
timestampMs(item.tsUpdatedAt)
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function numpadSlotUpdatedAt(item) {
|
|
700
|
+
if (!item || !item.pin) return 0;
|
|
701
|
+
return timestamp(item.pin.numberUpdatedAt) || pinUpdatedAt(item) || itemUpdatedAt(item);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function isBetterNumpadClaim(item, current) {
|
|
705
|
+
if (!current) return true;
|
|
706
|
+
const itemSlotUpdatedAt = numpadSlotUpdatedAt(item);
|
|
707
|
+
const currentSlotUpdatedAt = numpadSlotUpdatedAt(current);
|
|
708
|
+
if (itemSlotUpdatedAt !== currentSlotUpdatedAt) return itemSlotUpdatedAt > currentSlotUpdatedAt;
|
|
709
|
+
|
|
710
|
+
const itemUpdated = itemUpdatedAt(item);
|
|
711
|
+
const currentUpdated = itemUpdatedAt(current);
|
|
712
|
+
if (itemUpdated !== currentUpdated) return itemUpdated > currentUpdated;
|
|
713
|
+
|
|
714
|
+
return String(itemKey(item)) > String(itemKey(current));
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function removeNumpadSlot(item, updatedAt) {
|
|
718
|
+
if (!item || !item.pin) return false;
|
|
719
|
+
let changed = false;
|
|
720
|
+
if (typeof item.pin.number === 'number') {
|
|
721
|
+
delete item.pin.number;
|
|
722
|
+
changed = true;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const nextUpdatedAt = timestamp(updatedAt);
|
|
726
|
+
if (nextUpdatedAt) {
|
|
727
|
+
if (timestamp(item.pin.numberUpdatedAt) < nextUpdatedAt) {
|
|
728
|
+
item.pin.numberUpdatedAt = nextUpdatedAt;
|
|
729
|
+
changed = true;
|
|
730
|
+
}
|
|
731
|
+
if (timestamp(item.pin.updatedAt) < nextUpdatedAt) {
|
|
732
|
+
item.pin.updatedAt = nextUpdatedAt;
|
|
733
|
+
changed = true;
|
|
734
|
+
}
|
|
735
|
+
if (timestamp(item.pinUpdatedAt) < nextUpdatedAt) {
|
|
736
|
+
item.pinUpdatedAt = nextUpdatedAt;
|
|
737
|
+
changed = true;
|
|
738
|
+
}
|
|
739
|
+
if (timestamp(item.updatedAt) < nextUpdatedAt) {
|
|
740
|
+
item.updatedAt = nextUpdatedAt;
|
|
741
|
+
changed = true;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return changed;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function dedupeNumpadSlots(items) {
|
|
748
|
+
let changed = false;
|
|
749
|
+
const bestBySlot = new Map();
|
|
750
|
+
for (const item of items) {
|
|
751
|
+
const slot = numpadSlotOf(item);
|
|
752
|
+
if (slot == null) continue;
|
|
753
|
+
const current = bestBySlot.get(slot);
|
|
754
|
+
if (isBetterNumpadClaim(item, current)) bestBySlot.set(slot, item);
|
|
755
|
+
}
|
|
756
|
+
for (const item of items) {
|
|
757
|
+
const slot = numpadSlotOf(item);
|
|
758
|
+
if (slot != null && bestBySlot.get(slot) !== item) {
|
|
759
|
+
const winner = bestBySlot.get(slot);
|
|
760
|
+
const resolvedAt = Math.max(numpadSlotUpdatedAt(item), numpadSlotUpdatedAt(winner)) + 1;
|
|
761
|
+
if (removeNumpadSlot(item, resolvedAt)) changed = true;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return changed;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function legacySlotMatchesItem(slot, item) {
|
|
768
|
+
if (!slot || !item) return false;
|
|
769
|
+
if (slot.type === 'image') return item.type === 'image' && item.image === slot.image;
|
|
770
|
+
return item.type !== 'image' && String(item.text || '') === String(slot.text || '');
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function setNumpadSlot(item, slot, updatedAt) {
|
|
774
|
+
const pin = ensurePin(item);
|
|
775
|
+
const nextUpdatedAt = timestamp(updatedAt);
|
|
776
|
+
pin.number = slot;
|
|
777
|
+
if (nextUpdatedAt) {
|
|
778
|
+
pin.numberUpdatedAt = nextUpdatedAt;
|
|
779
|
+
pin.updatedAt = nextUpdatedAt;
|
|
780
|
+
item.pinUpdatedAt = nextUpdatedAt;
|
|
781
|
+
item.updatedAt = nextUpdatedAt;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function createHistoryItemForLegacySlot(slot, number, updatedAt) {
|
|
786
|
+
const item = slot.type === 'image'
|
|
787
|
+
? { type: 'image', image: slot.image || '', ts: updatedAt / 1000 }
|
|
788
|
+
: { type: 'text', text: String(slot.text || ''), ts: updatedAt / 1000 };
|
|
789
|
+
setNumpadSlot(item, number, updatedAt);
|
|
790
|
+
ensureItemId(item);
|
|
791
|
+
return item;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function migrateLegacyNumpadSlots(history, oldSlots, options = {}) {
|
|
795
|
+
const items = Array.isArray(history) ? history : [];
|
|
796
|
+
if (!oldSlots || typeof oldSlots !== 'object') return { changed: false, migrated: 0, created: 0, skipped: 0 };
|
|
797
|
+
|
|
798
|
+
for (const item of items) {
|
|
799
|
+
migrateItemPin(item);
|
|
800
|
+
ensureItemId(item);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const now = timestamp(options.now) || Date.now();
|
|
804
|
+
let changed = false;
|
|
805
|
+
let migrated = 0;
|
|
806
|
+
let created = 0;
|
|
807
|
+
let skipped = 0;
|
|
808
|
+
|
|
809
|
+
for (const [numStr, rawSlot] of Object.entries(oldSlots)) {
|
|
810
|
+
const slotNumber = Number.parseInt(numStr, 10);
|
|
811
|
+
if (!Number.isInteger(slotNumber) || slotNumber < 1 || slotNumber > 9 || !rawSlot) {
|
|
812
|
+
skipped++;
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const slot = typeof rawSlot === 'string' ? { type: 'text', text: rawSlot } : rawSlot;
|
|
817
|
+
const existingClaim = items.find(item => hasNumpadSlot(item, slotNumber));
|
|
818
|
+
const match = items.find(item => legacySlotMatchesItem(slot, item));
|
|
819
|
+
|
|
820
|
+
if (existingClaim && (!match || itemKey(existingClaim) !== itemKey(match))) {
|
|
821
|
+
skipped++;
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
if (existingClaim && match && itemKey(existingClaim) === itemKey(match)) {
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (match) {
|
|
829
|
+
setNumpadSlot(match, slotNumber, now);
|
|
830
|
+
changed = true;
|
|
831
|
+
migrated++;
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const createdItem = createHistoryItemForLegacySlot(slot, slotNumber, now);
|
|
836
|
+
items.unshift(createdItem);
|
|
837
|
+
changed = true;
|
|
838
|
+
migrated++;
|
|
839
|
+
created++;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (dedupeNumpadSlots(items)) changed = true;
|
|
843
|
+
return { changed, migrated, created, skipped };
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function groupsOnlyPin(groups, updatedAt, groupTombstones = []) {
|
|
847
|
+
const deleted = groupTombstoneNames(groupTombstones);
|
|
848
|
+
const cleanGroups = [...new Set((Array.isArray(groups) ? groups : [])
|
|
849
|
+
.map(group => String(group || '').trim())
|
|
850
|
+
.filter(group => group && !deleted.has(group)))];
|
|
851
|
+
if (!cleanGroups.length) return null;
|
|
852
|
+
const ts = timestamp(updatedAt) || Date.now();
|
|
853
|
+
return { groups: cleanGroups, updatedAt: ts, groupsUpdatedAt: ts };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function touchNumpadMetadata(item, updatedAt) {
|
|
857
|
+
if (!item || !item.pin || typeof item.pin.number !== 'number') return;
|
|
858
|
+
const ts = timestamp(updatedAt) || Date.now();
|
|
859
|
+
item.pin.numberUpdatedAt = ts;
|
|
860
|
+
item.pin.updatedAt = ts;
|
|
861
|
+
item.pinUpdatedAt = ts;
|
|
862
|
+
item.updatedAt = ts;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function moveIndexToFront(items, index) {
|
|
866
|
+
if (index <= 0) return;
|
|
867
|
+
const item = items.splice(index, 1)[0];
|
|
868
|
+
items.unshift(item);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function applyTextEdit(items, options = {}) {
|
|
872
|
+
const history = Array.isArray(items) ? items : [];
|
|
873
|
+
const originalText = String(options.originalText || '');
|
|
874
|
+
const newText = String(options.newText || '');
|
|
875
|
+
const textChanged = newText !== originalText;
|
|
876
|
+
const tombstoneIds = [];
|
|
877
|
+
const supersedes = [];
|
|
878
|
+
const titleEditRequested = options.originalTitle !== undefined || options.newTitle !== undefined;
|
|
879
|
+
|
|
880
|
+
if (!textChanged && !titleEditRequested) return { changed: false, reason: 'unchanged', tombstoneIds };
|
|
881
|
+
if (options.ignoreBlank !== false && !newText.trim()) return { changed: false, reason: 'blank', tombstoneIds };
|
|
882
|
+
|
|
883
|
+
const now = timestamp(options.now) || Date.now();
|
|
884
|
+
const nowSeconds = now / 1000;
|
|
885
|
+
const id = String(options.id || '');
|
|
886
|
+
const groupTombstones = options.groupTombstones || [];
|
|
887
|
+
|
|
888
|
+
for (const item of history) {
|
|
889
|
+
migrateItemPin(item);
|
|
890
|
+
ensureItemId(item);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const currentIndex = id ? history.findIndex(item => itemKey(item) === id) : -1;
|
|
894
|
+
const current = currentIndex >= 0 ? history[currentIndex] : null;
|
|
895
|
+
const originalTitle = titleEditRequested
|
|
896
|
+
? (options.originalTitle !== undefined ? cleanTitle(options.originalTitle) : titleOf(current))
|
|
897
|
+
: titleOf(current);
|
|
898
|
+
const newTitle = titleEditRequested
|
|
899
|
+
? (options.newTitle !== undefined ? cleanTitle(options.newTitle) : originalTitle)
|
|
900
|
+
: originalTitle;
|
|
901
|
+
const titleChanged = titleEditRequested && newTitle !== originalTitle;
|
|
902
|
+
if (!textChanged && !titleChanged) return { changed: false, reason: 'unchanged', tombstoneIds };
|
|
903
|
+
const canUpdateCurrent = current && current.type !== 'image' && String(current.text || '') === originalText;
|
|
904
|
+
const currentBefore = current ? conflictSnapshot(current, { id, text: originalText, title: originalTitle }) : null;
|
|
905
|
+
const currentTitle = current ? titleOf(current) : '';
|
|
906
|
+
const titleDiverged = !!(canUpdateCurrent && titleChanged && currentTitle !== originalTitle);
|
|
907
|
+
const oldId = canUpdateCurrent ? itemKey(current) : '';
|
|
908
|
+
const edited = canUpdateCurrent
|
|
909
|
+
? current
|
|
910
|
+
: {
|
|
911
|
+
type: 'text',
|
|
912
|
+
text: originalText,
|
|
913
|
+
ts: nowSeconds,
|
|
914
|
+
updatedAt: now,
|
|
915
|
+
pin: groupsOnlyPin(options.sourceGroups, now, groupTombstones),
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
if (edited.pin && !edited.pinUpdatedAt) edited.pinUpdatedAt = now;
|
|
919
|
+
if (textChanged || !canUpdateCurrent) setInlineText(edited, newText);
|
|
920
|
+
if (titleEditRequested) setTitleMetadata(edited, newTitle, now);
|
|
921
|
+
edited.ts = nowSeconds;
|
|
922
|
+
edited.updatedAt = now;
|
|
923
|
+
edited.id = legacyContentKey(edited);
|
|
924
|
+
touchNumpadMetadata(edited, now);
|
|
925
|
+
|
|
926
|
+
const editedId = itemKey(edited);
|
|
927
|
+
const existingIndex = history.findIndex(item => item !== edited && item.type !== 'image' && itemKey(item) === editedId);
|
|
928
|
+
if (existingIndex >= 0) {
|
|
929
|
+
const existing = history[existingIndex];
|
|
930
|
+
const existingBefore = conflictSnapshot(existing);
|
|
931
|
+
const existingPinUpdated = pinUpdatedAt(existing);
|
|
932
|
+
const editedPinUpdated = pinUpdatedAt(edited);
|
|
933
|
+
setInlineText(existing, newText);
|
|
934
|
+
if (titleChanged || newTitle || titleOf(existing)) mergeTitleMetadata(existing, existing, edited);
|
|
935
|
+
existing.id = editedId;
|
|
936
|
+
existing.ts = nowSeconds;
|
|
937
|
+
existing.updatedAt = now;
|
|
938
|
+
existing.pin = mergePins(existing.pin, edited.pin, existingPinUpdated, editedPinUpdated, groupTombstones);
|
|
939
|
+
existing.pinUpdatedAt = Math.max(pinUpdatedAt(existing), editedPinUpdated, existingPinUpdated) || undefined;
|
|
940
|
+
if (canUpdateCurrent) {
|
|
941
|
+
const removeIndex = history.indexOf(current);
|
|
942
|
+
if (removeIndex >= 0) history.splice(removeIndex, 1);
|
|
943
|
+
if (oldId && oldId !== editedId) {
|
|
944
|
+
tombstoneIds.push(oldId);
|
|
945
|
+
supersedes.push({ from: oldId, to: editedId, updatedAt: now });
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
moveIndexToFront(history, history.indexOf(existing));
|
|
949
|
+
dedupeNumpadSlots(history);
|
|
950
|
+
const titleMergeConflict = titleConflict(existingBefore, edited);
|
|
951
|
+
const conflict = current && (!canUpdateCurrent || titleDiverged || titleMergeConflict)
|
|
952
|
+
? {
|
|
953
|
+
kind: titleDiverged || titleMergeConflict ? 'title' : 'editor',
|
|
954
|
+
base: { id, type: 'text', text: originalText, title: originalTitle },
|
|
955
|
+
left: titleMergeConflict ? existingBefore : (currentBefore || existingBefore),
|
|
956
|
+
right: conflictSnapshot(edited, { text: newText, title: newTitle }),
|
|
957
|
+
targetId: itemKey(existing),
|
|
958
|
+
}
|
|
959
|
+
: null;
|
|
960
|
+
return {
|
|
961
|
+
changed: true,
|
|
962
|
+
reason: canUpdateCurrent || !current ? 'merged' : 'conflict_merged',
|
|
963
|
+
item: existing,
|
|
964
|
+
tombstoneIds,
|
|
965
|
+
supersedes,
|
|
966
|
+
conflict,
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
if (canUpdateCurrent) {
|
|
971
|
+
moveIndexToFront(history, history.indexOf(current));
|
|
972
|
+
if (oldId && oldId !== editedId) {
|
|
973
|
+
tombstoneIds.push(oldId);
|
|
974
|
+
supersedes.push({ from: oldId, to: editedId, updatedAt: now });
|
|
975
|
+
}
|
|
976
|
+
dedupeNumpadSlots(history);
|
|
977
|
+
return {
|
|
978
|
+
changed: true,
|
|
979
|
+
reason: 'updated',
|
|
980
|
+
item: current,
|
|
981
|
+
tombstoneIds,
|
|
982
|
+
supersedes,
|
|
983
|
+
conflict: titleDiverged
|
|
984
|
+
? {
|
|
985
|
+
kind: 'title',
|
|
986
|
+
base: { id, type: 'text', text: originalText, title: originalTitle },
|
|
987
|
+
left: currentBefore,
|
|
988
|
+
right: conflictSnapshot(current, { text: newText, title: newTitle }),
|
|
989
|
+
targetId: itemKey(current),
|
|
990
|
+
}
|
|
991
|
+
: null,
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
ensureItemId(edited);
|
|
996
|
+
history.unshift(edited);
|
|
997
|
+
dedupeNumpadSlots(history);
|
|
998
|
+
return {
|
|
999
|
+
changed: true,
|
|
1000
|
+
reason: id || originalText ? 'conflict_created' : 'created',
|
|
1001
|
+
item: edited,
|
|
1002
|
+
tombstoneIds,
|
|
1003
|
+
supersedes,
|
|
1004
|
+
conflict: current
|
|
1005
|
+
? {
|
|
1006
|
+
kind: 'editor',
|
|
1007
|
+
base: { id, type: 'text', text: originalText, title: originalTitle },
|
|
1008
|
+
left: currentBefore,
|
|
1009
|
+
right: conflictSnapshot(edited, { text: newText, title: newTitle }),
|
|
1010
|
+
targetId: itemKey(edited),
|
|
1011
|
+
}
|
|
1012
|
+
: null,
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// The ids one note has worn across its edits (every supersede link, obsolete
|
|
1017
|
+
// ones included, walked both ways) - built once, only when a merge drops a
|
|
1018
|
+
// local item.
|
|
1019
|
+
function lineageFamilies(list) {
|
|
1020
|
+
const neighbours = new Map();
|
|
1021
|
+
const link = (a, b) => { if (!neighbours.has(a)) neighbours.set(a, new Set()); neighbours.get(a).add(b); };
|
|
1022
|
+
for (const record of Array.isArray(list) ? list : []) {
|
|
1023
|
+
if (!record || !record.from || !record.to) continue;
|
|
1024
|
+
link(String(record.from), String(record.to));
|
|
1025
|
+
link(String(record.to), String(record.from));
|
|
1026
|
+
}
|
|
1027
|
+
return (id) => {
|
|
1028
|
+
const family = new Set([id]);
|
|
1029
|
+
const queue = [id];
|
|
1030
|
+
while (queue.length) for (const next of neighbours.get(queue.shift()) || []) if (!family.has(next)) { family.add(next); queue.push(next); }
|
|
1031
|
+
return family;
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// `report` (optional array) receives what the merge did to LOCAL history:
|
|
1036
|
+
// { event: 'dropped', reason: 'deleted' | 'superseded' } for a local clip that
|
|
1037
|
+
// is gone afterwards, { event: 'revived' } for a clip that came back over its
|
|
1038
|
+
// own tombstone, and { event: 'drop_blocked' } when the lineage tripwire kept a
|
|
1039
|
+
// clip. Remote copies that stay suppressed are routine and not reported.
|
|
1040
|
+
function mergeHistories(local, remote, settings = {}, report = null) {
|
|
1041
|
+
local = Array.isArray(local) ? local : [];
|
|
1042
|
+
remote = Array.isArray(remote) ? remote : [];
|
|
1043
|
+
const deleted = tombstoneMap(settings.tombstones);
|
|
1044
|
+
const groupTombstones = settings.group_tombstones || [];
|
|
1045
|
+
const links = liveSupersedeLinks(settings.supersedes);
|
|
1046
|
+
const lineage = resolveSupersedeLinks(links);
|
|
1047
|
+
const allKeys = new Set();
|
|
1048
|
+
for (const item of [...local, ...remote]) {
|
|
1049
|
+
if (!item) continue;
|
|
1050
|
+
migrateItemPin(item);
|
|
1051
|
+
ensureItemId(item);
|
|
1052
|
+
allKeys.add(itemKey(item));
|
|
1053
|
+
}
|
|
1054
|
+
const merged = new Map();
|
|
1055
|
+
const pendingStaleByTarget = new Map();
|
|
1056
|
+
|
|
1057
|
+
const foldPending = (targetKey) => {
|
|
1058
|
+
const pending = pendingStaleByTarget.get(targetKey);
|
|
1059
|
+
if (!pending || !merged.has(targetKey)) return;
|
|
1060
|
+
let target = merged.get(targetKey);
|
|
1061
|
+
for (const stale of pending) target = mergeSupersededStaleIntoTarget(target, stale, groupTombstones);
|
|
1062
|
+
merged.set(targetKey, target);
|
|
1063
|
+
pendingStaleByTarget.delete(targetKey);
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
const addItem = (item) => {
|
|
1067
|
+
if (!item) return;
|
|
1068
|
+
const key = itemKey(item);
|
|
1069
|
+
// A copy changed AFTER the edit that superseded its id (that text copied
|
|
1070
|
+
// again, or pinned/titled since) is not a stale version, so the lineage
|
|
1071
|
+
// must not fold it away; its own tombstone's version guard decides.
|
|
1072
|
+
const link = links.get(key);
|
|
1073
|
+
const reborn = link && itemMutationClock(item) > link.updatedAt + EDIT_TOMBSTONE_SLACK_MS;
|
|
1074
|
+
const targetKey = reborn ? undefined : lineage.get(key);
|
|
1075
|
+
if (targetKey && deleted.has(targetKey)) return;
|
|
1076
|
+
if (!targetKey || deletedAfterEdit(deleted.get(key), links.get(key))) {
|
|
1077
|
+
// Version-guarded tombstone: a delete only suppresses a copy whose own
|
|
1078
|
+
// mutation clock is at/older than the delete. A legitimately NEWER re-add
|
|
1079
|
+
// or edit of the same content-hash id beats the tombstone (no surprise
|
|
1080
|
+
// cross-device clobber); a stale pre-delete copy stays deleted.
|
|
1081
|
+
if (tombstoneSuppresses(deleted.get(key), item)) return;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (targetKey && allKeys.has(targetKey)) {
|
|
1085
|
+
if (merged.has(targetKey)) {
|
|
1086
|
+
merged.set(targetKey, mergeSupersededStaleIntoTarget(merged.get(targetKey), item, groupTombstones));
|
|
1087
|
+
} else {
|
|
1088
|
+
const pending = pendingStaleByTarget.get(targetKey) || [];
|
|
1089
|
+
pending.push(item);
|
|
1090
|
+
pendingStaleByTarget.set(targetKey, pending);
|
|
1091
|
+
}
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const existing = merged.get(key);
|
|
1096
|
+
merged.set(key, existing ? mergeItems(existing, item, groupTombstones) : item);
|
|
1097
|
+
foldPending(key);
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1100
|
+
for (const item of local) addItem(item);
|
|
1101
|
+
for (const item of remote) addItem(item);
|
|
1102
|
+
|
|
1103
|
+
// If every provider lost the target of an edit lineage, keep the newest stale
|
|
1104
|
+
// old-id copy rather than converting an edit into data loss. The old-id
|
|
1105
|
+
// tombstone is an edit marker while supersedes exists, not a hard delete.
|
|
1106
|
+
for (const pending of pendingStaleByTarget.values()) {
|
|
1107
|
+
for (const item of pending) {
|
|
1108
|
+
const key = itemKey(item);
|
|
1109
|
+
if (deleted.has(lineage.get(key))) continue;
|
|
1110
|
+
const existing = merged.get(key);
|
|
1111
|
+
merged.set(key, existing ? mergeItems(existing, item, groupTombstones) : item);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Lineage tripwire: a merge may drop a local clip only for a delete the user
|
|
1116
|
+
// made (its own tombstone, or the head of its lineage, is an explicit delete)
|
|
1117
|
+
// or because a newer version of the same note survives. Anything else is a
|
|
1118
|
+
// merge rule losing a note - the 2026-09-29 class of bug - so keep the clip.
|
|
1119
|
+
const explicitDelete = (id) => {
|
|
1120
|
+
const tombstone = deleted.get(id);
|
|
1121
|
+
if (!tombstone) return false;
|
|
1122
|
+
const link = links.get(id);
|
|
1123
|
+
return !link || deletedAfterEdit(tombstone, link);
|
|
1124
|
+
};
|
|
1125
|
+
let familyOf = null;
|
|
1126
|
+
const localKeys = new Set();
|
|
1127
|
+
for (const item of local) {
|
|
1128
|
+
if (!item) continue;
|
|
1129
|
+
const key = itemKey(item);
|
|
1130
|
+
localKeys.add(key);
|
|
1131
|
+
if (merged.has(key)) continue;
|
|
1132
|
+
const head = lineage.get(key);
|
|
1133
|
+
if (explicitDelete(key) || (head && explicitDelete(head))) {
|
|
1134
|
+
if (report) report.push({ event: 'dropped', reason: 'deleted', id: key, item });
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
if (!familyOf) familyOf = lineageFamilies(settings.supersedes);
|
|
1138
|
+
const survivor = [...familyOf(key)].find(id => id !== key && merged.has(id));
|
|
1139
|
+
if (survivor) {
|
|
1140
|
+
if (report) report.push({ event: 'dropped', reason: 'superseded', id: key, item, survivor });
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
merged.set(key, item);
|
|
1144
|
+
if (report) report.push({ event: 'drop_blocked', id: key, item, head: head || null });
|
|
1145
|
+
}
|
|
1146
|
+
if (report) {
|
|
1147
|
+
for (const [key, item] of merged) {
|
|
1148
|
+
if (!localKeys.has(key) && deleted.has(key)) report.push({ event: 'revived', id: key, item, deletedAt: deleted.get(key).deletedAt });
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const result = [...merged.values()].sort((a, b) => (b.ts || 0) - (a.ts || 0));
|
|
1153
|
+
dedupeNumpadSlots(result);
|
|
1154
|
+
return result;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function planHistoryPrune(history, settings = {}, options = {}) {
|
|
1158
|
+
const now = options.now != null ? Number(options.now) : Date.now() / 1000;
|
|
1159
|
+
const maxAgeDays = Number(settings.max_age_days);
|
|
1160
|
+
const maxAge = Number.isFinite(maxAgeDays) && maxAgeDays > 0 ? maxAgeDays * 86400 : Infinity;
|
|
1161
|
+
const planned = [];
|
|
1162
|
+
const survivors = Array.isArray(history) ? history.map((item, index) => ({ item, index })) : [];
|
|
1163
|
+
|
|
1164
|
+
for (let i = survivors.length - 1; i >= 0; i--) {
|
|
1165
|
+
const { item, index } = survivors[i];
|
|
1166
|
+
if (!isPinned(item) && (now - (Number(item && item.ts) || 0)) > maxAge) {
|
|
1167
|
+
planned.push({ index, reason: 'age' });
|
|
1168
|
+
survivors.splice(i, 1);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
return planned.sort((a, b) => b.index - a.index);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function isDestructivePrune(history, plan, options = {}) {
|
|
1176
|
+
const total = Array.isArray(history) ? history.length : 0;
|
|
1177
|
+
const count = Array.isArray(plan) ? plan.length : 0;
|
|
1178
|
+
if (!total || !count) return false;
|
|
1179
|
+
const minItems = Number(options.minItems) || 50;
|
|
1180
|
+
const fraction = Number(options.fraction) || DEFAULT_DESTRUCTIVE_PRUNE_FRACTION;
|
|
1181
|
+
return count >= minItems && count / total >= fraction;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function mergeGroups(local, remote, groupTombstones = []) {
|
|
1185
|
+
const deleted = groupTombstoneNames(groupTombstones);
|
|
1186
|
+
return [...new Set([...(local || []), ...(remote || [])])].filter(g => !deleted.has(g));
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
module.exports = {
|
|
1190
|
+
DEFAULT_SETTINGS,
|
|
1191
|
+
TOMBSTONE_MAX_AGE_MS,
|
|
1192
|
+
textHashForText,
|
|
1193
|
+
itemTextHash,
|
|
1194
|
+
itemHasVerifiedFullText,
|
|
1195
|
+
hasVerifiedFullValue,
|
|
1196
|
+
valueHash,
|
|
1197
|
+
blobFieldNames,
|
|
1198
|
+
RICH_BLOB_FIELDS,
|
|
1199
|
+
migrateItemPin,
|
|
1200
|
+
isPinned,
|
|
1201
|
+
numpadSlotOf,
|
|
1202
|
+
groupsOf,
|
|
1203
|
+
titleOf,
|
|
1204
|
+
titleUpdatedAt,
|
|
1205
|
+
setTitleMetadata,
|
|
1206
|
+
mergeTitleMetadata,
|
|
1207
|
+
conflictSnapshot,
|
|
1208
|
+
titleConflict,
|
|
1209
|
+
hasNumpadSlot,
|
|
1210
|
+
ensurePin,
|
|
1211
|
+
setInlineText,
|
|
1212
|
+
legacyContentKey,
|
|
1213
|
+
ensureItemId,
|
|
1214
|
+
itemKey,
|
|
1215
|
+
clipRevision,
|
|
1216
|
+
tombstoneSuppresses,
|
|
1217
|
+
normalizeTombstones,
|
|
1218
|
+
normalizeGroupTombstones,
|
|
1219
|
+
normalizeSupersedes,
|
|
1220
|
+
supersedeMap,
|
|
1221
|
+
tombstoneIds,
|
|
1222
|
+
tombstoneMap,
|
|
1223
|
+
groupTombstoneNames,
|
|
1224
|
+
planHistoryPrune,
|
|
1225
|
+
isDestructivePrune,
|
|
1226
|
+
mergePins,
|
|
1227
|
+
pinUpdatedAt,
|
|
1228
|
+
mergeItems,
|
|
1229
|
+
itemMutationClock,
|
|
1230
|
+
mergeHistories,
|
|
1231
|
+
mergeGroups,
|
|
1232
|
+
dedupeNumpadSlots,
|
|
1233
|
+
migrateLegacyNumpadSlots,
|
|
1234
|
+
applyTextEdit,
|
|
1235
|
+
};
|