@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.
- 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
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
|
+
};
|