@tobq/boardclip 0.0.0-stage → 2.1.108

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
package/lib/backup.js ADDED
@@ -0,0 +1,275 @@
1
+ 'use strict';
2
+
3
+ // Content-addressed local backup store (the "logic-bug time-machine").
4
+ //
5
+ // A backup is a point-in-time snapshot of the clipboard history + settings. The
6
+ // naive design copied the WHOLE history JSON (~4-5MB) on every change, so an edit
7
+ // to one note re-stored thousands of unchanged items. This store instead keeps a
8
+ // content-addressed OBJECT POOL: each stored item (and the settings object) is
9
+ // written once under its sha256, and a snapshot is just a small MANIFEST listing
10
+ // the ordered item hashes. Unchanged items across snapshots share one blob, so a
11
+ // snapshot after editing one note costs ~one item + a manifest, not a full copy.
12
+ //
13
+ // Everything stays PLAIN-TEXT JSON on disk (objects and manifests) so a backup is
14
+ // still greppable/inspectable during an incident. Reuses lib/blob-store for the
15
+ // atomic write + dir helpers and lib/retention (planRetention) for eviction.
16
+ //
17
+ // Layout under `baseDir` (e.g. clipboard-backups/):
18
+ // objects/{sha256}.json one stored item, or the settings object
19
+ // snapshots/{stamp}-{reason}.json manifest { items:[hash], settings:hash, ... }
20
+ // {stamp}-{reason}-{hash12}.json LEGACY full snapshots (read-only, age out)
21
+ //
22
+ // This module is deliberately filesystem-real but dependency-light: pass a baseDir
23
+ // and (optionally) `now`, so it's testable against a temp dir.
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const crypto = require('crypto');
28
+ const blobStore = require('./blob-store');
29
+ const { planRetention } = require('./retention');
30
+
31
+ const OBJECTS_DIRNAME = 'objects';
32
+ const SNAPSHOTS_DIRNAME = 'snapshots';
33
+ const OBJECT_RE = /^[a-f0-9]{64}\.json$/i;
34
+ const MANIFEST_RE = /\.json$/i;
35
+ // Legacy full-snapshot filenames: {ISO-stamp}-{reason}-{hash12}.json in baseDir.
36
+ const LEGACY_RE = /^\d{4}-\d{2}-\d{2}T[\dhZ.-]*-[a-z]+-[a-f0-9]{6,}\.json$/i;
37
+
38
+ function sha256Hex(str) {
39
+ return crypto.createHash('sha256').update(str).digest('hex');
40
+ }
41
+
42
+ // Deterministic JSON (recursively sorted object keys) so two equal items always
43
+ // serialize to the same bytes and therefore hash to the same object.
44
+ function stableStringify(value) {
45
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
46
+ if (value && typeof value === 'object') {
47
+ const keys = Object.keys(value).sort();
48
+ return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
49
+ }
50
+ return JSON.stringify(value === undefined ? null : value);
51
+ }
52
+
53
+ function objectsDir(baseDir) { return path.join(baseDir, OBJECTS_DIRNAME); }
54
+ function snapshotsDir(baseDir) { return path.join(baseDir, SNAPSHOTS_DIRNAME); }
55
+
56
+ function objectPath(baseDir, hash) {
57
+ return path.join(objectsDir(baseDir), `${hash}.json`);
58
+ }
59
+
60
+ // In-memory index of the pool's hashes per baseDir, seeded ONCE from a readdir
61
+ // of objects/ (one syscall for the whole pool). The previous existsSync-per-item
62
+ // check was a ~10k-stat storm on every snapshot of a mature history (~0.8s of
63
+ // the 0.9s a no-change snapshot cost, measured 2026-09-02). GC invalidates the
64
+ // index for its baseDir so a re-referenced object is re-written, never assumed.
65
+ const poolIndex = new Map();
66
+ function knownHashes(baseDir) {
67
+ let set = poolIndex.get(baseDir);
68
+ if (!set) {
69
+ set = new Set();
70
+ let names = [];
71
+ try { names = fs.readdirSync(objectsDir(baseDir)); } catch {}
72
+ for (const name of names) {
73
+ if (OBJECT_RE.test(name)) set.add(name.slice(0, -'.json'.length).toLowerCase());
74
+ }
75
+ poolIndex.set(baseDir, set);
76
+ }
77
+ return set;
78
+ }
79
+
80
+ // Write `value` into the pool iff its content isn't already there; return its hash.
81
+ function putObject(baseDir, value) {
82
+ const json = stableStringify(value);
83
+ const hash = sha256Hex(json);
84
+ const known = knownHashes(baseDir);
85
+ if (!known.has(hash)) {
86
+ blobStore.ensureDir(objectsDir(baseDir));
87
+ blobStore.atomicWriteFile(objectPath(baseDir, hash), json);
88
+ known.add(hash);
89
+ }
90
+ return hash;
91
+ }
92
+
93
+ function getObject(baseDir, hash) {
94
+ try { return JSON.parse(fs.readFileSync(objectPath(baseDir, hash), 'utf8')); }
95
+ catch { return null; }
96
+ }
97
+
98
+ function toIso(createdAt) {
99
+ if (createdAt instanceof Date) return createdAt.toISOString();
100
+ const d = new Date(createdAt || Date.now());
101
+ return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString();
102
+ }
103
+
104
+ // Write a content-addressed snapshot. Returns { manifestPath, manifest }.
105
+ function writeSnapshot(baseDir, { history, settings = null, reason = 'periodic', createdAt = new Date(), source = {} } = {}) {
106
+ const items = (Array.isArray(history) ? history : []).map(item => putObject(baseDir, item));
107
+ const settingsHash = settings != null ? putObject(baseDir, settings) : null;
108
+ const iso = toIso(createdAt);
109
+ const manifest = {
110
+ version: 1,
111
+ createdAt: iso,
112
+ reason,
113
+ source,
114
+ count: items.length,
115
+ settings: settingsHash,
116
+ items,
117
+ };
118
+ const dir = snapshotsDir(baseDir);
119
+ blobStore.ensureDir(dir);
120
+ const stamp = iso.replace(/[:.]/g, '-');
121
+ const manifestPath = path.join(dir, `${stamp}-${reason}.json`);
122
+ blobStore.atomicWriteFile(manifestPath, JSON.stringify(manifest));
123
+ return { manifestPath, manifest };
124
+ }
125
+
126
+ function readManifestFile(filePath) {
127
+ try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; }
128
+ }
129
+
130
+ // Reconstruct a snapshot's full { createdAt, reason, history, settings } from either
131
+ // a new manifest (items are hashes → resolve from the pool) or a legacy full file
132
+ // (history/settings inline). Accepts a manifest path or a parsed manifest object.
133
+ function readSnapshot(baseDir, manifestOrPath) {
134
+ const manifest = typeof manifestOrPath === 'string' ? readManifestFile(manifestOrPath) : manifestOrPath;
135
+ if (!manifest || typeof manifest !== 'object') return null;
136
+
137
+ // Legacy full snapshot: history already inline.
138
+ if (Array.isArray(manifest.history)) {
139
+ return {
140
+ createdAt: manifest.createdAt || null,
141
+ reason: manifest.reason || 'legacy',
142
+ history: manifest.history,
143
+ settings: manifest.settings && typeof manifest.settings === 'object' ? manifest.settings : null,
144
+ };
145
+ }
146
+
147
+ // New manifest: resolve item hashes + settings hash from the pool.
148
+ const items = Array.isArray(manifest.items) ? manifest.items : [];
149
+ const history = items.map(hash => getObject(baseDir, hash)).filter(v => v != null);
150
+ const settings = manifest.settings ? getObject(baseDir, manifest.settings) : null;
151
+ return {
152
+ createdAt: manifest.createdAt || null,
153
+ reason: manifest.reason || 'periodic',
154
+ history,
155
+ settings,
156
+ };
157
+ }
158
+
159
+ // Enumerate snapshot descriptors (new manifests + legacy full files), newest first.
160
+ function listSnapshots(baseDir) {
161
+ const out = [];
162
+ // New manifests.
163
+ try {
164
+ for (const name of fs.readdirSync(snapshotsDir(baseDir))) {
165
+ if (!MANIFEST_RE.test(name)) continue;
166
+ const filePath = path.join(snapshotsDir(baseDir), name);
167
+ try {
168
+ const st = fs.statSync(filePath);
169
+ out.push({ path: filePath, name, mtimeMs: st.mtimeMs, size: st.size, legacy: false });
170
+ } catch {}
171
+ }
172
+ } catch {}
173
+ // Legacy full snapshots in baseDir.
174
+ try {
175
+ for (const name of fs.readdirSync(baseDir)) {
176
+ if (!LEGACY_RE.test(name)) continue;
177
+ const filePath = path.join(baseDir, name);
178
+ try {
179
+ const st = fs.statSync(filePath);
180
+ if (st.isFile()) out.push({ path: filePath, name, mtimeMs: st.mtimeMs, size: st.size, legacy: true });
181
+ } catch {}
182
+ }
183
+ } catch {}
184
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
185
+ return out;
186
+ }
187
+
188
+ // Set of object hashes referenced by the given manifest descriptors.
189
+ function referencedHashes(baseDir, descriptors) {
190
+ const refs = new Set();
191
+ for (const d of descriptors) {
192
+ if (d.legacy) continue; // legacy files carry their own content, reference no pool objects
193
+ const manifest = readManifestFile(d.path);
194
+ if (!manifest) continue;
195
+ for (const h of Array.isArray(manifest.items) ? manifest.items : []) refs.add(h);
196
+ if (manifest.settings) refs.add(manifest.settings);
197
+ }
198
+ return refs;
199
+ }
200
+
201
+ // Delete pool objects not referenced by any surviving manifest. Returns freed count.
202
+ function gcObjects(baseDir, survivors) {
203
+ const refs = referencedHashes(baseDir, survivors);
204
+ let freed = 0;
205
+ let names = [];
206
+ try { names = fs.readdirSync(objectsDir(baseDir)); } catch { return 0; }
207
+ for (const name of names) {
208
+ if (!OBJECT_RE.test(name)) continue;
209
+ const hash = name.slice(0, -'.json'.length).toLowerCase();
210
+ if (refs.has(hash)) continue;
211
+ try { fs.rmSync(path.join(objectsDir(baseDir), name), { force: true }); freed++; } catch {}
212
+ }
213
+ poolIndex.delete(baseDir); // objects vanished: re-seed lazily on the next putObject
214
+ return freed;
215
+ }
216
+
217
+ function totalBytes(baseDir) {
218
+ return blobStore.directoryBytes(objectsDir(baseDir))
219
+ + blobStore.directoryBytes(snapshotsDir(baseDir))
220
+ + blobStore.directoryBytes(baseDir); // legacy files live directly in baseDir
221
+ }
222
+
223
+ // Object GC reads every surviving manifest, so on the save hot path it must not run
224
+ // on every prune. Throttle it: the cheap part (manifest eviction) always runs; the
225
+ // full-scan GC + size enforcement runs only when something was actually evicted (so
226
+ // orphans are possible) and the throttle has elapsed, OR when the byte cap is exceeded.
227
+ // Orphaned objects between GCs are tiny (a changed item each) and harmless.
228
+ const lastGcByBase = new Map();
229
+
230
+ // Prune snapshots by age + count, GC unreferenced objects, then enforce a size
231
+ // ceiling by dropping the oldest surviving snapshots (re-GC) until under maxBytes.
232
+ function pruneBackups(baseDir, { maxAgeMs, maxBytes, maxManifests, now = Date.now(), gcMinIntervalMs = 0 } = {}) {
233
+ let survivors = listSnapshots(baseDir);
234
+
235
+ // 1. age + count eviction (planRetention is oldest-first LRU by mtime). Cheap:
236
+ // listSnapshots only stats files, and eviction is stat+unlink — no manifest reads.
237
+ const evicted = planRetention(
238
+ survivors.map(s => ({ ...s, filePath: s.path })),
239
+ { maxAgeMs, maxFiles: maxManifests, now },
240
+ );
241
+ const evictedPaths = new Set(evicted.map(e => e.filePath));
242
+ for (const e of evicted) { try { fs.rmSync(e.filePath, { force: true }); } catch {} }
243
+ survivors = survivors.filter(s => !evictedPaths.has(s.path));
244
+
245
+ const overSize = maxBytes != null && totalBytes(baseDir) > maxBytes;
246
+ const gcDue = (now - (lastGcByBase.get(baseDir) || 0)) >= gcMinIntervalMs;
247
+ if (!((evictedPaths.size > 0 && gcDue) || overSize)) return;
248
+
249
+ // 2. GC objects no longer referenced by a surviving manifest.
250
+ gcObjects(baseDir, survivors);
251
+ lastGcByBase.set(baseDir, now);
252
+
253
+ // 3. size ceiling: drop oldest survivors until total is under maxBytes.
254
+ if (maxBytes != null) {
255
+ survivors.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
256
+ while (survivors.length > 1 && totalBytes(baseDir) > maxBytes) {
257
+ const oldest = survivors.shift();
258
+ try { fs.rmSync(oldest.path, { force: true }); } catch {}
259
+ gcObjects(baseDir, survivors);
260
+ }
261
+ }
262
+ }
263
+
264
+ module.exports = {
265
+ stableStringify,
266
+ sha256Hex,
267
+ putObject,
268
+ getObject,
269
+ writeSnapshot,
270
+ readSnapshot,
271
+ listSnapshots,
272
+ pruneBackups,
273
+ OBJECTS_DIRNAME,
274
+ SNAPSHOTS_DIRNAME,
275
+ };
@@ -0,0 +1,275 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const blobStore = require('./blob-store');
6
+ const clipboardModel = require('./clipboard-model');
7
+
8
+ const DEFAULT_THRESHOLD_BYTES = 64 * 1024;
9
+
10
+ // ONE content-addressed blob store, instantiated once per clipboard payload
11
+ // FIELD (text, html, rtf). Hashing, atomic writes, hydrate, externalize-above-
12
+ // threshold, sync filtering and unreferenced cleanup live here exactly once, so
13
+ // a fourth clipboard format is a config object rather than a fourth copy of the
14
+ // mechanics. lib/blob-store.js still owns the raw file primitives.
15
+ //
16
+ // The one real behavioural axis is PREVIEW, and it is not cosmetic:
17
+ // text (previewChars > 0) keeps a truncated preview, so a missing blob still
18
+ // renders something in the list and the user does not see an empty clip.
19
+ // html/rtf (previewChars = 0) must NOT. A truncated markup payload is worse
20
+ // than none — unbalanced tags and broken RTF groups paste as visible
21
+ // garbage — so a missing rich blob degrades to ABSENT, which makes the
22
+ // paste fall back to plain text. That is the correct failure direction.
23
+ //
24
+ // dropWhenEmpty removes the field entirely when there is nothing to store, so
25
+ // every plain-text clip in the history file does not grow two empty strings.
26
+ function makeBlobFieldStore(config) {
27
+ const field = String(config.field || '');
28
+ const dirname = String(config.dirname || '');
29
+ const ext = String(config.ext || '');
30
+ const previewChars = Number(config.previewChars) || 0;
31
+ const dropWhenEmpty = !!config.dropWhenEmpty;
32
+ const defaultThreshold = Number(config.thresholdBytes) || DEFAULT_THRESHOLD_BYTES;
33
+ const names = clipboardModel.blobFieldNames(field);
34
+ const REF_RE = new RegExp(`^[a-f0-9]{64}\\.${ext}$`, 'i');
35
+ const MISSING_FLAG = `__${field}BlobMissing`;
36
+ const LOADED_FLAG = `__${field}BlobLoaded`;
37
+
38
+ function refForHash(hash) {
39
+ const normalized = String(hash || '').trim().toLowerCase();
40
+ return /^[a-f0-9]{64}$/.test(normalized) ? `${normalized}.${ext}` : '';
41
+ }
42
+
43
+ function safeRef(ref) {
44
+ const value = path.basename(String(ref || '').trim());
45
+ return REF_RE.test(value) ? value.toLowerCase() : '';
46
+ }
47
+
48
+ // Every path helper refuses a falsy baseDir. Without this a missing directory
49
+ // would make path.join('', ref) resolve to a RELATIVE path and read or write
50
+ // blobs next to the process cwd.
51
+ function pathForRef(baseDir, ref) {
52
+ if (!baseDir) return '';
53
+ const safe = safeRef(ref);
54
+ return safe ? path.join(baseDir, safe) : '';
55
+ }
56
+
57
+ function preview(value) {
58
+ return previewChars ? String(value || '').slice(0, previewChars) : '';
59
+ }
60
+
61
+ function byteLength(value) {
62
+ return Buffer.byteLength(String(value || ''), 'utf8');
63
+ }
64
+
65
+ function markRuntime(item, key, value) {
66
+ Object.defineProperty(item, key, {
67
+ value,
68
+ writable: true,
69
+ configurable: true,
70
+ enumerable: false,
71
+ });
72
+ }
73
+
74
+ function clearRuntimeFlags(item) {
75
+ try { delete item[MISSING_FLAG]; } catch {}
76
+ try { delete item[LOADED_FLAG]; } catch {}
77
+ }
78
+
79
+ function clearMetadata(copy) {
80
+ delete copy[names.hash];
81
+ delete copy[names.ref];
82
+ delete copy[names.size];
83
+ delete copy[names.preview];
84
+ }
85
+
86
+ function setValue(target, value) {
87
+ if (dropWhenEmpty && !value) delete target[names.value];
88
+ else target[names.value] = value;
89
+ }
90
+
91
+ function writeBlob(baseDir, value, hash) {
92
+ if (!baseDir) return '';
93
+ blobStore.ensureDir(baseDir);
94
+ const ref = refForHash(hash || clipboardModel.valueHash(value));
95
+ if (!ref) return '';
96
+ const filePath = pathForRef(baseDir, ref);
97
+ if (!filePath) return '';
98
+ if (!fs.existsSync(filePath)) blobStore.atomicWriteFile(filePath, String(value || ''));
99
+ return ref;
100
+ }
101
+
102
+ function readBlob(baseDir, item) {
103
+ if (!item || item.type === 'image') return null;
104
+ const ref = safeRef(item[names.ref]) || refForHash(item[names.hash]);
105
+ const filePath = pathForRef(baseDir, ref);
106
+ if (!filePath) return null;
107
+ try {
108
+ const value = fs.readFileSync(filePath, 'utf8');
109
+ if (item[names.hash] && clipboardModel.valueHash(value) !== String(item[names.hash]).toLowerCase()) {
110
+ return null;
111
+ }
112
+ return value;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ function hydrateItem(item, baseDir) {
119
+ if (!item || item.type === 'image') return item;
120
+ clearRuntimeFlags(item);
121
+ if (!item[names.ref] && !item[names.hash]) {
122
+ setValue(item, String(item[names.value] || ''));
123
+ return item;
124
+ }
125
+
126
+ const value = readBlob(baseDir, item);
127
+ if (value != null) {
128
+ item[names.value] = value;
129
+ markRuntime(item, LOADED_FLAG, true);
130
+ return item;
131
+ }
132
+
133
+ setValue(item, previewChars ? String(item[names.preview] || item[names.value] || '') : '');
134
+ markRuntime(item, MISSING_FLAG, true);
135
+ return item;
136
+ }
137
+
138
+ function hydrateHistory(items, baseDir) {
139
+ if (!Array.isArray(items)) return [];
140
+ for (const item of items) hydrateItem(item, baseDir);
141
+ return items;
142
+ }
143
+
144
+ function prepareItemForStorage(item, baseDir, options = {}) {
145
+ if (!item || item.type === 'image') return { ...(item || {}) };
146
+ const threshold = Number(options.thresholdBytes) || defaultThreshold;
147
+
148
+ // Metadata present but the in-memory value is not the full payload (a
149
+ // missing blob, or a peer's preview-only copy). Keep the metadata so the
150
+ // blob can still be fetched from a peer; never overwrite it with a partial.
151
+ if ((item[names.hash] || item[names.ref]) && !clipboardModel.hasVerifiedFullValue(item, names)) {
152
+ const copy = { ...item };
153
+ const kept = previewChars ? String(item[names.preview] || item[names.value] || '') : '';
154
+ setValue(copy, kept);
155
+ if (previewChars && !copy[names.preview]) copy[names.preview] = kept;
156
+ clearRuntimeFlags(copy);
157
+ return copy;
158
+ }
159
+
160
+ const value = String(item[names.value] || '');
161
+ const bytes = byteLength(value);
162
+ const shouldExternalize = bytes > threshold || !!item[names.ref];
163
+ if (!shouldExternalize) {
164
+ const copy = { ...item };
165
+ setValue(copy, value);
166
+ clearMetadata(copy);
167
+ clearRuntimeFlags(copy);
168
+ return copy;
169
+ }
170
+
171
+ const hash = clipboardModel.valueHash(value);
172
+ try {
173
+ const ref = writeBlob(baseDir, value, hash);
174
+ if (!ref) throw new Error('Invalid blob reference');
175
+ const copy = { ...item };
176
+ setValue(copy, preview(value));
177
+ copy[names.hash] = hash;
178
+ copy[names.ref] = ref;
179
+ copy[names.size] = bytes;
180
+ if (previewChars) copy[names.preview] = preview(value);
181
+ clearRuntimeFlags(copy);
182
+ return copy;
183
+ } catch {
184
+ // Corruption guard: if the blob cannot be written, keep the full value
185
+ // inline rather than shipping a reference to a file that does not exist.
186
+ const copy = { ...item };
187
+ copy[names.value] = value;
188
+ clearMetadata(copy);
189
+ clearRuntimeFlags(copy);
190
+ return copy;
191
+ }
192
+ }
193
+
194
+ function prepareHistoryForStorage(items, baseDir, options = {}) {
195
+ if (baseDir) { try { blobStore.ensureDir(baseDir); } catch {} }
196
+ return (Array.isArray(items) ? items : []).map(item => prepareItemForStorage(item, baseDir, options));
197
+ }
198
+
199
+ function refsOf(items) {
200
+ const refs = new Set();
201
+ for (const item of Array.isArray(items) ? items : []) {
202
+ const ref = safeRef(item && item[names.ref]);
203
+ if (ref) refs.add(ref);
204
+ }
205
+ return [...refs];
206
+ }
207
+
208
+ function syncBlobs(localDir, remoteDir) {
209
+ if (!localDir || !remoteDir) return;
210
+ blobStore.syncMissingFiles(localDir, remoteDir, { filter: name => !!safeRef(name) });
211
+ }
212
+
213
+ function removeLocalBlobIfUnreferenced(item, items, baseDir) {
214
+ if (!item || item.type === 'image' || !baseDir) return;
215
+ const ref = safeRef(item[names.ref]) || refForHash(item[names.hash]);
216
+ if (!ref) return;
217
+ const stillUsed = (items || []).some(candidate => candidate !== item && (
218
+ safeRef(candidate && candidate[names.ref]) === ref ||
219
+ refForHash(candidate && candidate[names.hash]) === ref
220
+ ));
221
+ if (stillUsed) return;
222
+ try { fs.rmSync(pathForRef(baseDir, ref), { force: true }); } catch {}
223
+ }
224
+
225
+ // True when the item advertises this payload but hydrate could not produce it
226
+ // (the blob file is absent locally — typically synced metadata whose blob has
227
+ // not arrived yet). The distinction matters: such a payload is ADVERTISED but
228
+ // not USABLE, so a fresh capture carrying the real bytes should replace it.
229
+ function isBlobMissing(item) {
230
+ return !!(item && item[MISSING_FLAG]);
231
+ }
232
+
233
+ // Remove the whole field group. Used when replacing one payload with another:
234
+ // a leftover ref/hash beside a new inline value describes different content,
235
+ // and prepareItemForStorage would then reject the new value as unverified.
236
+ function clearField(item) {
237
+ if (!item) return item;
238
+ delete item[names.value];
239
+ clearMetadata(item);
240
+ clearRuntimeFlags(item);
241
+ return item;
242
+ }
243
+
244
+ function setInlineValue(item, value) {
245
+ if (!item || item.type === 'image') return item;
246
+ setValue(item, String(value || ''));
247
+ clearMetadata(item);
248
+ clearRuntimeFlags(item);
249
+ return item;
250
+ }
251
+
252
+ return {
253
+ field,
254
+ dirname,
255
+ ext,
256
+ names,
257
+ previewChars,
258
+ thresholdBytes: defaultThreshold,
259
+ refForHash,
260
+ safeRef,
261
+ preview,
262
+ hydrateItem,
263
+ hydrateHistory,
264
+ prepareItemForStorage,
265
+ prepareHistoryForStorage,
266
+ refsOf,
267
+ syncBlobs,
268
+ removeLocalBlobIfUnreferenced,
269
+ setInlineValue,
270
+ isBlobMissing,
271
+ clearField,
272
+ };
273
+ }
274
+
275
+ module.exports = { makeBlobFieldStore, DEFAULT_THRESHOLD_BYTES };
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function ensureDir(dir) {
7
+ fs.mkdirSync(dir, { recursive: true });
8
+ }
9
+
10
+ function atomicWriteFile(filePath, data) {
11
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
12
+ fs.writeFileSync(tmpPath, data);
13
+ fs.renameSync(tmpPath, filePath);
14
+ }
15
+
16
+ // ONE JSON text parser for every store read (local files AND remote provider
17
+ // files). Windows PowerShell's default UTF-8 writer prepends a BOM, which
18
+ // JSON.parse rejects — and a rejected parse is destructive here: the caller
19
+ // treats the store as empty and the next canonical write replaces it. Accept
20
+ // that valid document instead of silently discarding the user's data.
21
+ function parseJsonText(raw) {
22
+ return JSON.parse(String(raw == null ? '' : raw).replace(/^\uFEFF/, ''));
23
+ }
24
+
25
+ function directoryBytes(dir) {
26
+ let total = 0;
27
+ try {
28
+ for (const name of fs.readdirSync(dir)) {
29
+ try {
30
+ const stats = fs.statSync(path.join(dir, name));
31
+ if (stats.isFile()) total += stats.size;
32
+ } catch {}
33
+ }
34
+ } catch {}
35
+ return total;
36
+ }
37
+
38
+ function copyMissingFiles(fromDir, toDir, options = {}) {
39
+ const filter = typeof options.filter === 'function' ? options.filter : () => true;
40
+ try { ensureDir(toDir); } catch { return; }
41
+
42
+ let names = [];
43
+ try { names = fs.readdirSync(fromDir); } catch { return; }
44
+
45
+ for (const name of names) {
46
+ if (!filter(name)) continue;
47
+ const source = path.join(fromDir, name);
48
+ const dest = path.join(toDir, name);
49
+ try {
50
+ if (!fs.statSync(source).isFile()) continue;
51
+ if (!fs.existsSync(dest)) fs.copyFileSync(source, dest);
52
+ } catch {}
53
+ }
54
+ }
55
+
56
+ function syncMissingFiles(localDir, remoteDir, options = {}) {
57
+ copyMissingFiles(remoteDir, localDir, options);
58
+ copyMissingFiles(localDir, remoteDir, options);
59
+ }
60
+
61
+ module.exports = {
62
+ ensureDir,
63
+ atomicWriteFile,
64
+ parseJsonText,
65
+ directoryBytes,
66
+ copyMissingFiles,
67
+ syncMissingFiles,
68
+ };