@tobq/boardclip 2.1.113 → 2.1.114

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/lib/mcp-core.js CHANGED
@@ -15,21 +15,121 @@
15
15
  // keeps the module pure + unit-testable and lets both the helper (file-backed)
16
16
  // and the app (memory-backed) reuse the identical boundary.
17
17
 
18
+ const crypto = require('crypto');
18
19
  const model = require('./clipboard-model');
19
20
  const search = require('../site/shared/clip-search');
20
21
 
21
- const AI_GROUP_NAME = 'AI';
22
+ // The AI group is spelled exactly the model's AI tag (always shared with AI).
23
+ const AI_GROUP_NAME = model.AI_TAG_NAME;
22
24
  const DEFAULT_PREVIEW_CHARS = 280;
23
25
  const DEFAULT_LIST_LIMIT = 50;
24
26
 
27
+ // Groups are case-insensitive (search.tagKey), so the set answers has() for a
28
+ // group name in any spelling.
25
29
  function sharedGroupSet(settings) {
26
30
  const configured = settings && Array.isArray(settings.groups_shared_with_ai)
27
31
  ? settings.groups_shared_with_ai
28
32
  : [];
29
- const set = new Set(configured);
33
+ const keys = new Set(configured.map(search.tagKey).filter(Boolean));
30
34
  // The auto "AI" group is the quick drop-bucket and is always shared.
31
- set.add(AI_GROUP_NAME);
32
- return set;
35
+ keys.add(search.tagKey(AI_GROUP_NAME));
36
+ return { has: (name) => keys.has(search.tagKey(name)), keys, size: keys.size };
37
+ }
38
+
39
+ // ---- Ids and revs at the boundary ----
40
+ // A clip's real id is a content hash (txt:sha256 of its text) and its real rev
41
+ // hashes every group it is in, so handing either out raw lets an AI confirm a
42
+ // guessed text, or a guessed set of private group names, offline. What crosses
43
+ // the boundary is keyed with this install's own secret (settings.mcp_secret:
44
+ // local, never synced), so the helper (from the files) and the app (from
45
+ // memory) compute the same values and nobody without the secret can:
46
+ // rev - publicRev: an HMAC of the real rev. It still moves with any change to
47
+ // the text, title, pin, groups or numpad slot, so the app refuses a stale
48
+ // write, but it says nothing about what it covers.
49
+ // id - publicId: a clip the AI can read keeps its real id; any other clip gets
50
+ // an opaque handle (OPAQUE_ID_PREFIX + an HMAC of its id) that the app
51
+ // resolves back. A real id names only a clip the AI can read, so a
52
+ // guessed content hash finds nothing.
53
+ const OPAQUE_ID_PREFIX = 'private:';
54
+ // No secret yet (the app never enabled AI access): ids and revs are still keyed,
55
+ // stable for this process. Nothing can forward to the app then anyway.
56
+ const PROCESS_KEY = crypto.randomBytes(32).toString('hex');
57
+
58
+ function boundaryKey(settings) {
59
+ const secret = settings && settings.mcp_secret;
60
+ return typeof secret === 'string' && secret ? secret : PROCESS_KEY;
61
+ }
62
+
63
+ // The settings with the boundary key filled in, for a copy that crosses to
64
+ // another thread (a search worker would otherwise make up its own key).
65
+ function withBoundaryKey(settings) {
66
+ const s = settings || {};
67
+ return typeof s.mcp_secret === 'string' && s.mcp_secret ? s : { ...s, mcp_secret: boundaryKey(s) };
68
+ }
69
+
70
+ function keyedHash(settings, label, value) {
71
+ return crypto.createHmac('sha256', boundaryKey(settings)).update(`${label}\0${value}`).digest('hex');
72
+ }
73
+
74
+ function publicRev(item, settings) {
75
+ return item ? keyedHash(settings, 'rev', model.clipRevision(item)).slice(0, 16) : null;
76
+ }
77
+
78
+ // One HMAC per clip per key (a lookup by handle walks the whole history).
79
+ let opaqueCache = { key: null, map: new Map() };
80
+ function opaqueId(realId, settings) {
81
+ const key = boundaryKey(settings);
82
+ if (opaqueCache.key !== key || opaqueCache.map.size > 100000) opaqueCache = { key, map: new Map() };
83
+ const id = String(realId);
84
+ let value = opaqueCache.map.get(id);
85
+ if (!value) {
86
+ value = OPAQUE_ID_PREFIX + keyedHash(settings, 'id', id).slice(0, 32);
87
+ opaqueCache.map.set(id, value);
88
+ }
89
+ return value;
90
+ }
91
+
92
+ function isOpaqueId(id) {
93
+ return typeof id === 'string' && id.startsWith(OPAQUE_ID_PREFIX);
94
+ }
95
+
96
+ function publicId(item, settings, sharedSet = sharedGroupSet(settings)) {
97
+ const id = model.itemKey(item);
98
+ return isShared(item, sharedSet) ? id : opaqueId(id, settings);
99
+ }
100
+
101
+ // The clip an AI's id names: { item, realId, movedTo }.
102
+ // - An opaque handle names the clip it was handed out for, also after that clip
103
+ // became shared; one the clip was edited away from gives item null and, in
104
+ // movedTo, the public id of the clip it became.
105
+ // - A real id names only a clip the AI can read: a non-shared clip's content
106
+ // hash answers exactly like an id that does not exist. One edited away gives
107
+ // movedTo only when the clip it became is shared.
108
+ function resolvePublicId(history, settings, id) {
109
+ const items = Array.isArray(history) ? history : [];
110
+ const value = String(id == null ? '' : id).trim();
111
+ const none = { item: null, realId: null, movedTo: null };
112
+ if (!value) return none;
113
+ const sharedSet = sharedGroupSet(settings);
114
+ const moved = (realId, opaque) => {
115
+ const currentId = model.supersedeMap(settings && settings.supersedes).get(realId);
116
+ const current = currentId && currentId !== realId ? items.find(i => model.itemKey(i) === currentId) : null;
117
+ if (!current || (!opaque && !isShared(current, sharedSet))) return none;
118
+ return { item: null, realId, movedTo: publicId(current, settings, sharedSet) };
119
+ };
120
+ if (isOpaqueId(value)) {
121
+ for (const item of items) {
122
+ const realId = model.itemKey(item);
123
+ if (opaqueId(realId, settings) === value) return { item, realId, movedTo: null };
124
+ }
125
+ for (const record of Array.isArray(settings && settings.supersedes) ? settings.supersedes : []) {
126
+ if (record && record.from && opaqueId(String(record.from), settings) === value) return moved(String(record.from), true);
127
+ }
128
+ return none;
129
+ }
130
+ const item = items.find(i => model.itemKey(i) === value) || null;
131
+ if (item) return isShared(item, sharedSet) ? { item, realId: value, movedTo: null } : none;
132
+ return moved(value, false);
33
133
  }
34
134
 
35
135
  // A clip carries a format when the value is inline OR externalized to a blob.
@@ -54,30 +154,58 @@ function previewText(item, chars) {
54
154
  return `${raw.slice(0, chars)}…`;
55
155
  }
56
156
 
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(' ');
157
+ // The group names a clip shows an AI: only the SHARED ones (never a private group
158
+ // a shared clip is also in), all of them after an approved all-scope search.
159
+ function visibleGroups(item, sharedSet, forceAll = false) {
160
+ const groups = model.groupsOf(item);
161
+ return forceAll ? groups : groups.filter(g => sharedSet.has(g));
162
+ }
163
+
164
+ // The groups an AI tool names in one go (a group param): the app's own reading.
165
+ function namedTags(input, settings) {
166
+ return model.tagInputNames(String(input == null ? '' : input), settings || {});
167
+ }
168
+
169
+ // ---- The AI's group tools ----
170
+ // What the AI typed, one entry per name it typed, with the stored groups each
171
+ // one means (a retired nested name means its parts; an existing group, in any
172
+ // case, its stored spelling). An existing group whose name holds a comma is
173
+ // named in full, as model.tagInputNames reads it.
174
+ function namedGroupTokens(input, settings) {
175
+ const text = String(input == null ? '' : input);
176
+ const groups = Array.isArray(settings && settings.groups) ? settings.groups : [];
177
+ const typed = groups.some(g => search.sameTag(g, text)) ? [search.cleanTagName(text)] : search.splitTagInput(text);
178
+ return typed.map(token => ({ typed: token, names: namedTags(token, settings) }));
179
+ }
180
+
181
+ // What a group tool may say back about the groups it was given: the stored names
182
+ // of those shared with AI (it sees them on clips and in list_context anyway) and,
183
+ // in notShared, every other name only as it was typed. Nothing in it says whether
184
+ // such a group existed, or was on the clip. `lists` maps a result field to the
185
+ // stored names it holds (e.g. { added, removed }).
186
+ function groupToolView(tokens, settings, lists = {}) {
187
+ const sharedSet = sharedGroupSet(settings);
188
+ const view = {};
189
+ for (const [field, names] of Object.entries(lists)) view[field] = (Array.isArray(names) ? names : []).filter(n => sharedSet.has(n));
190
+ const notShared = (Array.isArray(tokens) ? tokens : [])
191
+ .filter(t => !t.names.length || t.names.some(n => !sharedSet.has(n)))
192
+ .map(t => t.typed);
193
+ if (notShared.length) view.notShared = notShared;
194
+ return view;
66
195
  }
67
196
 
68
197
  // Shape a single clip for AI consumption. `forcePreview` is set only on the
69
198
  // post-approval ('all') path, where the user has explicitly approved reading
70
199
  // 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);
200
+ function clipView(item, { settings = {}, sharedSet = sharedGroupSet(settings), previewChars = DEFAULT_PREVIEW_CHARS, forcePreview = false } = {}) {
73
201
  const shared = isShared(item, sharedSet);
74
202
  const base = {
75
- id,
203
+ id: publicId(item, settings, sharedSet),
76
204
  type: item.type === 'image' ? 'image' : 'text',
77
205
  ts: item.ts || 0,
78
206
  pinned: model.isPinned(item),
79
207
  numpad: model.numpadSlotOf(item),
80
- rev: model.clipRevision(item),
208
+ rev: publicRev(item, settings),
81
209
  shared,
82
210
  };
83
211
  if (!shared && !forcePreview) {
@@ -87,7 +215,7 @@ function clipView(item, { sharedSet, previewChars = DEFAULT_PREVIEW_CHARS, force
87
215
  if (!shared && forcePreview) base.viaApproval = true;
88
216
  // Only expose the SHARED group names a clip belongs to - never the names of
89
217
  // any private/non-shared groups it is also a member of.
90
- base.groups = model.groupsOf(item).filter(g => sharedSet.has(g));
218
+ base.groups = visibleGroups(item, sharedSet);
91
219
  if (item.type === 'image') {
92
220
  base.image = item.image || null;
93
221
  if (item.width) base.width = item.width;
@@ -107,9 +235,15 @@ function clipView(item, { sharedSet, previewChars = DEFAULT_PREVIEW_CHARS, force
107
235
  // The full-text read result shape, shared by the helper's get_clip fast-path and
108
236
  // the app's approval-gated read_clip so both stay identical. Group names are
109
237
  // 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 };
238
+ function fullTextResult(item, { settings = {}, sharedSet = sharedGroupSet(settings) } = {}) {
239
+ const result = {
240
+ id: publicId(item, settings, sharedSet),
241
+ rev: publicRev(item, settings),
242
+ type: 'text',
243
+ text: String(item.text || ''),
244
+ groups: visibleGroups(item, sharedSet),
245
+ ts: item.ts || 0,
246
+ };
113
247
  const title = model.titleOf(item);
114
248
  if (title) result.title = title;
115
249
  return result;
@@ -128,24 +262,23 @@ function buildContext(history, settings) {
128
262
  for (const item of items) {
129
263
  if (model.isPinned(item)) pinnedCount++;
130
264
  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);
265
+ if (slot != null) numpad[slot] = { id: publicId(item, settings, sharedSet), type: item.type === 'image' ? 'image' : 'text' };
266
+ for (const g of model.groupsOf(item)) counts.set(search.tagKey(g), (counts.get(search.tagKey(g)) || 0) + 1);
133
267
  if (isShared(item, sharedSet)) sharedCount++;
134
268
  }
135
269
 
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.
270
+ // Only the SHARED groups, and no count of the others: the group tools are free for
271
+ // any name, so a count that moved (or did not) after create_group would tell the AI
272
+ // whether a private group of that name existed.
138
273
  const groups = groupNames
139
274
  .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;
275
+ .map(name => ({ name, shared: true, count: counts.get(search.tagKey(name)) || 0 }));
142
276
 
143
277
  return {
144
278
  totalClips: items.length,
145
279
  sharedClips: sharedCount,
146
280
  pinnedClips: pinnedCount,
147
281
  groups,
148
- privateGroupCount,
149
282
  numpadSlots: numpad,
150
283
  aiGroup: AI_GROUP_NAME,
151
284
  };
@@ -153,6 +286,8 @@ function buildContext(history, settings) {
153
286
 
154
287
  // List clips. By default returns shared clips with previews (most recent first);
155
288
  // pass includeNonShared to also append metadata-only entries for the rest.
289
+ // `group` keeps the clips in EVERY group it names ("todo, forge"), matched only
290
+ // against the groups shared with AI, so naming a private group finds nothing.
156
291
  function listClips(history, settings, {
157
292
  limit = DEFAULT_LIST_LIMIT,
158
293
  previewChars = DEFAULT_PREVIEW_CHARS,
@@ -161,6 +296,7 @@ function listClips(history, settings, {
161
296
  } = {}) {
162
297
  const items = Array.isArray(history) ? history : [];
163
298
  const sharedSet = sharedGroupSet(settings);
299
+ const wanted = group ? namedTags(group, settings) : [];
164
300
  const out = [];
165
301
  let nonSharedTotal = 0;
166
302
 
@@ -170,27 +306,28 @@ function listClips(history, settings, {
170
306
  nonSharedTotal++;
171
307
  if (!includeNonShared) continue;
172
308
  }
173
- if (group && !model.groupsOf(item).includes(group)) continue;
174
- out.push(clipView(item, { sharedSet, previewChars }));
309
+ if (wanted.length) {
310
+ const tags = visibleGroups(item, sharedSet);
311
+ if (!wanted.every(name => tags.some(g => search.sameTag(g, name)))) continue;
312
+ }
313
+ out.push(clipView(item, { settings, sharedSet, previewChars }));
175
314
  if (out.length >= limit) break;
176
315
  }
177
316
  return { clips: out, nonSharedTotal, returned: out.length };
178
317
  }
179
318
 
180
319
  // 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);
320
+ // with the clip's public id and its SHARED group names (all of them after an approved
321
+ // all-scope search, the only time a non-shared clip is searched at all).
322
+ function clipToSearchDoc(item, { settings = {}, sharedSet = sharedGroupSet(settings), forceAll = false } = {}) {
186
323
  const isImage = item && item.type === 'image';
187
324
  const body = isImage ? String(item && item.image || '') : String(item && (item.text != null ? item.text : item.textPreview) || '');
188
325
  return {
189
- id: model.itemKey(item),
326
+ id: publicId(item, settings, sharedSet),
190
327
  type: isImage ? 'image' : 'text',
191
- title: visible ? model.titleOf(item) : '',
328
+ title: model.titleOf(item),
192
329
  body,
193
- groups: visible ? model.groupsOf(item) : [],
330
+ groups: visibleGroups(item, sharedSet, forceAll),
194
331
  numpad: model.numpadSlotOf(item),
195
332
  pinned: model.isPinned(item),
196
333
  ts: Number(item && item.ts) || 0,
@@ -202,13 +339,35 @@ function clipToSearchDoc(item, { sharedSet, forceAll = false } = {}) {
202
339
  };
203
340
  }
204
341
 
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).
342
+ // The retired nested names an AI's query is rewritten through: only those whose
343
+ // parts are all shared with AI. Any other reads as an unknown name, exactly as a
344
+ // guess at a private one must (whether a name was a private group's is not the
345
+ // AI's to learn).
346
+ function visibleSplitMap(settings, sharedSet = sharedGroupSet(settings)) {
347
+ const out = new Map();
348
+ for (const [key, parts] of model.splitMapOf(settings || {})) {
349
+ if (parts.length && parts.every(part => sharedSet.has(part))) out.set(key, parts);
350
+ }
351
+ return out;
352
+ }
353
+
354
+ function notSearchedHint(count) {
355
+ return `${count} ${count === 1 ? 'clip is' : 'clips are'} not shared with AI and ${count === 1 ? 'was' : 'were'} not searched. To search them too, call search_clips again with include_unshared: true (the user is asked first).`;
356
+ }
357
+
358
+ // Search. Delegates parsing + matching to the SHARED engine (site/shared/clip-search)
359
+ // so the MCP tool understands the SAME language as the app's search field:
360
+ // `title:`, `group:`, `is:pinned`, `since:7d`, `-word`, `/regex/`, `a OR b`, `( )`.
361
+ // Plain words are literal. The old `regex: true` flag still works: the query's free
362
+ // text is read as ONE regex (search.legacyRegexQuery). A retired nested name of
363
+ // shared groups (group:todo/forge) is searched as its parts (group:todo group:forge)
364
+ // and the result carries that `rewrittenQuery`, so the caller learns the flat form.
365
+ //
366
+ // The default scope searches ONLY the clips shared with AI: a non-shared clip never
367
+ // influences a free answer (a match count over private text would read it out, one
368
+ // /regex/ at a time). The result says instead how many clips were not searched
369
+ // (nonSharedTotal, the same for every query) with a hint to pass include_unshared,
370
+ // which the app runs as scope 'all' after the user approves.
212
371
  function searchClips(history, settings, {
213
372
  query,
214
373
  regex = false,
@@ -219,47 +378,59 @@ function searchClips(history, settings, {
219
378
  } = {}) {
220
379
  const items = Array.isArray(history) ? history : [];
221
380
  const sharedSet = sharedGroupSet(settings);
222
- const parsed = search.parseQuery(regex ? search.legacyRegexQuery(query) : query);
381
+ const all = scope === 'all';
382
+ const flat = search.flattenRetiredGroupTokens(query, visibleSplitMap(settings, sharedSet));
383
+ const parsed = search.parseQuery(regex ? search.legacyRegexQuery(flat.query) : flat.query);
223
384
  const matches = [];
224
- let nonSharedMatches = 0;
385
+ let nonSharedTotal = 0;
225
386
 
226
387
  for (const item of items) {
227
388
  const shared = isShared(item, sharedSet);
228
- const doc = clipToSearchDoc(item, { sharedSet, forceAll: scope === 'all' });
389
+ if (!shared && !all) { nonSharedTotal++; continue; }
390
+ if (matches.length >= limit) continue;
391
+ const doc = clipToSearchDoc(item, { settings, sharedSet, forceAll: all });
229
392
  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
- }
393
+ // A non-shared match exists only in the approved all scope: shaped as a real
394
+ // preview so the assistant can read it.
395
+ matches.push(clipView(item, { settings, sharedSet, previewChars, forcePreview: !shared }));
396
+ }
397
+ const result = { matches, returned: matches.length };
398
+ if (!all) {
399
+ result.nonSharedTotal = nonSharedTotal;
400
+ if (nonSharedTotal) result.hint = notSearchedHint(nonSharedTotal);
240
401
  }
241
- return { matches, nonSharedMatches, returned: matches.length };
402
+ if (flat.rewritten) result.rewrittenQuery = flat.query;
403
+ return result;
242
404
  }
243
405
 
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'.
406
+ // Resolve a single clip id (a public id, see resolvePublicId) for a full-text read.
407
+ // Returns the decision; the caller hydrates the text only when allowed. `reason` is
408
+ // one of: 'ok' | 'not_found' | 'not_shared'. A not_found id the clip was edited
409
+ // away from carries `movedTo`.
247
410
  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 };
411
+ const found = resolvePublicId(history, settings, id);
412
+ if (!found.item) return { reason: 'not_found', item: null, shared: false, movedTo: found.movedTo };
413
+ const shared = isShared(found.item, sharedGroupSet(settings));
414
+ return { reason: shared ? 'ok' : 'not_shared', item: found.item, shared, movedTo: null };
255
415
  }
256
416
 
257
417
  module.exports = {
258
418
  AI_GROUP_NAME,
259
419
  DEFAULT_PREVIEW_CHARS,
260
420
  DEFAULT_LIST_LIMIT,
421
+ OPAQUE_ID_PREFIX,
261
422
  sharedGroupSet,
262
423
  isShared,
424
+ visibleGroups,
425
+ namedTags,
426
+ namedGroupTokens,
427
+ groupToolView,
428
+ withBoundaryKey,
429
+ publicRev,
430
+ publicId,
431
+ isOpaqueId,
432
+ resolvePublicId,
433
+ visibleSplitMap,
263
434
  previewText,
264
435
  clipView,
265
436
  fullTextResult,
package/lib/sync-delta.js CHANGED
@@ -27,6 +27,9 @@ const PROTOCOL = 2;
27
27
  const K_ITEM = 'i:';
28
28
  const K_TOMB = 't:';
29
29
  const K_GTOMB = 'g:';
30
+ // A tag delete's entry: an exact-spelling record (older builds) and an any-case record of the
31
+ // same name are two entries.
32
+ const gtombKey = (g) => K_GTOMB + (g.anyCase ? '' : '=') + g.name;
30
33
  const K_SUP = 's:';
31
34
  const K_CONF = 'c:';
32
35
  const K_SETTINGS = 'S';
@@ -96,7 +99,7 @@ function createChangeTracker({ revision = 0, entries = {}, now = Date.now } = {}
96
99
  touch(K_ITEM + clipboardModel.itemKey(item), itemFingerprint(item));
97
100
  }
98
101
  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));
102
+ for (const g of view.groupTombstones || []) if (g && g.name) touch(gtombKey(g), `${g.deletedAt || 0}|${g.restoredAt || 0}`);
100
103
  for (const s of view.supersedes || []) if (s && s.from && s.to) touch(K_SUP + s.from + '>' + s.to, String(s.updatedAt || 0));
101
104
  const records = view.conflicts && Array.isArray(view.conflicts.records) ? view.conflicts.records : [];
102
105
  for (const c of records) if (c && c.id) touch(K_CONF + c.id, conflictFingerprint(c));
@@ -118,7 +121,7 @@ function createChangeTracker({ revision = 0, entries = {}, now = Date.now } = {}
118
121
  if (item && newer(K_ITEM + clipboardModel.itemKey(item))) items.push(item);
119
122
  }
120
123
  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));
124
+ const groupTombstones = (view.groupTombstones || []).filter(g => g && g.name && newer(gtombKey(g)));
122
125
  const supersedes = (view.supersedes || []).filter(s => s && s.from && s.to && newer(K_SUP + s.from + '>' + s.to));
123
126
  const records = view.conflicts && Array.isArray(view.conflicts.records) ? view.conflicts.records : [];
124
127
  const conflicts = records.filter(c => c && c.id && newer(K_CONF + c.id));
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+ // What the main thread (lib/windows-hook.js) and the hook worker
3
+ // (lib/windows-hook-worker.js) must agree on, and the hook's whole claim
4
+ // decision, kept Win32-free so it is unit-tested without installing a hook
5
+ // (test/windows-hook.test.js):
6
+ // - STATE: the SharedArrayBuffer layout main writes and the worker reads;
7
+ // - createHookClaimer: which key presses the hook takes.
8
+
9
+ // --- Shared state layout (one byte each; main writes, the worker reads) ---
10
+ const STATE = Object.freeze({
11
+ POPUP_VISIBLE: 0, // the popup is open: plain numpad keys type into it
12
+ SLOT_FIRST: 1, // bytes 1-9: numpad slot N holds a clip
13
+ SLOT_LAST: 9,
14
+ NEW_NOTE_HOTKEY: 10, // Win+N is the new-note shortcut (not off, not a recorded chord, not recording)
15
+ SIZE: 16,
16
+ });
17
+
18
+ const VK = Object.freeze({
19
+ SHIFT: 0x10, CONTROL: 0x11, MENU: 0x12,
20
+ LWIN: 0x5B, RWIN: 0x5C,
21
+ N: 0x4E, V: 0x56,
22
+ NUMPAD1: 0x61, NUMPAD9: 0x69,
23
+ });
24
+ const LLKHF_ALTDOWN = 0x20;
25
+
26
+ // A claimed key fires only with EXACTLY its modifiers: never with Ctrl, Shift
27
+ // or Alt held, and Win as `win` says (true = held, false = up, null = either).
28
+ // A Win chord is Win alone (Win+Ctrl+N is Windows' Narrator settings, Win+Ctrl+V
29
+ // its sound output panel: those stay Windows'); a numpad slot is a plain digit
30
+ // or Win+digit (both documented), never Alt+digit (Alt codes) or Ctrl+digit.
31
+ // mods = { win, ctrl, shift, alt } as held at the keydown.
32
+ function exactModifiers(mods, { win = false } = {}) {
33
+ if (mods.ctrl || mods.shift || mods.alt) return false;
34
+ return win == null || !!mods.win === win;
35
+ }
36
+
37
+ // One verdict per press: a key's auto-repeats and its keyup follow its first
38
+ // keydown's verdict, so a claimed press fires once and swallows its keyup, and
39
+ // an unclaimed one passes untouched to its end (letting go of Ctrl while
40
+ // Win+Ctrl+N is held never turns the repeats into a Win+N).
41
+ // claim() decides a fresh press (only then: repeats read nothing);
42
+ // wasDown() says the key was already down before this keydown. Asked only
43
+ // for an UNCLAIMED held key, whose keydown reached the system: up
44
+ // means its keyup never reached us (released on a desktop the hook
45
+ // cannot see), so this is a fresh press. A claimed key's keydowns
46
+ // were swallowed, so its state says nothing.
47
+ function createPressTracker() {
48
+ const held = new Map(); // vk -> claimed, for keys down since their first keydown
49
+ return {
50
+ event(vk, down, claim, wasDown) {
51
+ if (!down) {
52
+ const claimed = held.get(vk) === true;
53
+ held.delete(vk);
54
+ return { swallow: claimed, fire: false };
55
+ }
56
+ if (held.has(vk)) {
57
+ const claimed = held.get(vk);
58
+ if (claimed || !wasDown || wasDown()) return { swallow: claimed, fire: false };
59
+ }
60
+ const claimed = !!claim();
61
+ held.set(vk, claimed);
62
+ return { swallow: claimed, fire: claimed };
63
+ },
64
+ };
65
+ }
66
+
67
+ // The hook's claim decision for one key event. `state` is the shared-state
68
+ // byte array; io.readMods(flags) and io.isKeyDown(vk) are the worker's Win32
69
+ // reads (GetAsyncKeyState), called only when a decision needs them, so a key
70
+ // the hook never takes costs a Map lookup.
71
+ // Win+V -> post { type: 'show' }, Clipboard History never opens;
72
+ // Win+N -> post { type: 'new-note' } while STATE.NEW_NOTE_HOTKEY;
73
+ // Numpad1-9 -> post { type: 'numpad', slot } with the popup closed and the
74
+ // slot assigned (open, the digit types into the search).
75
+ // The Win key itself always passes. A press swallowed while Win was held
76
+ // leaves Windows a bare Win tap, so the Win release after one says
77
+ // markWinUsed and the worker taps a key that keeps the Start menu shut.
78
+ // Returns { swallow, post, markWinUsed }.
79
+ const PASS = Object.freeze({ swallow: false, post: null, markWinUsed: false });
80
+ function createHookClaimer(state, io) {
81
+ // Win+<key> chords taken before Windows sees them: Win+V (Clipboard
82
+ // History's) shows the popup; Win+N (the notification panel's) opens a new
83
+ // note while main says it is the new-note shortcut. win = the Win rule for
84
+ // exactModifiers; ready() = main wants the key now.
85
+ const KEYS = new Map([
86
+ [VK.V, { type: 'show', win: true, ready: () => true }],
87
+ [VK.N, { type: 'new-note', win: true, ready: () => state[STATE.NEW_NOTE_HOTKEY] === 1 }],
88
+ ]);
89
+ for (let slot = 1; slot <= 9; slot++) {
90
+ KEYS.set(VK.NUMPAD1 + slot - 1, { type: 'numpad', slot, win: null,
91
+ ready: () => state[STATE.POPUP_VISIBLE] !== 1 && state[STATE.SLOT_FIRST + slot - 1] === 1 });
92
+ }
93
+ const tracker = createPressTracker();
94
+ let winUsed = false;
95
+ return {
96
+ event(vk, down, flags = 0) {
97
+ if (vk === VK.LWIN || vk === VK.RWIN) {
98
+ if (down || !winUsed) return PASS;
99
+ winUsed = false;
100
+ return { swallow: false, post: null, markWinUsed: true };
101
+ }
102
+ const key = KEYS.get(vk);
103
+ if (!key) return PASS;
104
+ let mods = null;
105
+ const r = tracker.event(vk, down,
106
+ () => key.ready() && exactModifiers(mods = io.readMods(flags), { win: key.win }),
107
+ () => io.isKeyDown(vk));
108
+ if (!r.swallow) return PASS;
109
+ if (r.fire && mods.win) winUsed = true;
110
+ const post = !r.fire ? null : key.slot ? { type: key.type, slot: key.slot } : { type: key.type };
111
+ return { swallow: true, post, markWinUsed: false };
112
+ },
113
+ };
114
+ }
115
+
116
+ // The worker's io.readMods: GetAsyncKeyState-style isKeyDown(vk) + the hook
117
+ // event's flags (LLKHF_ALTDOWN covers an Alt the async state has not caught).
118
+ function modsFrom(isKeyDown, flags) {
119
+ return {
120
+ win: isKeyDown(VK.LWIN) || isKeyDown(VK.RWIN),
121
+ ctrl: isKeyDown(VK.CONTROL),
122
+ shift: isKeyDown(VK.SHIFT),
123
+ alt: (flags & LLKHF_ALTDOWN) !== 0 || isKeyDown(VK.MENU),
124
+ };
125
+ }
126
+
127
+ module.exports = { STATE, VK, LLKHF_ALTDOWN, exactModifiers, createPressTracker, createHookClaimer, modsFrom };