@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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -2
  3. package/assets/boardclip-icon.png +0 -0
  4. package/bin/boardclip.js +43 -0
  5. package/editor-preload.js +71 -0
  6. package/editor.html +220 -0
  7. package/icon.png +0 -0
  8. package/icon@2x.png +0 -0
  9. package/iconTemplate.png +0 -0
  10. package/iconTemplate@2x.png +0 -0
  11. package/index.html +1339 -0
  12. package/lib/appearance.js +183 -0
  13. package/lib/auto-update.js +306 -0
  14. package/lib/backup-worker.js +46 -0
  15. package/lib/backup.js +275 -0
  16. package/lib/blob-field-store.js +275 -0
  17. package/lib/blob-store.js +68 -0
  18. package/lib/build-info.js +89 -0
  19. package/lib/clip-blob-store.js +161 -0
  20. package/lib/clip-revision.js +81 -0
  21. package/lib/clipboard-capture.js +95 -0
  22. package/lib/clipboard-follow.js +98 -0
  23. package/lib/clipboard-model.js +1235 -0
  24. package/lib/cloud-accounts.js +250 -0
  25. package/lib/conflict-model.js +186 -0
  26. package/lib/control-client.js +87 -0
  27. package/lib/control-server.js +149 -0
  28. package/lib/diagnostics.js +121 -0
  29. package/lib/drag-files.js +48 -0
  30. package/lib/ensure-directory.js +56 -0
  31. package/lib/fork-names.js +35 -0
  32. package/lib/fs-probe.js +132 -0
  33. package/lib/history-feed.js +48 -0
  34. package/lib/hmac-auth.js +78 -0
  35. package/lib/macos-hotkey.js +297 -0
  36. package/lib/macos-paste.js +112 -0
  37. package/lib/mcp-core.js +270 -0
  38. package/lib/mcp-installers.js +284 -0
  39. package/lib/mcp-paths.js +99 -0
  40. package/lib/p2p-crypto.js +54 -0
  41. package/lib/p2p-discovery.js +191 -0
  42. package/lib/quick-paste.js +187 -0
  43. package/lib/retention.js +43 -0
  44. package/lib/sync-delta.js +227 -0
  45. package/lib/sync-journal.js +181 -0
  46. package/lib/sync-paths.js +73 -0
  47. package/lib/tailscale.js +80 -0
  48. package/lib/text-blob-store.js +38 -0
  49. package/lib/windows-clipboard.js +213 -0
  50. package/lib/windows-console.js +63 -0
  51. package/lib/windows-dwm.js +183 -0
  52. package/lib/windows-hook-worker.js +236 -0
  53. package/lib/windows-hook.js +91 -0
  54. package/lib/windows-paste.js +185 -0
  55. package/main.js +7540 -0
  56. package/mcp/boardclip-mcp.js +255 -0
  57. package/mcp-approval-preload.js +22 -0
  58. package/mcp-approval.html +218 -0
  59. package/package.json +55 -4
  60. package/preload.js +106 -0
  61. package/site/index.html +1624 -0
  62. package/site/shared/clip-search.js +2330 -0
  63. package/site/shared/clipboard-popup.css +1046 -0
  64. package/site/shared/clipboard-tokens.css +326 -0
  65. package/site/shared/clipboard-ui-core.js +6921 -0
  66. package/site/shared/clipboard-window.css +36 -0
  67. package/site/shared/vendor/cm5/README.md +52 -0
  68. package/site/shared/vendor/cm5/codemirror.css +344 -0
  69. package/site/shared/vendor/cm5/codemirror.js +9884 -0
  70. package/site/shared/vendor/cm5/diff-match-patch.js +2228 -0
  71. package/site/shared/vendor/cm5/merge.css +119 -0
  72. package/site/shared/vendor/cm5/merge.js +1182 -0
  73. package/site/shared/vendor/fonts/README.md +19 -0
  74. package/site/shared/vendor/fonts/material-symbols-rounded.woff2 +0 -0
  75. package/viewer-preload.js +51 -0
  76. package/viewer.html +129 -0
@@ -0,0 +1,187 @@
1
+ 'use strict';
2
+ // Robust quick-paste orchestrator (numpad slots + panel number keys).
3
+ //
4
+ // The failure this module fixes: BoardClip quick-paste must (1) put a saved
5
+ // item onto the clipboard, (2) synthesize Ctrl/Cmd+V into the foreground app,
6
+ // then (3) restore the user's previous clipboard. Steps 2 and 3 race — Ctrl+V
7
+ // is asynchronous (it only injects a keystroke; the target app reads the
8
+ // clipboard whenever it next drains its input queue), so a fixed delay before
9
+ // restoring the previous clipboard is unreliable. Under load the target reads
10
+ // AFTER we've already restored → it pastes the previous clip. That is the
11
+ // "pastes random old stuff, worse under lag" bug.
12
+ //
13
+ // The orchestrator makes the sequence robust regardless of platform strategy:
14
+ // * SERIALIZES requests through a promise chain (rapid presses queue instead
15
+ // of being dropped — no more "press it 3 times").
16
+ // * COALESCES duplicate requests for the same slot inside a short window
17
+ // (belt-and-suspenders against key auto-repeat that slips past the hook).
18
+ // * VERIFIES the macro actually landed on the clipboard before pasting
19
+ // (a synchronous write can still be observed late under contention).
20
+ // * MEASURES real scheduler lag and ADAPTS the pre-restore delay to it, with
21
+ // a generous floor/ceiling, so a laggy machine waits longer before restore.
22
+ // * RESTORES SAFELY: only overwrites the clipboard if it still holds our
23
+ // macro, so we never clobber something the user copied mid-sequence.
24
+ //
25
+ // Everything here is dependency-injected and pure so it runs under a plain-node
26
+ // test with a fake clipboard + a fake "late-reading target" (see
27
+ // test/numpad-paste.test.js). main.js wires the real Electron clipboard and the
28
+ // platform paste primitive. There is a SINGLE delivery mechanism — put the item
29
+ // on the clipboard, synthesize Ctrl/Cmd+V, safe-restore the previous clipboard —
30
+ // which is the SAME primitive the panel-click paste uses. Numpad therefore can
31
+ // never diverge into typing keystrokes (an earlier keystroke-injection mode
32
+ // turned newlines into Enter presses and fired unintended sends; it was removed).
33
+
34
+ const DEFAULT_CONFIG = {
35
+ restore: true, // restore the user's previous clipboard afterwards
36
+ verifyTries: 12, // how many times to re-check the macro landed
37
+ verifyIntervalMs: 8, // spacing between verify checks (~100ms budget)
38
+ minRestoreDelayMs: 400, // floor before restoring (target needs time to read)
39
+ maxRestoreDelayMs: 1200, // ceiling so restore never hangs forever
40
+ lagMultiplier: 3, // restore delay grows this * measured scheduler lag
41
+ coalesceMs: 90, // drop same-key repeats that arrive within this window
42
+ };
43
+
44
+ function createQuickPaster(deps) {
45
+ const {
46
+ snapshot, // () => opaque backup of current clipboard
47
+ restore, // (backup) => void — write backup back
48
+ writeItem, // (item) => void — put the macro on the clipboard
49
+ clipboardMatchesItem, // (item) => bool — does the clipboard hold the macro?
50
+ paste, // async () => pasteResult — synthesize Ctrl/Cmd+V
51
+ sleep, // (ms) => Promise
52
+ now = () => Date.now(),
53
+ log = () => {},
54
+ getConfig = () => ({}),
55
+ } = deps;
56
+
57
+ let chain = Promise.resolve();
58
+ let running = false;
59
+ const lastStartByKey = new Map();
60
+
61
+ function config() {
62
+ return { ...DEFAULT_CONFIG, ...(getConfig() || {}) };
63
+ }
64
+
65
+ // Wait until the clipboard actually reflects the macro. A synchronous
66
+ // clipboard write can still be observed late while the system is busy, and
67
+ // pasting before it lands is exactly how stale content sneaks through.
68
+ async function verifySet(item, cfg, trace) {
69
+ for (let attempt = 1; attempt <= cfg.verifyTries; attempt++) {
70
+ let matched = false;
71
+ try { matched = !!clipboardMatchesItem(item); } catch { matched = false; }
72
+ if (matched) {
73
+ if (attempt > 1) log('quick_paste.set_verified_late', { ...trace, attempts: attempt });
74
+ return { ok: true, attempts: attempt };
75
+ }
76
+ if (attempt < cfg.verifyTries) await sleep(cfg.verifyIntervalMs);
77
+ }
78
+ log('quick_paste.set_unverified', { ...trace, attempts: cfg.verifyTries });
79
+ return { ok: false, attempts: cfg.verifyTries };
80
+ }
81
+
82
+ // Measure how much longer than requested a short sleep actually took — a
83
+ // direct read of current scheduler/system lag, which we fold into the
84
+ // pre-restore delay so a laggy machine gives the target more time.
85
+ async function measureLag(sampleMs) {
86
+ const t0 = now();
87
+ await sleep(sampleMs);
88
+ return Math.max(0, (now() - t0) - sampleMs);
89
+ }
90
+
91
+ async function timedDeliver({ item, paste: doPaste, cfg, trace }) {
92
+ writeItem(item);
93
+ const verify = await verifySet(item, cfg, trace);
94
+ // Scheduler-lag proxy: how much our own short sleeps overrun is a real
95
+ // signal of local system load (a bogged machine both delays us AND makes
96
+ // the target slower to read). Sample either side of the paste and take the
97
+ // worse. It's a mild safety bump — the configured floor is the main lever;
98
+ // we cannot measure the target process's read latency from here.
99
+ const lagBefore = await measureLag(20);
100
+ const pasteResult = await doPaste();
101
+ const lagAfter = await measureLag(20);
102
+ const lag = Math.max(lagBefore, lagAfter);
103
+ const restoreDelay = Math.min(
104
+ cfg.maxRestoreDelayMs,
105
+ Math.max(cfg.minRestoreDelayMs, cfg.minRestoreDelayMs + lag * cfg.lagMultiplier),
106
+ );
107
+ log('quick_paste.timed_wait', { ...trace, lag_ms: Math.round(lag), restore_delay_ms: Math.round(restoreDelay) });
108
+ await sleep(restoreDelay);
109
+ return { verified: verify.ok, verifyAttempts: verify.attempts, pasteResult, restoreDelay };
110
+ }
111
+
112
+ async function runOne(item, opts) {
113
+ const cfg = config();
114
+ const trace = opts.trace || {};
115
+ const startedAt = now();
116
+ running = true;
117
+
118
+ let backup = null;
119
+ let outcome = { ok: false };
120
+ try {
121
+ // A request may supply its own paste primitive (e.g. macOS needs the
122
+ // target app name captured at request time); otherwise use the dep.
123
+ const doPaste = typeof opts.paste === 'function' ? opts.paste : paste;
124
+
125
+ backup = snapshot();
126
+ const delivered = await timedDeliver({ item, paste: doPaste, cfg, trace });
127
+ outcome = { ok: true, ...delivered };
128
+
129
+ if (cfg.restore) {
130
+ // Only restore if the clipboard still holds our macro. If it changed,
131
+ // the user (or another app) put something new there — never clobber it.
132
+ let stillMacro = true;
133
+ try { stillMacro = !!clipboardMatchesItem(item); } catch { stillMacro = true; }
134
+ if (stillMacro) {
135
+ try { restore(backup); outcome.restored = true; }
136
+ catch (e) { outcome.restored = false; outcome.restoreError = e && e.message; }
137
+ } else {
138
+ outcome.restored = false;
139
+ outcome.restoreSkipped = 'clipboard_changed';
140
+ log('quick_paste.restore_skipped', { ...trace, reason: 'clipboard_changed' });
141
+ }
142
+ }
143
+ } catch (e) {
144
+ outcome = { ok: false, error: e && e.message };
145
+ log('quick_paste.error', { ...trace, error: e && e.message });
146
+ } finally {
147
+ running = false;
148
+ log('quick_paste.done', { ...trace, total_ms: now() - startedAt, ...summarize(outcome) });
149
+ }
150
+ return outcome;
151
+ }
152
+
153
+ function summarize(o) {
154
+ return {
155
+ verified: o.verified,
156
+ restored: o.restored,
157
+ restore_skipped: o.restoreSkipped,
158
+ };
159
+ }
160
+
161
+ // Enqueue a quick-paste. Returns a promise for the outcome. Rapid presses
162
+ // serialize; same-key repeats inside coalesceMs are dropped as duplicates.
163
+ function request(item, opts = {}) {
164
+ const cfg = config();
165
+ const key = opts.coalesceKey != null ? String(opts.coalesceKey) : null;
166
+ if (key != null) {
167
+ const prev = lastStartByKey.get(key);
168
+ if (prev != null && now() - prev < cfg.coalesceMs) {
169
+ log('quick_paste.coalesced', { ...(opts.trace || {}), key });
170
+ return Promise.resolve({ ok: false, coalesced: true });
171
+ }
172
+ lastStartByKey.set(key, now());
173
+ }
174
+ const next = chain.then(() => runOne(item, opts));
175
+ // Keep the chain alive even if one op throws.
176
+ chain = next.catch(() => {});
177
+ return next;
178
+ }
179
+
180
+ return {
181
+ request,
182
+ busy: () => running,
183
+ _config: config,
184
+ };
185
+ }
186
+
187
+ module.exports = { createQuickPaster, DEFAULT_CONFIG };
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ // Shared retention policy for on-disk buffers (history backups, the external-edit
4
+ // archive, ...). Pure and side-effect-free so the policy is unit-testable without
5
+ // the filesystem: callers enumerate the dir into { mtimeMs, size } entries, this
6
+ // decides WHICH to evict, the caller unlinks them.
7
+ //
8
+ // Any combination of caps may be supplied; each is skipped when its option is
9
+ // absent, and they compose in this order (a file removed by an earlier cap is not
10
+ // reconsidered):
11
+ // - maxAgeMs: drop entries older than the cutoff (needs `now`)
12
+ // - maxFiles: keep only the newest N survivors
13
+ // - maxBytes: evict oldest survivors until the total is within the cap
14
+ // Eviction is always oldest-first (LRU by mtime).
15
+
16
+ function planRetention(entries, { maxFiles, maxBytes, maxAgeMs, now } = {}) {
17
+ const sorted = [...entries].sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
18
+ const remove = new Set();
19
+ const survivors = () => sorted.filter((e) => !remove.has(e));
20
+
21
+ if (maxAgeMs != null && now != null) {
22
+ for (const e of sorted) if (now - e.mtimeMs > maxAgeMs) remove.add(e);
23
+ }
24
+
25
+ if (maxFiles != null) {
26
+ const live = survivors();
27
+ for (let i = 0; i < live.length - maxFiles; i++) remove.add(live[i]); // oldest excess
28
+ }
29
+
30
+ if (maxBytes != null) {
31
+ let total = survivors().reduce((sum, e) => sum + (e.size || 0), 0);
32
+ for (const e of sorted) {
33
+ if (remove.has(e)) continue;
34
+ if (total <= maxBytes) break;
35
+ remove.add(e);
36
+ total -= e.size || 0;
37
+ }
38
+ }
39
+
40
+ return sorted.filter((e) => remove.has(e));
41
+ }
42
+
43
+ module.exports = { planRetention };
@@ -0,0 +1,227 @@
1
+ 'use strict';
2
+
3
+ // Delta sync: ONE changes feed shared by P2P (v2 /delta endpoints) and the
4
+ // cloud journals (sync/<device>/<revision>.json files in each provider folder).
5
+ //
6
+ // Model (CouchDB `_changes?since=seq` on top of the existing union merge):
7
+ // - `createChangeTracker` keeps, per entry (item / tombstone / group tombstone
8
+ // / supersede / conflict record / the small synced settings), the LOCAL
9
+ // revision at which that entry last changed on THIS device, whatever its
10
+ // source (own capture, P2P, cloud). Revisions are a per-device monotonic
11
+ // counter, so cursors are "the sender's revision" and never compare clocks
12
+ // across devices.
13
+ // - `deltaSince(since)` selects every entry whose arrival revision is newer
14
+ // than the cursor. Applying a delta is the existing `foldRemoteState` union
15
+ // merge, which only ever ADDS or UPDATES (deletion needs a tombstone), so a
16
+ // partial history is safe by construction and re-sending is idempotent.
17
+ // - Restart safety: the tracker is persisted lazily (sync-state.json). On load
18
+ // the start revision jumps to max(persisted + 1, Date.now()), and any entry
19
+ // whose arrival was lost (crash inside the lazy-write window) is stamped with
20
+ // the new start revision, so it is re-sent to every peer once. Over-sending
21
+ // is harmless; under-sending would be a silent hole, so the bias is deliberate.
22
+
23
+ const crypto = require('crypto');
24
+ const clipboardModel = require('./clipboard-model');
25
+
26
+ const PROTOCOL = 2;
27
+ const K_ITEM = 'i:';
28
+ const K_TOMB = 't:';
29
+ const K_GTOMB = 'g:';
30
+ const K_SUP = 's:';
31
+ const K_CONF = 'c:';
32
+ const K_SETTINGS = 'S';
33
+
34
+ function hashJson(value) {
35
+ return crypto.createHash('sha1').update(JSON.stringify(value === undefined ? null : value)).digest('hex').slice(0, 20);
36
+ }
37
+
38
+ // The mutation clock is THE "newer" signal the merge itself uses (capture/edit,
39
+ // pin/groups/numpad, title, ts correction), so it is the right change signal
40
+ // here too; the blob refs cover a formatting payload arriving for an existing
41
+ // clip without any clock change.
42
+ function itemFingerprint(item) {
43
+ return [
44
+ clipboardModel.itemMutationClock(item),
45
+ item.textRef || '', item.htmlRef || '', item.rtfRef || '', item.image || '',
46
+ item.textHash || '', item.htmlHash || '', item.rtfHash || '',
47
+ ].join('|');
48
+ }
49
+
50
+ // Synced settings minus the three big time-stamped lists (which are tracked
51
+ // entry by entry) and minus anything that is not user data.
52
+ function settingsSmall(syncedSettings) {
53
+ const small = { ...(syncedSettings || {}) };
54
+ delete small.tombstones;
55
+ delete small.group_tombstones;
56
+ delete small.supersedes;
57
+ return small;
58
+ }
59
+
60
+ function conflictFingerprint(record) {
61
+ return `${record.updatedAt || 0}|${record.resolved ? 1 : 0}|${hashJson(record.result || null)}`;
62
+ }
63
+
64
+ function startRevisionAfterLoad(persistedRevision, now = Date.now()) {
65
+ return Math.max((Number(persistedRevision) || 0) + 1, now);
66
+ }
67
+
68
+ function createChangeTracker({ revision = 0, entries = {}, now = Date.now } = {}) {
69
+ const map = new Map();
70
+ for (const [key, entry] of Object.entries(entries || {})) {
71
+ if (entry && Number.isFinite(entry.a)) map.set(key, { a: entry.a, f: String(entry.f || '') });
72
+ }
73
+ let rev = Number(revision) || 0;
74
+ let dirty = false;
75
+
76
+ function bump() {
77
+ rev += 1;
78
+ dirty = true;
79
+ return rev;
80
+ }
81
+
82
+ // Stamp every entry the view holds that is new or changed with the current
83
+ // revision; forget entries the view no longer holds. Returns the changed keys.
84
+ function observe(view) {
85
+ const seen = new Set();
86
+ const changed = [];
87
+ const touch = (key, fingerprint) => {
88
+ seen.add(key);
89
+ const current = map.get(key);
90
+ if (current && current.f === fingerprint) return;
91
+ map.set(key, { a: rev, f: fingerprint });
92
+ changed.push(key);
93
+ };
94
+ for (const item of view.items || []) {
95
+ if (!item) continue;
96
+ touch(K_ITEM + clipboardModel.itemKey(item), itemFingerprint(item));
97
+ }
98
+ for (const t of view.tombstones || []) if (t && t.id) touch(K_TOMB + t.id, String(t.deletedAt || 0));
99
+ for (const g of view.groupTombstones || []) if (g && g.name) touch(K_GTOMB + g.name, String(g.deletedAt || 0));
100
+ for (const s of view.supersedes || []) if (s && s.from && s.to) touch(K_SUP + s.from + '>' + s.to, String(s.updatedAt || 0));
101
+ const records = view.conflicts && Array.isArray(view.conflicts.records) ? view.conflicts.records : [];
102
+ for (const c of records) if (c && c.id) touch(K_CONF + c.id, conflictFingerprint(c));
103
+ touch(K_SETTINGS, hashJson(settingsSmall(view.settings)));
104
+ for (const key of [...map.keys()]) if (!seen.has(key)) { map.delete(key); dirty = true; }
105
+ if (changed.length) dirty = true;
106
+ return changed;
107
+ }
108
+
109
+ // Everything that arrived after `since`, as a self-describing envelope.
110
+ function deltaSince(since, view, meta = {}) {
111
+ const cursor = Number(since) || 0;
112
+ const newer = key => {
113
+ const entry = map.get(key);
114
+ return !entry || entry.a > cursor;
115
+ };
116
+ const items = [];
117
+ for (const item of view.items || []) {
118
+ if (item && newer(K_ITEM + clipboardModel.itemKey(item))) items.push(item);
119
+ }
120
+ const tombstones = (view.tombstones || []).filter(t => t && t.id && newer(K_TOMB + t.id));
121
+ const groupTombstones = (view.groupTombstones || []).filter(g => g && g.name && newer(K_GTOMB + g.name));
122
+ const supersedes = (view.supersedes || []).filter(s => s && s.from && s.to && newer(K_SUP + s.from + '>' + s.to));
123
+ const records = view.conflicts && Array.isArray(view.conflicts.records) ? view.conflicts.records : [];
124
+ const conflicts = records.filter(c => c && c.id && newer(K_CONF + c.id));
125
+ const includeSettings = newer(K_SETTINGS);
126
+ const envelope = {
127
+ v: PROTOCOL,
128
+ deviceId: meta.deviceId || '',
129
+ deviceName: meta.deviceName || '',
130
+ build: meta.build || '',
131
+ port: meta.port || 0,
132
+ since: cursor,
133
+ revision: rev,
134
+ originClock: now(),
135
+ full: cursor <= 0 || items.length === (view.items || []).length,
136
+ history: items,
137
+ tombstones,
138
+ group_tombstones: groupTombstones,
139
+ supersedes,
140
+ };
141
+ if (conflicts.length) envelope.conflicts = { records: conflicts };
142
+ if (includeSettings) envelope.settings = settingsSmall(view.settings);
143
+ if (typeof meta.assetsOf === 'function') envelope.assets = meta.assetsOf(items);
144
+ return envelope;
145
+ }
146
+
147
+ function isEmpty(envelope) {
148
+ return !envelope.history.length && !envelope.tombstones.length && !envelope.group_tombstones.length
149
+ && !envelope.supersedes.length && !envelope.conflicts && !envelope.settings;
150
+ }
151
+
152
+ function serialize() {
153
+ return { revision: rev, entries: Object.fromEntries(map) };
154
+ }
155
+
156
+ return {
157
+ get revision() { return rev; },
158
+ get size() { return map.size; },
159
+ get dirty() { return dirty; },
160
+ markClean() { dirty = false; },
161
+ bump,
162
+ observe,
163
+ deltaSince,
164
+ isEmpty,
165
+ serialize,
166
+ };
167
+ }
168
+
169
+ function isEnvelope(value) {
170
+ return !!(value && typeof value === 'object' && value.v === PROTOCOL && value.deviceId && Array.isArray(value.history));
171
+ }
172
+
173
+ // The (remoteHistory, remoteSettings, remoteConflicts) triple foldRemoteState
174
+ // already consumes, built from an envelope. Items are still in STORED form
175
+ // (blob refs); the caller hydrates after fetching missing assets.
176
+ function envelopeToRemoteState(envelope) {
177
+ return {
178
+ remoteHistory: Array.isArray(envelope.history) ? envelope.history : [],
179
+ remoteSettings: {
180
+ ...(envelope.settings || {}),
181
+ tombstones: Array.isArray(envelope.tombstones) ? envelope.tombstones : [],
182
+ group_tombstones: Array.isArray(envelope.group_tombstones) ? envelope.group_tombstones : [],
183
+ supersedes: Array.isArray(envelope.supersedes) ? envelope.supersedes : [],
184
+ },
185
+ remoteConflicts: envelope.conflicts && typeof envelope.conflicts === 'object' ? envelope.conflicts : {},
186
+ };
187
+ }
188
+
189
+ // Ids an envelope can have touched in the merged history: its items, its
190
+ // tombstones, and both ends of its edit lineages. Used for O(delta) change
191
+ // detection instead of stringifying the whole 8 MB history twice per apply.
192
+ function touchedIds(envelope) {
193
+ const ids = new Set();
194
+ for (const item of envelope.history || []) if (item) ids.add(clipboardModel.itemKey(item));
195
+ for (const t of envelope.tombstones || []) if (t && t.id) ids.add(t.id);
196
+ for (const s of envelope.supersedes || []) { if (s && s.from) ids.add(s.from); if (s && s.to) ids.add(s.to); }
197
+ return ids;
198
+ }
199
+
200
+ function historyChangedBy(before, after, ids) {
201
+ if (before.length !== after.length) return true;
202
+ const beforeById = new Map();
203
+ for (const item of before) if (item) beforeById.set(clipboardModel.itemKey(item), item);
204
+ const afterById = new Map();
205
+ for (const item of after) if (item) afterById.set(clipboardModel.itemKey(item), item);
206
+ if (beforeById.size !== afterById.size) return true;
207
+ for (const id of ids) {
208
+ const a = beforeById.get(id);
209
+ const b = afterById.get(id);
210
+ if (!a && !b) continue;
211
+ if (!a || !b) return true;
212
+ if (a !== b && JSON.stringify(a) !== JSON.stringify(b)) return true;
213
+ }
214
+ return false;
215
+ }
216
+
217
+ module.exports = {
218
+ PROTOCOL,
219
+ createChangeTracker,
220
+ startRevisionAfterLoad,
221
+ settingsSmall,
222
+ itemFingerprint,
223
+ isEnvelope,
224
+ envelopeToRemoteState,
225
+ touchedIds,
226
+ historyChangedBy,
227
+ };
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+
3
+ // Cloud-folder change journals. Instead of rewriting the whole
4
+ // clipboard-history.json on every copy (8 MB per copy per provider), each
5
+ // device appends ONE small NEW file per change under
6
+ // <provider>/sync/<deviceId>/<revision16>.json
7
+ // holding the delta envelope (lib/sync-delta.js) since its previous journal
8
+ // write to that folder. Readers list the other devices' directories and apply
9
+ // every file newer than their per-device cursor. The monolith stays as a
10
+ // periodic SNAPSHOT (cold start, pre-v2 readers) and the writer prunes its own
11
+ // journal files once a snapshot covers them.
12
+ //
13
+ // Files are only ever CREATED (tmp + rename to a fresh name), never rewritten:
14
+ // Google Drive File Stream forks an object when a rename lands on an existing
15
+ // name (lib/fork-names.js), so nothing here renames over an existing file.
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const crypto = require('crypto');
20
+ const blobStore = require('./blob-store');
21
+
22
+ const JOURNAL_DIR = 'sync';
23
+ const FOLDER_ID_FILE = '.boardclip-folder-id';
24
+ const NAME_RE = /^(\d{16})\.json$/;
25
+ const DEVICE_RE = /^[a-z0-9_-]{1,64}$/i;
26
+
27
+ function safeDeviceId(deviceId) {
28
+ const value = String(deviceId || '').replace(/[^a-z0-9_-]/ig, '').slice(0, 64);
29
+ return DEVICE_RE.test(value) ? value : '';
30
+ }
31
+
32
+ function journalName(revision) {
33
+ return String(Math.max(0, Math.floor(Number(revision) || 0))).padStart(16, '0') + '.json';
34
+ }
35
+
36
+ function parseJournalName(name) {
37
+ const match = NAME_RE.exec(String(name || ''));
38
+ return match ? Number(match[1]) : null;
39
+ }
40
+
41
+ function journalRoot(syncPath) {
42
+ return path.join(syncPath, JOURNAL_DIR);
43
+ }
44
+
45
+ function deviceDir(syncPath, deviceId) {
46
+ return path.join(journalRoot(syncPath), safeDeviceId(deviceId));
47
+ }
48
+
49
+ // [{ deviceId, revision, path }] for every journal file in the folder, sorted by
50
+ // device then revision. `excludeDeviceId` skips the caller's own files.
51
+ async function listJournal(syncPath, { excludeDeviceId = '' } = {}) {
52
+ let devices = [];
53
+ try { devices = await fs.promises.readdir(journalRoot(syncPath), { withFileTypes: true }); } catch { return []; }
54
+ const out = [];
55
+ const skip = safeDeviceId(excludeDeviceId);
56
+ for (const entry of devices) {
57
+ if (!entry.isDirectory()) continue;
58
+ const deviceId = safeDeviceId(entry.name);
59
+ if (!deviceId || deviceId === skip) continue;
60
+ let names = [];
61
+ try { names = await fs.promises.readdir(path.join(journalRoot(syncPath), entry.name)); } catch { continue; }
62
+ for (const name of names) {
63
+ const revision = parseJournalName(name);
64
+ if (revision == null) continue;
65
+ out.push({ deviceId, revision, path: path.join(journalRoot(syncPath), entry.name, name) });
66
+ }
67
+ }
68
+ out.sort((a, b) => a.deviceId.localeCompare(b.deviceId) || a.revision - b.revision);
69
+ return out;
70
+ }
71
+
72
+ async function writeJournalEntry(syncPath, deviceId, envelope) {
73
+ const dir = deviceDir(syncPath, deviceId);
74
+ if (!safeDeviceId(deviceId)) throw new Error('journal: invalid device id');
75
+ await fs.promises.mkdir(dir, { recursive: true });
76
+ const finalPath = path.join(dir, journalName(envelope.revision));
77
+ const tmpPath = `${finalPath}.${process.pid}.${Date.now()}.tmp`;
78
+ const json = JSON.stringify(envelope);
79
+ await fs.promises.writeFile(tmpPath, json);
80
+ await fs.promises.rename(tmpPath, finalPath);
81
+ return { path: finalPath, bytes: Buffer.byteLength(json) };
82
+ }
83
+
84
+ async function readJournalEntry(filePath) {
85
+ try {
86
+ const parsed = blobStore.parseJsonText(await fs.promises.readFile(filePath, 'utf-8'));
87
+ return parsed && typeof parsed === 'object' ? parsed : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ // Delete this device's own journal files that a snapshot already covers
94
+ // (revision <= upToRevision) and that are old enough for every reader to have
95
+ // consumed them. Returns how many were removed.
96
+ async function pruneJournal(syncPath, deviceId, { upToRevision = 0, olderThanMs = 60 * 60 * 1000, now = Date.now() } = {}) {
97
+ const dir = deviceDir(syncPath, deviceId);
98
+ let names = [];
99
+ try { names = await fs.promises.readdir(dir); } catch { return 0; }
100
+ let removed = 0;
101
+ for (const name of names) {
102
+ const revision = parseJournalName(name);
103
+ const isTmp = /\.tmp$/.test(name);
104
+ if (revision == null && !isTmp) continue;
105
+ const file = path.join(dir, name);
106
+ try {
107
+ const stats = await fs.promises.stat(file);
108
+ if (now - stats.mtimeMs < olderThanMs) continue;
109
+ if (!isTmp && revision > upToRevision) continue;
110
+ await fs.promises.unlink(file);
111
+ removed++;
112
+ } catch {}
113
+ }
114
+ return removed;
115
+ }
116
+
117
+ async function countJournal(syncPath, deviceId) {
118
+ try {
119
+ const names = await fs.promises.readdir(deviceDir(syncPath, deviceId));
120
+ return names.filter(name => parseJournalName(name) != null).length;
121
+ } catch {
122
+ return 0;
123
+ }
124
+ }
125
+
126
+ // A stable id stored INSIDE the folder, so two mount paths of the same cloud
127
+ // folder (Google Drive on G: and H:) are recognised as one provider.
128
+ async function folderIdentity(syncPath) {
129
+ const marker = path.join(syncPath, FOLDER_ID_FILE);
130
+ try {
131
+ const raw = (await fs.promises.readFile(marker, 'utf-8')).trim();
132
+ if (/^[a-f0-9]{32}$/i.test(raw)) return raw.toLowerCase();
133
+ } catch {}
134
+ const id = crypto.randomBytes(16).toString('hex');
135
+ try {
136
+ await fs.promises.mkdir(syncPath, { recursive: true });
137
+ await fs.promises.writeFile(marker, id, { flag: 'wx' });
138
+ return id;
139
+ } catch {
140
+ // Lost a race with another writer (or a read-only folder): re-read, else
141
+ // fall back to the path itself so the folder is still treated as unique.
142
+ try {
143
+ const raw = (await fs.promises.readFile(marker, 'utf-8')).trim();
144
+ if (/^[a-f0-9]{32}$/i.test(raw)) return raw.toLowerCase();
145
+ } catch {}
146
+ return `path:${syncPath}`;
147
+ }
148
+ }
149
+
150
+ // entries: [{ path, id }] -> { primary: [path], duplicateOf: { path: primaryPath } }
151
+ function dedupeByIdentity(entries) {
152
+ const byId = new Map();
153
+ const primary = [];
154
+ const duplicateOf = {};
155
+ for (const entry of entries) {
156
+ if (!entry || !entry.path) continue;
157
+ const id = entry.id || `path:${entry.path}`;
158
+ const first = byId.get(id);
159
+ if (first) { duplicateOf[entry.path] = first; continue; }
160
+ byId.set(id, entry.path);
161
+ primary.push(entry.path);
162
+ }
163
+ return { primary, duplicateOf };
164
+ }
165
+
166
+ module.exports = {
167
+ JOURNAL_DIR,
168
+ FOLDER_ID_FILE,
169
+ safeDeviceId,
170
+ journalName,
171
+ parseJournalName,
172
+ journalRoot,
173
+ deviceDir,
174
+ listJournal,
175
+ writeJournalEntry,
176
+ readJournalEntry,
177
+ pruneJournal,
178
+ countJournal,
179
+ folderIdentity,
180
+ dedupeByIdentity,
181
+ };