@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,297 @@
1
+ 'use strict';
2
+
3
+ // macOS Carbon hotkeys cover shortcut paths where Electron's globalShortcut is
4
+ // incomplete or flaky: Globe/Fn popup shortcuts and numeric quick-paste slots.
5
+ // Unlike a Quartz event tap, this should not require Input Monitoring.
6
+
7
+ const koffi = require('koffi');
8
+
9
+ const MOD_COMMAND = 1 << 8;
10
+ const MOD_SHIFT = 1 << 9;
11
+ const MOD_ALT = 1 << 11;
12
+ const MOD_CONTROL = 1 << 12;
13
+ const MOD_FN = 1 << 17;
14
+
15
+ const KEY_CODE_BY_NAME = {
16
+ A: 0x00, S: 0x01, D: 0x02, F: 0x03, H: 0x04, G: 0x05, Z: 0x06, X: 0x07,
17
+ C: 0x08, V: 0x09, B: 0x0B, Q: 0x0C, W: 0x0D, E: 0x0E, R: 0x0F, Y: 0x10,
18
+ T: 0x11, 1: 0x12, 2: 0x13, 3: 0x14, 4: 0x15, 6: 0x16, 5: 0x17,
19
+ '=': 0x18, 9: 0x19, 7: 0x1A, Minus: 0x1B, 8: 0x1C, 0: 0x1D, ']': 0x1E,
20
+ O: 0x1F, U: 0x20, '[': 0x21, I: 0x22, P: 0x23, L: 0x25, J: 0x26,
21
+ "'": 0x27, K: 0x28, ';': 0x29, Backslash: 0x2A, ',': 0x2B, '/': 0x2C,
22
+ N: 0x2D, M: 0x2E, '.': 0x2F, '`': 0x32,
23
+ Enter: 0x24, Tab: 0x30, Space: 0x31, Backspace: 0x33, Escape: 0x35,
24
+ Delete: 0x75, Home: 0x73, End: 0x77, PageUp: 0x74, PageDown: 0x79,
25
+ Left: 0x7B, Right: 0x7C, Down: 0x7D, Up: 0x7E,
26
+ F1: 0x7A, F2: 0x78, F3: 0x63, F4: 0x76, F5: 0x60, F6: 0x61,
27
+ F7: 0x62, F8: 0x64, F9: 0x65, F10: 0x6D, F11: 0x67, F12: 0x6F,
28
+ F13: 0x69, F14: 0x6B, F15: 0x71, F16: 0x6A, F17: 0x40, F18: 0x4F,
29
+ F19: 0x50, F20: 0x5A,
30
+ num0: 0x52, num1: 0x53, num2: 0x54, num3: 0x55, num4: 0x56,
31
+ num5: 0x57, num6: 0x58, num7: 0x59, num8: 0x5B, num9: 0x5C,
32
+ };
33
+
34
+ let carbon = null;
35
+ let target = null;
36
+ let handlerCallback = null;
37
+ let handlerRef = null;
38
+ const hotKeyRefs = new Map();
39
+ const handlers = new Map();
40
+ const DEFAULT_NAMESPACE = 'default';
41
+ const QUICK_PASTE_NAMESPACE = 'quickPaste';
42
+
43
+ function fourCharCode(value) {
44
+ return (
45
+ (value.charCodeAt(0) << 24) |
46
+ (value.charCodeAt(1) << 16) |
47
+ (value.charCodeAt(2) << 8) |
48
+ value.charCodeAt(3)
49
+ ) >>> 0;
50
+ }
51
+
52
+ const EventHotKeyID = koffi.struct('EventHotKeyID', {
53
+ signature: 'uint32',
54
+ id: 'uint32',
55
+ });
56
+
57
+ const EventTypeSpec = koffi.struct('EventTypeSpec', {
58
+ eventClass: 'uint32',
59
+ eventKind: 'uint32',
60
+ });
61
+
62
+ function loadCarbon() {
63
+ if (carbon) return;
64
+ carbon = koffi.load('/System/Library/Frameworks/Carbon.framework/Carbon');
65
+ const EventHandlerProc = koffi.proto('int EventHandlerProc(void *nextHandler, void *event, void *userData)');
66
+
67
+ carbon.GetApplicationEventTarget = carbon.func('void *GetApplicationEventTarget()');
68
+ carbon.InstallEventHandler = carbon.func(
69
+ 'int InstallEventHandler(void *target, EventHandlerProc *handler, uint32 numTypes, EventTypeSpec *list, void *userData, _Out_ void **outHandlerRef)'
70
+ );
71
+ carbon.RemoveEventHandler = carbon.func('int RemoveEventHandler(void *handlerRef)');
72
+ carbon.RegisterEventHotKey = carbon.func(
73
+ 'int RegisterEventHotKey(uint32 code, uint32 modifiers, EventHotKeyID hotKeyID, void *target, uint32 options, _Out_ void **outRef)'
74
+ );
75
+ carbon.UnregisterEventHotKey = carbon.func('int UnregisterEventHotKey(void *hotKey)');
76
+ carbon.GetCurrentKeyModifiers = carbon.func('uint32 GetCurrentKeyModifiers()');
77
+ carbon.GetEventParameter = carbon.func(
78
+ 'int GetEventParameter(void *event, uint32 name, uint32 desiredType, void *outActualType, uint32 bufferSize, void *outActualSize, _Out_ EventHotKeyID *outData)'
79
+ );
80
+
81
+ target = carbon.GetApplicationEventTarget();
82
+ handlerCallback = koffi.register((_nextHandler, event) => {
83
+ const hotKeyID = {};
84
+ try {
85
+ const status = carbon.GetEventParameter(
86
+ event,
87
+ fourCharCode('----'),
88
+ fourCharCode('hkid'),
89
+ null,
90
+ koffi.sizeof(EventHotKeyID),
91
+ null,
92
+ hotKeyID
93
+ );
94
+ const handler = status === 0 ? handlers.get(hotKeyID.id) : null;
95
+ if (handler) handler();
96
+ } catch (err) { console.error('[macos-hotkey] onPressed:', err); }
97
+ return 0;
98
+ }, koffi.pointer(EventHandlerProc));
99
+ }
100
+
101
+ function normalizeShortcutPart(part) {
102
+ const value = String(part || '').trim();
103
+ const lower = value.toLowerCase();
104
+ if (lower === 'globe' || lower === 'function') return 'Fn';
105
+ if (lower === 'cmd') return 'Command';
106
+ if (lower === 'ctrl') return 'Control';
107
+ if (lower === 'option') return 'Alt';
108
+ if (lower === 'super' || lower === 'meta') return 'Command';
109
+ if (lower === 'return') return 'Enter';
110
+ if (lower === 'esc') return 'Escape';
111
+ if (lower === 'plus') return 'Plus';
112
+ if (lower === 'minus') return 'Minus';
113
+ if (/^num[0-9]$/i.test(value)) return value.toLowerCase();
114
+ if (/^f([1-9]|1[0-9]|20)$/i.test(value)) return value.toUpperCase();
115
+ if (value.length === 1 && /[a-z]/i.test(value)) return value.toUpperCase();
116
+ return value;
117
+ }
118
+
119
+ function modifierMask(part) {
120
+ switch (normalizeShortcutPart(part)) {
121
+ case 'Command':
122
+ case 'CommandOrControl':
123
+ case 'CommandOrCtrl':
124
+ case 'CmdOrCtrl':
125
+ return MOD_COMMAND;
126
+ case 'Control': return MOD_CONTROL;
127
+ case 'Alt': return MOD_ALT;
128
+ case 'Shift': return MOD_SHIFT;
129
+ case 'Fn': return MOD_FN;
130
+ default: return 0;
131
+ }
132
+ }
133
+
134
+ function usesFn(shortcut) {
135
+ return String(shortcut || '').split('+').some(part => normalizeShortcutPart(part) === 'Fn');
136
+ }
137
+
138
+ function parseShortcut(shortcut) {
139
+ const parts = String(shortcut || '').split('+').map(normalizeShortcutPart).filter(Boolean);
140
+ let modifiers = 0;
141
+ let keyName = '';
142
+ for (const part of parts) {
143
+ const mask = modifierMask(part);
144
+ if (mask) modifiers |= mask;
145
+ else keyName = part;
146
+ }
147
+ if (keyName === 'Plus') keyName = '=';
148
+ const keyCode = KEY_CODE_BY_NAME[keyName];
149
+ if (typeof keyCode !== 'number') return null;
150
+ return { keyCode, modifiers };
151
+ }
152
+
153
+ function installHandler() {
154
+ if (handlerRef) return { ok: true };
155
+ const out = [null];
156
+ const status = carbon.InstallEventHandler(target, handlerCallback, 1, [{
157
+ eventClass: fourCharCode('keyb'),
158
+ eventKind: 5,
159
+ }], null, out);
160
+ if (status !== 0) return { ok: false, error: `Could not install macOS hotkey handler (${status}).` };
161
+ handlerRef = out[0];
162
+ return { ok: true };
163
+ }
164
+
165
+ function unregisterHotKey(id) {
166
+ const entry = hotKeyRefs.get(id);
167
+ if (entry && entry.ref) {
168
+ try { carbon.UnregisterEventHotKey(entry.ref); } catch {}
169
+ }
170
+ hotKeyRefs.delete(id);
171
+ handlers.delete(id);
172
+ }
173
+
174
+ function clearNamespace(namespace = DEFAULT_NAMESPACE) {
175
+ for (const [id, entry] of [...hotKeyRefs.entries()]) {
176
+ if (entry.namespace === namespace) unregisterHotKey(id);
177
+ }
178
+ }
179
+
180
+ function clearRuntimeShortcut() {
181
+ clearNamespace(DEFAULT_NAMESPACE);
182
+ }
183
+
184
+ function clearQuickPasteShortcuts() {
185
+ clearNamespace(QUICK_PASTE_NAMESPACE);
186
+ }
187
+
188
+ function registerParsedHotKey({ parsed, id, namespace, handler, signature = 'BChk' }) {
189
+ loadCarbon();
190
+ const handlerResult = installHandler();
191
+ if (!handlerResult.ok) return handlerResult;
192
+
193
+ unregisterHotKey(id);
194
+ const out = [null];
195
+ const status = carbon.RegisterEventHotKey(parsed.keyCode, parsed.modifiers, {
196
+ signature: fourCharCode(signature),
197
+ id,
198
+ }, target, 0, out);
199
+
200
+ if (status !== 0) {
201
+ const error = status === -9878
202
+ ? 'Shortcut is already in use.'
203
+ : `Could not register macOS shortcut (${status}).`;
204
+ return { ok: false, error };
205
+ }
206
+
207
+ hotKeyRefs.set(id, { ref: out[0], namespace });
208
+ handlers.set(id, handler);
209
+ return { ok: true };
210
+ }
211
+
212
+ function install({ shortcut, onPressed: handler }) {
213
+ if (process.platform !== 'darwin') return { ok: false, error: 'macOS hotkeys are only available on macOS.' };
214
+ const parsed = parseShortcut(shortcut);
215
+ if (!parsed) return { ok: false, error: `Could not parse ${shortcut}.` };
216
+
217
+ clearRuntimeShortcut();
218
+ return registerParsedHotKey({ parsed, id: 1, namespace: DEFAULT_NAMESPACE, handler });
219
+ }
220
+
221
+ function shortcutForSlot(shortcut, slot) {
222
+ const parts = String(shortcut || '').split('+').map(normalizeShortcutPart).filter(Boolean);
223
+ if (!parts.length) return '';
224
+ parts[parts.length - 1] = String(slot);
225
+ return parts.join('+');
226
+ }
227
+
228
+ function installQuickPaste({ shortcut, onSlot }) {
229
+ if (process.platform !== 'darwin') return { ok: false, error: 'macOS hotkeys are only available on macOS.' };
230
+ clearQuickPasteShortcuts();
231
+ for (let slot = 1; slot <= 9; slot++) {
232
+ const slotShortcut = shortcutForSlot(shortcut, slot);
233
+ const parsed = parseShortcut(slotShortcut);
234
+ if (!parsed) {
235
+ clearQuickPasteShortcuts();
236
+ return { ok: false, error: `Could not parse ${slotShortcut}.` };
237
+ }
238
+ const result = registerParsedHotKey({
239
+ parsed,
240
+ id: 100 + slot,
241
+ namespace: QUICK_PASTE_NAMESPACE,
242
+ handler: () => onSlot(slot),
243
+ signature: 'BCqp',
244
+ });
245
+ if (!result.ok) {
246
+ clearQuickPasteShortcuts();
247
+ return result;
248
+ }
249
+ }
250
+ return { ok: true };
251
+ }
252
+
253
+ function currentModifierParts() {
254
+ if (process.platform !== 'darwin') return [];
255
+ loadCarbon();
256
+ const modifiers = carbon.GetCurrentKeyModifiers();
257
+ const parts = [];
258
+ if (modifiers & MOD_FN) parts.push('Fn');
259
+ if (modifiers & MOD_COMMAND) parts.push('Command');
260
+ if (modifiers & MOD_CONTROL) parts.push('Control');
261
+ if (modifiers & MOD_ALT) parts.push('Alt');
262
+ if (modifiers & MOD_SHIFT) parts.push('Shift');
263
+ return parts;
264
+ }
265
+
266
+ function resolveShortcutFromCurrentModifiers(shortcut) {
267
+ const parts = String(shortcut || '').split('+').map(normalizeShortcutPart).filter(Boolean);
268
+ const keyName = parts.reverse().find(part => !modifierMask(part));
269
+ if (!keyName) return shortcut;
270
+ const modifiers = currentModifierParts();
271
+ return modifiers.length ? [...modifiers, keyName].join('+') : shortcut;
272
+ }
273
+
274
+ function uninstall() {
275
+ clearNamespace(DEFAULT_NAMESPACE);
276
+ clearNamespace(QUICK_PASTE_NAMESPACE);
277
+ if (handlerRef && carbon) {
278
+ try { carbon.RemoveEventHandler(handlerRef); } catch {}
279
+ }
280
+ handlerRef = null;
281
+ if (handlerCallback) {
282
+ try { koffi.unregister(handlerCallback); } catch {}
283
+ }
284
+ handlerCallback = null;
285
+ }
286
+
287
+ module.exports = {
288
+ install,
289
+ clearRuntimeShortcut,
290
+ installQuickPaste,
291
+ clearQuickPasteShortcuts,
292
+ currentModifierParts,
293
+ resolveShortcutFromCurrentModifiers,
294
+ uninstall,
295
+ usesFn,
296
+ parseShortcut,
297
+ };
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ if (process.platform !== 'darwin') {
4
+ module.exports = {
5
+ sendCommandV() { return { ok: true }; },
6
+ typeText() { return { ok: false, chars: 0 }; },
7
+ };
8
+ return;
9
+ }
10
+
11
+ const koffi = require('koffi');
12
+
13
+ let coreGraphics = null;
14
+ let coreFoundation = null;
15
+
16
+ const KEY_V = 0x09;
17
+ const KEY_COMMAND = 0x37;
18
+ const kCGHIDEventTap = 0;
19
+ const kCGEventFlagMaskCommand = 1 << 20;
20
+
21
+ function loadFrameworks() {
22
+ if (coreGraphics) return;
23
+ coreGraphics = koffi.load('/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics');
24
+ coreFoundation = koffi.load('/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation');
25
+
26
+ coreGraphics.CGEventCreateKeyboardEvent = coreGraphics.func(
27
+ 'void *CGEventCreateKeyboardEvent(void *source, uint16 virtualKey, bool keyDown)'
28
+ );
29
+ coreGraphics.CGEventSetFlags = coreGraphics.func(
30
+ 'void CGEventSetFlags(void *event, uint64 flags)'
31
+ );
32
+ coreGraphics.CGEventPost = coreGraphics.func(
33
+ 'void CGEventPost(uint32 tap, void *event)'
34
+ );
35
+ // Sets the Unicode payload of a keyboard event: posting it inserts the exact
36
+ // string as text (layout-independent), the macOS analogue of Windows'
37
+ // KEYEVENTF_UNICODE. UniChar is uint16, so we pass a UTF-16 code-unit buffer.
38
+ coreGraphics.CGEventKeyboardSetUnicodeString = coreGraphics.func(
39
+ 'void CGEventKeyboardSetUnicodeString(void *event, uintptr_t length, uint16 *string)'
40
+ );
41
+ coreFoundation.CFRelease = coreFoundation.func(
42
+ 'void CFRelease(void *cf)'
43
+ );
44
+ }
45
+
46
+ function postKey(keyCode, keyDown, flags) {
47
+ const event = coreGraphics.CGEventCreateKeyboardEvent(null, keyCode, keyDown);
48
+ if (!event) throw new Error(`CGEventCreateKeyboardEvent failed for key ${keyCode}`);
49
+ try {
50
+ coreGraphics.CGEventSetFlags(event, flags);
51
+ coreGraphics.CGEventPost(kCGHIDEventTap, event);
52
+ } finally {
53
+ coreFoundation.CFRelease(event);
54
+ }
55
+ }
56
+
57
+ function sendCommandV() {
58
+ try {
59
+ loadFrameworks();
60
+ postKey(KEY_COMMAND, true, kCGEventFlagMaskCommand);
61
+ postKey(KEY_V, true, kCGEventFlagMaskCommand);
62
+ postKey(KEY_V, false, kCGEventFlagMaskCommand);
63
+ postKey(KEY_COMMAND, false, 0);
64
+ return { ok: true };
65
+ } catch (error) {
66
+ return { ok: false, error: error && error.message };
67
+ }
68
+ }
69
+
70
+ // Post a chunk of text as a single keyboard event carrying its Unicode string.
71
+ // The whole chunk is delivered in one event (not char-by-char), so it is fast.
72
+ function postUnicodeChunk(str) {
73
+ const units = [];
74
+ for (let i = 0; i < str.length; i++) units.push(str.charCodeAt(i));
75
+ const buf = Uint16Array.from(units);
76
+ const down = coreGraphics.CGEventCreateKeyboardEvent(null, 0, true);
77
+ if (!down) throw new Error('CGEventCreateKeyboardEvent failed');
78
+ try {
79
+ coreGraphics.CGEventKeyboardSetUnicodeString(down, buf.length, buf);
80
+ coreGraphics.CGEventPost(kCGHIDEventTap, down);
81
+ } finally {
82
+ coreFoundation.CFRelease(down);
83
+ }
84
+ const up = coreGraphics.CGEventCreateKeyboardEvent(null, 0, false);
85
+ if (!up) return;
86
+ try {
87
+ coreGraphics.CGEventKeyboardSetUnicodeString(up, buf.length, buf);
88
+ coreGraphics.CGEventPost(kCGHIDEventTap, up);
89
+ } finally {
90
+ coreFoundation.CFRelease(up);
91
+ }
92
+ }
93
+
94
+ // Type text by synthesizing keyboard events carrying the Unicode string —
95
+ // bypassing the clipboard entirely (no backup/restore race, clipboard
96
+ // untouched). Chunked so a huge string doesn't overflow a single event.
97
+ function typeText(text) {
98
+ const str = String(text == null ? '' : text);
99
+ if (!str) return { ok: true, chars: 0 };
100
+ try {
101
+ loadFrameworks();
102
+ const CHUNK = 200;
103
+ for (let i = 0; i < str.length; i += CHUNK) {
104
+ postUnicodeChunk(str.slice(i, i + CHUNK));
105
+ }
106
+ return { ok: true, chars: str.length };
107
+ } catch (error) {
108
+ return { ok: false, chars: str.length, error: error && error.message };
109
+ }
110
+ }
111
+
112
+ module.exports = { sendCommandV, typeText };
@@ -0,0 +1,270 @@
1
+ 'use strict';
2
+
3
+ // Pure context-shaping for the MCP server.
4
+ //
5
+ // Given the raw clipboard history + settings (read from the JSON files by the
6
+ // helper, or held in memory by the app), this builds exactly what an AI
7
+ // assistant is allowed to see, applying one filter:
8
+ //
9
+ // Allowlist by curation - only clips in a group the user shared with AI
10
+ // ("groups_shared_with_ai", which always includes the auto "AI" group)
11
+ // expose their content. Everything else is metadata only. Sharing is fully
12
+ // opt-in per group, so a clip the user put in a shared group is shared as-is.
13
+ //
14
+ // No I/O here: text-blob hydration and image bytes are the caller's job. This
15
+ // keeps the module pure + unit-testable and lets both the helper (file-backed)
16
+ // and the app (memory-backed) reuse the identical boundary.
17
+
18
+ const model = require('./clipboard-model');
19
+ const search = require('../site/shared/clip-search');
20
+
21
+ const AI_GROUP_NAME = 'AI';
22
+ const DEFAULT_PREVIEW_CHARS = 280;
23
+ const DEFAULT_LIST_LIMIT = 50;
24
+
25
+ function sharedGroupSet(settings) {
26
+ const configured = settings && Array.isArray(settings.groups_shared_with_ai)
27
+ ? settings.groups_shared_with_ai
28
+ : [];
29
+ const set = new Set(configured);
30
+ // The auto "AI" group is the quick drop-bucket and is always shared.
31
+ set.add(AI_GROUP_NAME);
32
+ return set;
33
+ }
34
+
35
+ // A clip carries a format when the value is inline OR externalized to a blob.
36
+ // Checked without loading the blob: this is availability, not content.
37
+ function hasRichPayload(item, field) {
38
+ if (!item || item.type === 'image') return false;
39
+ const names = model.blobFieldNames(field);
40
+ return !!(item[names.value] || item[names.ref] || item[names.hash]);
41
+ }
42
+
43
+ function isShared(item, sharedSet) {
44
+ const groups = model.groupsOf(item);
45
+ for (const g of groups) if (sharedSet.has(g)) return true;
46
+ return false;
47
+ }
48
+
49
+ function previewText(item, chars) {
50
+ const raw = item && item.type !== 'image'
51
+ ? String(item.text != null && item.text !== '' ? item.text : (item.textPreview || ''))
52
+ : '';
53
+ if (raw.length <= chars) return raw;
54
+ return `${raw.slice(0, chars)}…`;
55
+ }
56
+
57
+ function searchableText(item, { sharedSet = null, forceAll = false } = {}) {
58
+ const shared = sharedSet ? isShared(item, sharedSet) : false;
59
+ const includeTitle = forceAll || shared;
60
+ return [
61
+ includeTitle ? model.titleOf(item) : '',
62
+ item && item.type === 'image' ? (item.image || '') : (item && item.text != null ? item.text : item && item.textPreview || ''),
63
+ item && item.type || '',
64
+ ...(includeTitle ? model.groupsOf(item) : []),
65
+ ].filter(Boolean).join(' ');
66
+ }
67
+
68
+ // Shape a single clip for AI consumption. `forcePreview` is set only on the
69
+ // post-approval ('all') path, where the user has explicitly approved reading
70
+ // beyond the allowlist - it surfaces the preview even for a non-shared clip.
71
+ function clipView(item, { sharedSet, previewChars = DEFAULT_PREVIEW_CHARS, forcePreview = false } = {}) {
72
+ const id = model.itemKey(item);
73
+ const shared = isShared(item, sharedSet);
74
+ const base = {
75
+ id,
76
+ type: item.type === 'image' ? 'image' : 'text',
77
+ ts: item.ts || 0,
78
+ pinned: model.isPinned(item),
79
+ numpad: model.numpadSlotOf(item),
80
+ rev: model.clipRevision(item),
81
+ shared,
82
+ };
83
+ if (!shared && !forcePreview) {
84
+ // Metadata only: no content, no group names, no preview.
85
+ return base;
86
+ }
87
+ if (!shared && forcePreview) base.viaApproval = true;
88
+ // Only expose the SHARED group names a clip belongs to - never the names of
89
+ // any private/non-shared groups it is also a member of.
90
+ base.groups = model.groupsOf(item).filter(g => sharedSet.has(g));
91
+ if (item.type === 'image') {
92
+ base.image = item.image || null;
93
+ if (item.width) base.width = item.width;
94
+ if (item.height) base.height = item.height;
95
+ return base;
96
+ }
97
+ const title = model.titleOf(item);
98
+ if (title) base.title = title;
99
+ base.preview = previewText(item, previewChars);
100
+ // Advertise that formatting exists WITHOUT shipping it. The raw HTML/RTF is
101
+ // large and low-value to a model, but knowing a clip is rich is useful.
102
+ if (hasRichPayload(item, 'html')) base.has_html = true;
103
+ if (hasRichPayload(item, 'rtf')) base.has_rtf = true;
104
+ return base;
105
+ }
106
+
107
+ // The full-text read result shape, shared by the helper's get_clip fast-path and
108
+ // the app's approval-gated read_clip so both stay identical. Group names are
109
+ // filtered to shared-only, matching clipView's policy.
110
+ function fullTextResult(item, sharedSet) {
111
+ const groups = sharedSet ? model.groupsOf(item).filter(g => sharedSet.has(g)) : model.groupsOf(item);
112
+ const result = { id: model.itemKey(item), rev: model.clipRevision(item), type: 'text', text: String(item.text || ''), groups, ts: item.ts || 0 };
113
+ const title = model.titleOf(item);
114
+ if (title) result.title = title;
115
+ return result;
116
+ }
117
+
118
+ // Summary of the user's organisation - the "clipboard / workflows" context.
119
+ function buildContext(history, settings) {
120
+ const items = Array.isArray(history) ? history : [];
121
+ const sharedSet = sharedGroupSet(settings);
122
+ const groupNames = Array.isArray(settings && settings.groups) ? settings.groups : [];
123
+ const counts = new Map();
124
+ const numpad = {};
125
+ let pinnedCount = 0;
126
+ let sharedCount = 0;
127
+
128
+ for (const item of items) {
129
+ if (model.isPinned(item)) pinnedCount++;
130
+ const slot = model.numpadSlotOf(item);
131
+ if (slot != null) numpad[slot] = { id: model.itemKey(item), type: item.type === 'image' ? 'image' : 'text' };
132
+ for (const g of model.groupsOf(item)) counts.set(g, (counts.get(g) || 0) + 1);
133
+ if (isShared(item, sharedSet)) sharedCount++;
134
+ }
135
+
136
+ // Only name the SHARED groups. Private/non-shared groups are surfaced as a bare
137
+ // count so the assistant knows the scale without learning private group names.
138
+ const groups = groupNames
139
+ .filter(name => sharedSet.has(name))
140
+ .map(name => ({ name, shared: true, count: counts.get(name) || 0 }));
141
+ const privateGroupCount = groupNames.filter(name => !sharedSet.has(name)).length;
142
+
143
+ return {
144
+ totalClips: items.length,
145
+ sharedClips: sharedCount,
146
+ pinnedClips: pinnedCount,
147
+ groups,
148
+ privateGroupCount,
149
+ numpadSlots: numpad,
150
+ aiGroup: AI_GROUP_NAME,
151
+ };
152
+ }
153
+
154
+ // List clips. By default returns shared clips with previews (most recent first);
155
+ // pass includeNonShared to also append metadata-only entries for the rest.
156
+ function listClips(history, settings, {
157
+ limit = DEFAULT_LIST_LIMIT,
158
+ previewChars = DEFAULT_PREVIEW_CHARS,
159
+ group = null,
160
+ includeNonShared = false,
161
+ } = {}) {
162
+ const items = Array.isArray(history) ? history : [];
163
+ const sharedSet = sharedGroupSet(settings);
164
+ const out = [];
165
+ let nonSharedTotal = 0;
166
+
167
+ for (const item of items) {
168
+ const shared = isShared(item, sharedSet);
169
+ if (!shared) {
170
+ nonSharedTotal++;
171
+ if (!includeNonShared) continue;
172
+ }
173
+ if (group && !model.groupsOf(item).includes(group)) continue;
174
+ out.push(clipView(item, { sharedSet, previewChars }));
175
+ if (out.length >= limit) break;
176
+ }
177
+ return { clips: out, nonSharedTotal, returned: out.length };
178
+ }
179
+
180
+ // A boundary-aware search doc: the SAME normalized shape the shared engine ranks/filters,
181
+ // but with title/groups HIDDEN unless the clip is shared (or forceAll after approval). So the
182
+ // engine's grammar (title:/since:/is:/…) works while the allowlist boundary is preserved — a
183
+ // `title:` scope on a non-shared clip can never match unless the user approved all-scope.
184
+ function clipToSearchDoc(item, { sharedSet, forceAll = false } = {}) {
185
+ const visible = forceAll || isShared(item, sharedSet);
186
+ const isImage = item && item.type === 'image';
187
+ const body = isImage ? String(item && item.image || '') : String(item && (item.text != null ? item.text : item.textPreview) || '');
188
+ return {
189
+ id: model.itemKey(item),
190
+ type: isImage ? 'image' : 'text',
191
+ title: visible ? model.titleOf(item) : '',
192
+ body,
193
+ groups: visible ? model.groupsOf(item) : [],
194
+ numpad: model.numpadSlotOf(item),
195
+ pinned: model.isPinned(item),
196
+ ts: Number(item && item.ts) || 0,
197
+ len: isImage ? 0 : body.length,
198
+ lines: isImage || !body ? 0 : body.split(/\r\n|\r|\n/).length,
199
+ words: isImage || !body.trim() ? 0 : body.trim().split(/\s+/).length,
200
+ url: !isImage && /^(?:[a-z][a-z0-9+.-]*:\/\/|www\.)[^\s]+$|^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:\/[^\s]*)?$/i.test(body.trim()),
201
+ rich: !isImage && !!(item.html || item.htmlRef || item.htmlHash || item.rtf || item.rtfRef || item.rtfHash),
202
+ };
203
+ }
204
+
205
+ // Search. Shared matches return previews; non-shared matches are counted only (the caller
206
+ // decides whether to escalate to an approval-gated full search). Delegates parsing +
207
+ // matching to the SHARED engine (site/shared/clip-search) so the MCP tool understands the
208
+ // SAME language as the app's search field: `title:`, `group:`, `is:pinned`,
209
+ // `since:7d`, `-word`, `/regex/`, `a OR b`, `( )`. Plain words are literal. The old
210
+ // `regex: true` flag still works: the query's free text is read as ONE regex
211
+ // (search.legacyRegexQuery).
212
+ function searchClips(history, settings, {
213
+ query,
214
+ regex = false,
215
+ limit = DEFAULT_LIST_LIMIT,
216
+ previewChars = DEFAULT_PREVIEW_CHARS,
217
+ scope = 'shared',
218
+ now,
219
+ } = {}) {
220
+ const items = Array.isArray(history) ? history : [];
221
+ const sharedSet = sharedGroupSet(settings);
222
+ const parsed = search.parseQuery(regex ? search.legacyRegexQuery(query) : query);
223
+ const matches = [];
224
+ let nonSharedMatches = 0;
225
+
226
+ for (const item of items) {
227
+ const shared = isShared(item, sharedSet);
228
+ const doc = clipToSearchDoc(item, { sharedSet, forceAll: scope === 'all' });
229
+ if (!search.matchDoc(doc, parsed, { now })) continue;
230
+ if (shared) {
231
+ if (matches.length < limit) matches.push(clipView(item, { sharedSet, previewChars }));
232
+ } else {
233
+ nonSharedMatches++;
234
+ // scope === 'all' is only used by the app AFTER approval; it shapes the
235
+ // non-shared match as a real preview so the assistant can read it.
236
+ if (scope === 'all' && matches.length < limit) {
237
+ matches.push(clipView(item, { sharedSet, previewChars, forcePreview: true }));
238
+ }
239
+ }
240
+ }
241
+ return { matches, nonSharedMatches, returned: matches.length };
242
+ }
243
+
244
+ // Resolve a single clip id for a full-text read. Returns the decision; the
245
+ // caller hydrates the text only when allowed. `reason` is one of:
246
+ // 'ok' | 'not_found' | 'not_shared'.
247
+ function resolveForRead(history, settings, id) {
248
+ const items = Array.isArray(history) ? history : [];
249
+ const sharedSet = sharedGroupSet(settings);
250
+ const item = items.find(i => model.itemKey(i) === id);
251
+ if (!item) return { reason: 'not_found', item: null, shared: false };
252
+ const shared = isShared(item, sharedSet);
253
+ if (!shared) return { reason: 'not_shared', item, shared: false };
254
+ return { reason: 'ok', item, shared: true };
255
+ }
256
+
257
+ module.exports = {
258
+ AI_GROUP_NAME,
259
+ DEFAULT_PREVIEW_CHARS,
260
+ DEFAULT_LIST_LIMIT,
261
+ sharedGroupSet,
262
+ isShared,
263
+ previewText,
264
+ clipView,
265
+ fullTextResult,
266
+ buildContext,
267
+ listClips,
268
+ searchClips,
269
+ resolveForRead,
270
+ };