@tobq/boardclip 2.1.113 → 2.1.115
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/README.md +5 -2
- package/editor-preload.js +2 -0
- package/editor.html +12 -0
- package/index.html +115 -58
- package/lib/clipboard-model.js +588 -23
- package/lib/mcp-core.js +240 -69
- package/lib/sync-delta.js +5 -2
- package/lib/windows-hook-policy.js +245 -0
- package/lib/windows-hook-worker.js +18 -77
- package/lib/windows-hook.js +32 -9
- package/main.js +865 -202
- package/mcp/boardclip-mcp.js +68 -39
- package/mcp-approval.html +4 -2
- package/package.json +1 -1
- package/preload.js +11 -2
- package/site/index.html +108 -37
- package/site/shared/clip-search.js +291 -134
- package/site/shared/clipboard-popup.css +8 -20
- package/site/shared/clipboard-ui-core.js +405 -329
- package/viewer.html +1 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
32
|
-
return
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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:
|
|
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 =
|
|
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
|
|
112
|
-
|
|
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:
|
|
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
|
|
137
|
-
//
|
|
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 (
|
|
174
|
-
|
|
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
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
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:
|
|
326
|
+
id: publicId(item, settings, sharedSet),
|
|
190
327
|
type: isImage ? 'image' : 'text',
|
|
191
|
-
title:
|
|
328
|
+
title: model.titleOf(item),
|
|
192
329
|
body,
|
|
193
|
-
groups:
|
|
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
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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
|
|
385
|
+
let nonSharedTotal = 0;
|
|
225
386
|
|
|
226
387
|
for (const item of items) {
|
|
227
388
|
const shared = isShared(item, sharedSet);
|
|
228
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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.
|
|
245
|
-
// caller hydrates the text only when allowed. `reason` is
|
|
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
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
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(
|
|
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(
|
|
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));
|