@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/README.md +5 -2
- package/editor-preload.js +2 -0
- package/editor.html +12 -0
- package/index.html +94 -48
- 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 +127 -0
- package/lib/windows-hook-worker.js +18 -77
- package/lib/windows-hook.js +20 -9
- package/main.js +812 -188
- package/mcp/boardclip-mcp.js +68 -39
- package/mcp-approval.html +4 -2
- package/package.json +1 -1
- package/preload.js +4 -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/mcp/boardclip-mcp.js
CHANGED
|
@@ -45,7 +45,7 @@ function readSettings() {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
function
|
|
48
|
+
function readHistoryFile() {
|
|
49
49
|
try {
|
|
50
50
|
const loaded = JSON.parse(fs.readFileSync(HISTORY_PATH, 'utf8'));
|
|
51
51
|
const items = Array.isArray(loaded) ? loaded : [];
|
|
@@ -56,6 +56,20 @@ function readHistory() {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
// Both files through the app's flat-tags plan (model.planTagState), together or not at
|
|
60
|
+
// all: tags split for the clips but not for the share list (or the reverse) could show
|
|
61
|
+
// an AI a clip the app would not. The settings always carry the boundary key
|
|
62
|
+
// (mcpCore.withBoundaryKey) so a search worker keys ids and revs as this thread does.
|
|
63
|
+
function readState() {
|
|
64
|
+
const settings = mcpCore.withBoundaryKey(readSettings());
|
|
65
|
+
const history = readHistoryFile();
|
|
66
|
+
try {
|
|
67
|
+
const plan = model.planTagState(history, settings, { aiGroupName: mcpCore.AI_GROUP_NAME });
|
|
68
|
+
if (plan.changed) return { settings: { ...settings, ...plan.settings }, history: plan.history };
|
|
69
|
+
} catch {}
|
|
70
|
+
return { settings, history };
|
|
71
|
+
}
|
|
72
|
+
|
|
59
73
|
// --- Result helpers ----------------------------------------------------------
|
|
60
74
|
function jsonResult(value) {
|
|
61
75
|
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
|
|
@@ -66,8 +80,16 @@ function errorResult(message) {
|
|
|
66
80
|
}
|
|
67
81
|
|
|
68
82
|
// Every clip mutation carries the rev it read; the app refuses a stale one.
|
|
69
|
-
const REV_NOTE = ' Requires expected_rev: the clip\'s `rev` from list_clips/search_clips/get_clip (or from your previous change\'s result). If the clip changed since you read it the change is refused - re-read it and retry.
|
|
83
|
+
const REV_NOTE = ' Requires expected_rev: the clip\'s `rev` from list_clips/search_clips/get_clip (or from your previous change\'s result). If the clip changed since you read it the change is refused - re-read it and retry.';
|
|
84
|
+
// What a clip change returns, for the next change.
|
|
85
|
+
const RETURNS_NOTE = ' Returns the clip\'s id and new rev.';
|
|
70
86
|
const expectedRevSchema = () => z.string().min(1).describe('The clip\'s `rev` as you last read it; the change is refused if the clip changed since.');
|
|
87
|
+
// Every id param: a clip you can read goes by its real id, any other by the opaque one you were given.
|
|
88
|
+
const idSchema = () => z.string().min(1).describe('The clip\'s `id` as a BoardClip tool returned it (a clip not shared with you has an opaque id starting "private:").');
|
|
89
|
+
// Every group param (named group/name, like the tools) reads groups the same way.
|
|
90
|
+
const GROUPS_PARAM = 'One group, or several separated by commas (\'todo, forge\'). Groups are flat and case-insensitive; an old nested name like todo/forge means its parts, todo and forge.';
|
|
91
|
+
// What every group tool says back about a group not shared with you.
|
|
92
|
+
const NOT_SHARED_NOTE = ' A group you name that is not shared with you is listed in notShared exactly as you typed it, and what happened with it is not reported.';
|
|
71
93
|
|
|
72
94
|
function revisionConflictMessage(err) {
|
|
73
95
|
if (!err || !/revision_conflict:/.test(String(err.message || ''))) return null;
|
|
@@ -109,19 +131,20 @@ const server = new McpServer({ name: 'boardclip', version: '1.0.0' });
|
|
|
109
131
|
|
|
110
132
|
// ---- Read tools (served locally from the data files) ----
|
|
111
133
|
server.registerTool('list_context', {
|
|
112
|
-
description: 'Summary of the user\'s clipboard organisation: groups
|
|
134
|
+
description: 'Summary of the user\'s clipboard organisation: the groups shared with AI with their clip counts (groups not shared with you are not listed), pinned and numpad-slot clips (a clip not shared with you by its opaque id), and totals. The starting point for understanding what is available.',
|
|
113
135
|
inputSchema: {},
|
|
114
|
-
}, async () => jsonResult(mcpCore.buildContext(
|
|
136
|
+
}, async () => { const st = readState(); return jsonResult(mcpCore.buildContext(st.history, st.settings)); });
|
|
115
137
|
|
|
116
138
|
server.registerTool('list_clips', {
|
|
117
|
-
description: 'List clipboard clips the user shared with AI (most recent first, with text previews). Non-shared clips are excluded unless include_unshared_metadata is set, in which case they appear as metadata only (no text).',
|
|
139
|
+
description: 'List clipboard clips the user shared with AI (most recent first, with text previews). Non-shared clips are excluded unless include_unshared_metadata is set, in which case they appear as metadata only (no text), each under an opaque id starting "private:" that every tool taking an id accepts.',
|
|
118
140
|
inputSchema: {
|
|
119
141
|
limit: z.number().int().min(1).max(200).optional(),
|
|
120
|
-
group: z.string().optional().describe(
|
|
142
|
+
group: z.string().optional().describe(`Only clips in all of these groups. ${GROUPS_PARAM}`),
|
|
121
143
|
include_unshared_metadata: z.boolean().optional(),
|
|
122
144
|
},
|
|
123
145
|
}, async ({ limit, group, include_unshared_metadata }) => {
|
|
124
|
-
const
|
|
146
|
+
const st = readState();
|
|
147
|
+
const res = mcpCore.listClips(st.history, st.settings, {
|
|
125
148
|
limit: limit || mcpCore.DEFAULT_LIST_LIMIT,
|
|
126
149
|
group: group || null,
|
|
127
150
|
includeNonShared: !!include_unshared_metadata,
|
|
@@ -130,7 +153,7 @@ server.registerTool('list_clips', {
|
|
|
130
153
|
});
|
|
131
154
|
|
|
132
155
|
server.registerTool('search_clips', {
|
|
133
|
-
description: 'Search shared clips with the BoardClip search language (the same text the app\'s search field takes). Plain words are literal, case-insensitive substrings, and every term must match (AND); "a quoted phrase" matches exactly; /pattern/ is a regular expression term (case-insensitive, . never crosses a line break; title:/re/ and text:/re/ too); a OR b matches either (OR binds tighter than AND, so x a OR b = x AND (a OR b)); ( ) groups at any depth; -term or -( ... ) excludes. Field scopes title:/text:/group: (short: t:/b:/g:),
|
|
156
|
+
description: 'Search shared clips with the BoardClip search language (the same text the app\'s search field takes). Plain words are literal, case-insensitive substrings, and every term must match (AND); "a quoted phrase" matches exactly; /pattern/ is a regular expression term (case-insensitive, . never crosses a line break; title:/re/ and text:/re/ too); a OR b matches either (OR binds tighter than AND, so x a OR b = x AND (a OR b)); ( ) groups at any depth; -term or -( ... ) excludes. Field scopes title:/text:/group: (short: t:/b:/g:); group:a group:b = clips in both groups (any case), and a retired nested name of shared groups (group:todo/forge) is searched as its parts, shown in the result\'s rewrittenQuery. Facets is:pinned|is:image|is:text|is:numpad|is:url|is:multiline|is:rich, num:1-9 (n:), since:/before: e.g. since:7d (s:/bf:), len:>100 or len:50-200 (l:), lines:>3 (ln:), words:<20 (wd:), sort:new|best (o:), id:. Every prefix has a short + long alias. Example: group:Work OR group:Ideas /inv(oice)?/ -draft. Returns matching shared clips with previews. Clips not shared with AI are not searched at all: nonSharedTotal says how many there are (the same number whatever the query, so a miss says nothing about them) and a hint says how to include them. Set include_unshared to search everything - that requires the app and pops an approval prompt.',
|
|
134
157
|
inputSchema: {
|
|
135
158
|
query: z.string().min(1),
|
|
136
159
|
regex: z.boolean().optional().describe('Legacy: read the query\'s free text as ONE regular expression. Prefer /pattern/ terms in the query.'),
|
|
@@ -142,7 +165,8 @@ server.registerTool('search_clips', {
|
|
|
142
165
|
return runForward('search_all', { query, regex: !!regex, limit: limit || mcpCore.DEFAULT_LIST_LIMIT });
|
|
143
166
|
}
|
|
144
167
|
try {
|
|
145
|
-
const
|
|
168
|
+
const st = readState();
|
|
169
|
+
const res = await searchClipsOffThread(st.history, st.settings, {
|
|
146
170
|
query, regex: !!regex, limit: limit || mcpCore.DEFAULT_LIST_LIMIT,
|
|
147
171
|
}, { signal: extra && extra.signal });
|
|
148
172
|
return jsonResult(res);
|
|
@@ -151,47 +175,52 @@ server.registerTool('search_clips', {
|
|
|
151
175
|
}
|
|
152
176
|
});
|
|
153
177
|
|
|
178
|
+
// A missing id: say where an edited clip went, as the app's conflicts do.
|
|
179
|
+
function notFoundResult(id, resolved) {
|
|
180
|
+
if (resolved && resolved.movedTo) return errorResult(`Clip ${id} was edited and is now clip ${resolved.movedTo}. Read that one instead.`);
|
|
181
|
+
return errorResult(`No clip with id "${id}".`);
|
|
182
|
+
}
|
|
183
|
+
|
|
154
184
|
server.registerTool('get_clip', {
|
|
155
|
-
description: 'Get the full text of a clip by id. Shared clips return immediately. A non-shared clip requires the app and pops an approval prompt before the text is returned.',
|
|
156
|
-
inputSchema: { id:
|
|
185
|
+
description: 'Get the full text of a clip by id. Shared clips return immediately. A non-shared clip (an opaque "private:" id) requires the app and pops an approval prompt before the text is returned.',
|
|
186
|
+
inputSchema: { id: idSchema() },
|
|
157
187
|
}, async ({ id }) => {
|
|
158
|
-
const settings =
|
|
159
|
-
const resolved = mcpCore.resolveForRead(
|
|
160
|
-
if (resolved.reason === 'not_found') return
|
|
188
|
+
const { settings, history } = readState();
|
|
189
|
+
const resolved = mcpCore.resolveForRead(history, settings, id);
|
|
190
|
+
if (resolved.reason === 'not_found') return notFoundResult(id, resolved);
|
|
161
191
|
if (resolved.reason === 'ok') {
|
|
162
192
|
const item = resolved.item;
|
|
163
|
-
|
|
164
|
-
if (item.type === 'image') return jsonResult(mcpCore.clipView(item, { sharedSet }));
|
|
193
|
+
if (item.type === 'image') return jsonResult(mcpCore.clipView(item, { settings }));
|
|
165
194
|
textBlobStore.hydrateTextItem(item, TEXT_DIR);
|
|
166
|
-
return jsonResult(mcpCore.fullTextResult(item,
|
|
195
|
+
return jsonResult(mcpCore.fullTextResult(item, { settings }));
|
|
167
196
|
}
|
|
168
197
|
// not_shared -> approval-gated read through the app.
|
|
169
198
|
return runForward('read_clip', { id, reason: resolved.reason });
|
|
170
199
|
});
|
|
171
200
|
|
|
172
201
|
server.registerTool('get_image', {
|
|
173
|
-
description: 'Get metadata for an image clip (type, dimensions,
|
|
174
|
-
inputSchema: { id:
|
|
202
|
+
description: 'Get metadata for an image clip (type, dimensions, groups, timestamp). Set include_path to also get the local file path, which requires the app and pops an approval prompt.',
|
|
203
|
+
inputSchema: { id: idSchema(), include_path: z.boolean().optional() },
|
|
175
204
|
}, async ({ id, include_path }) => {
|
|
176
205
|
if (include_path) return runForward('image_path', { id });
|
|
177
|
-
const settings =
|
|
178
|
-
const resolved = mcpCore.resolveForRead(
|
|
179
|
-
if (resolved.reason === 'not_found') return
|
|
206
|
+
const { settings, history } = readState();
|
|
207
|
+
const resolved = mcpCore.resolveForRead(history, settings, id);
|
|
208
|
+
if (resolved.reason === 'not_found') return notFoundResult(id, resolved);
|
|
180
209
|
if (resolved.item.type !== 'image') return errorResult(`Clip "${id}" is not an image.`);
|
|
181
210
|
// clipView returns metadata-only for a non-shared image; full meta for a shared one.
|
|
182
|
-
return jsonResult(mcpCore.clipView(resolved.item, {
|
|
211
|
+
return jsonResult(mcpCore.clipView(resolved.item, { settings }));
|
|
183
212
|
});
|
|
184
213
|
|
|
185
214
|
// ---- Management tools (forwarded to the app; gated there) ----
|
|
186
215
|
server.registerTool('add_clip', {
|
|
187
|
-
description: 'Add a
|
|
188
|
-
inputSchema: { text: z.string().min(1), group: z.string().optional() },
|
|
216
|
+
description: 'Add a text clip to history, optionally in groups. If the text is already a clip, that clip moves to the top and goes into the groups instead. The user is asked first when that clip is one you cannot read, or when a group you name exists but is not shared with you.',
|
|
217
|
+
inputSchema: { text: z.string().min(1), group: z.string().optional().describe(`Groups for the clip. ${GROUPS_PARAM}`) },
|
|
189
218
|
}, async ({ text, group }) => runForward('add_clip', { text, group: group || null }));
|
|
190
219
|
|
|
191
220
|
server.registerTool('edit_clip', {
|
|
192
|
-
description: 'Replace (or append to) the text of an existing TEXT clip in place, keeping its pin, groups, and numpad slot. Text clips are content-addressed, so editing changes the clip id - the new id is returned in the result. Images cannot be edited. Pops an approval prompt.' + REV_NOTE,
|
|
221
|
+
description: 'Replace (or append to) the text of an existing TEXT clip in place, keeping its pin, groups, and numpad slot. Text clips are content-addressed, so editing changes the clip id - the new id is returned in the result. Images cannot be edited. Pops an approval prompt.' + REV_NOTE + RETURNS_NOTE,
|
|
193
222
|
inputSchema: {
|
|
194
|
-
id:
|
|
223
|
+
id: idSchema(),
|
|
195
224
|
expected_rev: expectedRevSchema(),
|
|
196
225
|
text: z.string().min(1),
|
|
197
226
|
title: z.string().optional().describe('Optional new title/name for the clip.'),
|
|
@@ -200,38 +229,38 @@ server.registerTool('edit_clip', {
|
|
|
200
229
|
}, async ({ id, expected_rev, text, title, append }) => runForward('edit_clip', { id, expected_rev, text, title: title != null ? title : null, append: !!append }));
|
|
201
230
|
|
|
202
231
|
server.registerTool('pin_clip', {
|
|
203
|
-
description: 'Toggle the pin (star) on a clip by id.' + REV_NOTE,
|
|
204
|
-
inputSchema: { id:
|
|
232
|
+
description: 'Toggle the pin (star) on a clip by id.' + REV_NOTE + RETURNS_NOTE,
|
|
233
|
+
inputSchema: { id: idSchema(), expected_rev: expectedRevSchema() },
|
|
205
234
|
}, async ({ id, expected_rev }) => runForward('pin_clip', { id, expected_rev }));
|
|
206
235
|
|
|
207
236
|
server.registerTool('set_numpad', {
|
|
208
|
-
description: 'Assign a clip to a numpad quick-paste slot (1-9).' + REV_NOTE,
|
|
209
|
-
inputSchema: { id:
|
|
237
|
+
description: 'Assign a clip to a numpad quick-paste slot (1-9).' + REV_NOTE + RETURNS_NOTE,
|
|
238
|
+
inputSchema: { id: idSchema(), expected_rev: expectedRevSchema(), slot: z.number().int().min(1).max(9) },
|
|
210
239
|
}, async ({ id, expected_rev, slot }) => runForward('set_numpad', { id, expected_rev, slot }));
|
|
211
240
|
|
|
212
241
|
server.registerTool('assign_group', {
|
|
213
|
-
description: '
|
|
214
|
-
inputSchema: { id:
|
|
242
|
+
description: 'Put a clip into groups or take it out: the groups it is not in are added, and if it is already in every one it is taken out of them all. Free on a clip you can read; asks the user first for a clip you cannot read or a group that exists but is not shared with you. Returns added and removed (groups shared with you only) and groups: the clip\'s shared groups after the change. The clip\'s id changes form when the change makes it readable to you or not.' + NOT_SHARED_NOTE + REV_NOTE + RETURNS_NOTE,
|
|
243
|
+
inputSchema: { id: idSchema(), expected_rev: expectedRevSchema(), group: z.string().min(1).describe(GROUPS_PARAM) },
|
|
215
244
|
}, async ({ id, expected_rev, group }) => runForward('assign_group', { id, expected_rev, group }));
|
|
216
245
|
|
|
217
246
|
server.registerTool('create_group', {
|
|
218
|
-
description: 'Create
|
|
219
|
-
inputSchema: { name: z.string().min(1) },
|
|
247
|
+
description: 'Create groups (an existing group, in any case, is reused). New groups are not shared with AI. Asks the user first when a group you name exists but is not shared with you. Returns groups: the named groups shared with you, as stored.' + NOT_SHARED_NOTE,
|
|
248
|
+
inputSchema: { name: z.string().min(1).describe(GROUPS_PARAM) },
|
|
220
249
|
}, async ({ name }) => runForward('create_group', { name }));
|
|
221
250
|
|
|
222
251
|
server.registerTool('delete_group', {
|
|
223
|
-
description: 'Delete
|
|
252
|
+
description: 'Delete ONE group by its full name, in any case (a comma or / in it is part of the name). Clips stay in history and leave only this group. Deleting a group not shared with AI asks the user first. The "AI" group cannot be deleted.',
|
|
224
253
|
inputSchema: { name: z.string().min(1) },
|
|
225
254
|
}, async ({ name }) => runForward('delete_group', { name }));
|
|
226
255
|
|
|
227
256
|
server.registerTool('delete_clip', {
|
|
228
257
|
description: 'Delete a clip from history. Always pops an approval prompt.' + REV_NOTE,
|
|
229
|
-
inputSchema: { id:
|
|
258
|
+
inputSchema: { id: idSchema(), expected_rev: expectedRevSchema() },
|
|
230
259
|
}, async ({ id, expected_rev }) => runForward('delete_clip', { id, expected_rev }));
|
|
231
260
|
|
|
232
261
|
server.registerTool('copy_to_clipboard', {
|
|
233
262
|
description: 'Put a clip (by id) or literal text onto the user\'s system clipboard. Always pops an approval prompt.',
|
|
234
|
-
inputSchema: { id:
|
|
263
|
+
inputSchema: { id: idSchema().optional(), text: z.string().optional() },
|
|
235
264
|
}, async ({ id, text }) => {
|
|
236
265
|
if (!id && !text) return errorResult('Provide either id or text.');
|
|
237
266
|
return runForward('copy_to_clipboard', { id: id || null, text: text != null ? text : null });
|
|
@@ -239,7 +268,7 @@ server.registerTool('copy_to_clipboard', {
|
|
|
239
268
|
|
|
240
269
|
server.registerTool('paste_clip', {
|
|
241
270
|
description: 'Put a clip on the clipboard and paste it into the foreground app. Always pops an approval prompt.',
|
|
242
|
-
inputSchema: { id:
|
|
271
|
+
inputSchema: { id: idSchema() },
|
|
243
272
|
}, async ({ id }) => runForward('paste_clip', { id }));
|
|
244
273
|
|
|
245
274
|
// --- Connect -----------------------------------------------------------------
|
package/mcp-approval.html
CHANGED
|
@@ -95,10 +95,12 @@
|
|
|
95
95
|
create_group:'new_label', delete_group:'label_off', read_clip:'visibility',
|
|
96
96
|
search_all:'search', image_path:'image',
|
|
97
97
|
};
|
|
98
|
+
// What "Always allow" / "Allow for this session" grant: ONE tool each (main keeps
|
|
99
|
+
// grants per tool), so each label names its own action.
|
|
98
100
|
const KIND = {
|
|
99
101
|
delete_clip:'deleting clips', edit_clip:'editing clip text', copy_to_clipboard:'writing your clipboard', paste_clip:'pasting into apps',
|
|
100
|
-
add_clip:'adding clips', pin_clip:'pinning', set_numpad:'numpad slots', assign_group:'
|
|
101
|
-
create_group:'
|
|
102
|
+
add_clip:'adding clips', pin_clip:'pinning', set_numpad:'numpad slots', assign_group:"changing clips' groups",
|
|
103
|
+
create_group:'creating groups', delete_group:'deleting groups', read_clip:'reading private clips',
|
|
102
104
|
search_all:'searching private clips', image_path:'revealing image paths',
|
|
103
105
|
};
|
|
104
106
|
// The prompt never takes focus (main shows it inactive) and never allows by
|
package/package.json
CHANGED
package/preload.js
CHANGED
|
@@ -31,11 +31,13 @@ contextBridge.exposeInMainWorld('api', {
|
|
|
31
31
|
saveSettings: (settings) => ipcRenderer.invoke('save-settings', settings),
|
|
32
32
|
setShowShortcut: (shortcut) => ipcRenderer.invoke('set-show-shortcut', shortcut),
|
|
33
33
|
setQuickPasteShortcut: (shortcut) => ipcRenderer.invoke('set-quick-paste-shortcut', shortcut),
|
|
34
|
+
setNewNoteShortcut: (shortcut) => ipcRenderer.invoke('set-new-note-shortcut', shortcut),
|
|
34
35
|
suspendShortcuts: () => ipcRenderer.invoke('suspend-shortcuts'),
|
|
35
36
|
resumeShortcuts: () => ipcRenderer.invoke('resume-shortcuts'),
|
|
36
37
|
resolveShowShortcut: (shortcut) => ipcRenderer.invoke('resolve-show-shortcut', shortcut),
|
|
37
|
-
groupCreate: (
|
|
38
|
-
groupDelete: (name) => ipcRenderer.invoke('group-delete', name),
|
|
38
|
+
groupCreate: (text) => ipcRenderer.invoke('group-create', text), // -> the tag names ("a, b" = two), or false
|
|
39
|
+
groupDelete: (name) => ipcRenderer.invoke('group-delete', name), // -> { ok, name, ids, shared }, or false
|
|
40
|
+
groupRestore: (payload) => ipcRenderer.invoke('group-restore', payload), // the delete's Undo
|
|
39
41
|
groupAssign: (id, group, rev) => ipcRenderer.invoke('group-assign', id, group, rev),
|
|
40
42
|
copyImagePath: (id) => ipcRenderer.invoke('copy-image-path', id),
|
|
41
43
|
openEditor: (id, options) => ipcRenderer.invoke('open-editor', id, options || {}),
|
package/site/index.html
CHANGED
|
@@ -76,6 +76,10 @@
|
|
|
76
76
|
<dt><kbd>Numpad 1-9</kbd></dt>
|
|
77
77
|
<dd>Paste a numpad slot without opening the popup. <span class="dim">On a Mac: <kbd>Cmd</kbd><kbd>Option</kbd><kbd>1-9</kbd></span></dd>
|
|
78
78
|
</div>
|
|
79
|
+
<div>
|
|
80
|
+
<dt><kbd>Win</kbd><kbd>N</kbd></dt>
|
|
81
|
+
<dd>A new note, from any app. <span class="dim">On a Mac: record a shortcut in Settings</span></dd>
|
|
82
|
+
</div>
|
|
79
83
|
</dl>
|
|
80
84
|
|
|
81
85
|
<div class="get" id="download">
|
|
@@ -164,7 +168,7 @@
|
|
|
164
168
|
</div>
|
|
165
169
|
<div>
|
|
166
170
|
<dt><span class="mi spec-icon" aria-hidden="true">star</span>Pins, groups and titles</dt>
|
|
167
|
-
<dd>Pinned clips never expire.
|
|
171
|
+
<dd>Pinned clips never expire. A clip can sit in several groups (type <code>work, api</code> to add both), and any clip can carry a title, so a snippet you keep reads as a note rather than a fragment.</dd>
|
|
168
172
|
</div>
|
|
169
173
|
<div>
|
|
170
174
|
<dt><span class="mi spec-icon" aria-hidden="true">image</span>Images</dt>
|
|
@@ -344,7 +348,11 @@
|
|
|
344
348
|
}
|
|
345
349
|
};
|
|
346
350
|
const DEMO_SHOTS = ["assets/demo-shot-waveform.svg", "assets/demo-shot-dashboard.svg", "assets/demo-shot-architecture.svg"];
|
|
347
|
-
|
|
351
|
+
// The tags each seed slot's clips carry, typed as in the app ("work, api"
|
|
352
|
+
// is two tags); labels is every tag once, first use first.
|
|
353
|
+
const DEMO_TAG_SLOTS = ["work", "work, api", "work, release", "code", "code, review", "support", "ideas", "legal", "sales", "design", "finance", "ops", "personal", "release", "meeting", "writing", "bug", "social"];
|
|
354
|
+
let labels = Core.search.splitTagInput(DEMO_TAG_SLOTS.join(", "));
|
|
355
|
+
const slotTags = (slot) => Core.search.splitTagInput(DEMO_TAG_SLOTS[slot % DEMO_TAG_SLOTS.length]);
|
|
348
356
|
const numpadSlots = new Map([
|
|
349
357
|
[0, 1],
|
|
350
358
|
[5, 4],
|
|
@@ -390,8 +398,8 @@
|
|
|
390
398
|
const isImage = Boolean(example.image) || /screenshot|image|diagram|png/i.test(text) || index % 17 === 0;
|
|
391
399
|
const pin = {};
|
|
392
400
|
if (numpadSlots.has(index)) pin.number = numpadSlots.get(index);
|
|
393
|
-
if (index % 11 === 0 || index < 4) pin.groups =
|
|
394
|
-
if (index % 23 === 0) pin.groups = [...new Set([...(pin.groups || []),
|
|
401
|
+
if (index % 11 === 0 || index < 4) pin.groups = slotTags(index);
|
|
402
|
+
if (index % 23 === 0) pin.groups = [...new Set([...(pin.groups || []), ...slotTags(index * 3 + 4)])];
|
|
395
403
|
if (DEMO_EXTRA_GROUPS.has(index)) pin.groups = [...new Set([...(pin.groups || []), DEMO_EXTRA_GROUPS.get(index)])];
|
|
396
404
|
const hasPin = index % 19 === 0 || index < 2 || Object.keys(pin).length > 0;
|
|
397
405
|
return {
|
|
@@ -411,7 +419,7 @@
|
|
|
411
419
|
// The search box TEXT is the single source of truth (same model as the app): facets live
|
|
412
420
|
// as group:/is:/num:/since: tokens inside `query`; chip state is DERIVED from it.
|
|
413
421
|
const state = { query: "", sortMode: null };
|
|
414
|
-
const facetState = () => Core.search.facetState(Core.search.parseQuery(state.query));
|
|
422
|
+
const facetState = () => Core.search.facetState(Core.search.parseQuery(state.query), labels);
|
|
415
423
|
const installState = { mode: "installer", downloadOS: "win" };
|
|
416
424
|
const clipList = document.getElementById("clip-list");
|
|
417
425
|
const clipCount = document.getElementById("clip-count");
|
|
@@ -579,6 +587,9 @@
|
|
|
579
587
|
demoZoom.bindInput(document.getElementById("imagePreviewHeight"), document.getElementById("imagePreviewHeightReset"));
|
|
580
588
|
document.getElementById("shortcutRecord").textContent = `${mod} + Shift + V`;
|
|
581
589
|
document.getElementById("quickPasteRecord").textContent = `${mod} + Shift + B`;
|
|
590
|
+
const newNoteRecord = document.getElementById("newNoteShortcutRecord");
|
|
591
|
+
newNoteRecord.textContent = isMac ? "Not set" : "Win + N";
|
|
592
|
+
newNoteRecord.classList.toggle("unset", isMac);
|
|
582
593
|
const providers = [
|
|
583
594
|
{ name: "Google Drive", path: isMac ? "~/Library/CloudStorage/GoogleDrive/BoardClip" : "G:\\My Drive\\BoardClip" },
|
|
584
595
|
{ name: "OneDrive", path: isMac ? "~/Library/CloudStorage/OneDrive/BoardClip" : "C:\\Users\\you\\OneDrive\\BoardClip" },
|
|
@@ -824,14 +835,24 @@
|
|
|
824
835
|
// uses, via an adapter backed by the in-memory clips + browser APIs. Click
|
|
825
836
|
// routing, confirm/prompt dialogs, numpad-replace, add-group and keyboard nav
|
|
826
837
|
// therefore behave identically to the app and cannot drift.
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
838
|
+
// The demo's tags follow the app's rules through the same shared functions
|
|
839
|
+
// (Core.search.inputTagNames / tagTogglePlan, the app's tagInputNames and
|
|
840
|
+
// applyGroup* read input the same way): flat, ONE stored spelling per tag
|
|
841
|
+
// whatever the case it is typed in, and "a, b" typed in one go is two tags.
|
|
842
|
+
const storedTag = (name) => labels.find((label) => Core.search.sameTag(label, name)) || null;
|
|
843
|
+
const demoTagNames = (input) => Core.search.inputTagNames(input, labels, null, Core.search.tagSpellings([labels]));
|
|
844
|
+
// Tags that come with data (a new note's filter tags, unions) in their stored spelling.
|
|
845
|
+
const demoTagList = (names) => Core.search.expandTagNames(names, null, Core.search.tagSpellings([labels]));
|
|
846
|
+
function registerDemoTags(names) {
|
|
847
|
+
for (const name of names) if (!storedTag(name)) labels.push(name);
|
|
848
|
+
}
|
|
849
|
+
// One clip in or out of one tag (any case). True when it changed.
|
|
850
|
+
function setDemoTag(id, name, shouldHave) {
|
|
851
|
+
const clip = clips.find((item) => Core.itemId(item) === id);
|
|
852
|
+
const own = clip && Core.groupsOf(clip).find((group) => Core.search.sameTag(group, name));
|
|
853
|
+
if (!clip || !!own === !!shouldHave) return false;
|
|
854
|
+
clips = Core.toggleGroup(clips, id, own || name);
|
|
855
|
+
return true;
|
|
835
856
|
}
|
|
836
857
|
function closeDemoSettings() {
|
|
837
858
|
settingsPanel.classList.remove("show");
|
|
@@ -851,6 +872,10 @@
|
|
|
851
872
|
deletedToast: "Deleted",
|
|
852
873
|
itemById: (id) => clips.find((item) => Core.itemId(item) === id),
|
|
853
874
|
numpadMap: () => Core.numpadMap(clips),
|
|
875
|
+
// The popup's quick keys (controller.armQuickKeys): a digit does what its
|
|
876
|
+
// slot's click does here (copies it), N opens a new note.
|
|
877
|
+
quickPaste: (slot) => demoAdapter.activateClip(Core.numpadMap(clips)[slot]),
|
|
878
|
+
newNote: () => openDemoEditor(null),
|
|
854
879
|
protectedGroups: () => new Set(),
|
|
855
880
|
getQuery: () => state.query,
|
|
856
881
|
applyFacet: (token, intent) => demoSearchBox.applyFacet(token, intent),
|
|
@@ -874,17 +899,50 @@
|
|
|
874
899
|
return { ...clip, pin: Object.keys(pin).length ? pin : null };
|
|
875
900
|
});
|
|
876
901
|
},
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
902
|
+
// The app's applyGroupAssign: several tags at once are one decision (the
|
|
903
|
+
// missing ones are added; a clip that has them all loses them all).
|
|
904
|
+
toggleGroup: (id, text) => {
|
|
905
|
+
const clip = demoAdapter.itemById(id);
|
|
906
|
+
const names = clip ? demoTagNames(text) : [];
|
|
907
|
+
if (!names.length) return false;
|
|
908
|
+
const { add: added, remove: removed } = Core.search.tagTogglePlan(Core.groupsOf(clip), names);
|
|
909
|
+
registerDemoTags(added);
|
|
910
|
+
for (const name of added) setDemoTag(id, name, true);
|
|
911
|
+
for (const name of removed) setDemoTag(id, name, false);
|
|
912
|
+
return { ok: true, added, removed };
|
|
913
|
+
},
|
|
914
|
+
// The tags made (or reused, in their stored spelling), or false.
|
|
915
|
+
createGroup: (text) => {
|
|
916
|
+
const names = demoTagNames(text);
|
|
917
|
+
if (!names.length) return false;
|
|
918
|
+
registerDemoTags(names);
|
|
919
|
+
return names;
|
|
920
|
+
},
|
|
921
|
+
// ONE tag by its name (any case, never split), off every clip that had
|
|
922
|
+
// it; returns what restoreGroup (the Undo) puts back.
|
|
923
|
+
deleteGroup: (name) => {
|
|
924
|
+
const stored = storedTag(name);
|
|
925
|
+
if (!stored) return false;
|
|
926
|
+
labels = labels.filter((label) => label !== stored);
|
|
927
|
+
state.query = Core.search.stripFacet(state.query, { kind: "group", value: stored });
|
|
882
928
|
if (typeof demoSearchBox !== "undefined" && demoSearchBox) demoSearchBox.setQuery(state.query);
|
|
883
|
-
|
|
929
|
+
const ids = clips.map((clip) => Core.itemId(clip)).filter((id) => setDemoTag(id, stored, false));
|
|
930
|
+
// A deleted tag is shared with no one (made again, it starts private).
|
|
931
|
+
const shared = demoSharedGroups.delete(stored);
|
|
932
|
+
return { ok: true, name: stored, ids, shared };
|
|
933
|
+
},
|
|
934
|
+
restoreGroup: (result) => {
|
|
935
|
+
const name = Core.search.cleanTagName(result && result.name);
|
|
936
|
+
if (!name) return false;
|
|
937
|
+
registerDemoTags([name]);
|
|
938
|
+
const stored = storedTag(name);
|
|
939
|
+
for (const id of (result.ids || [])) setDemoTag(id, stored, true);
|
|
940
|
+
if (result.shared) demoSharedGroups.add(stored);
|
|
941
|
+
return true;
|
|
884
942
|
},
|
|
885
943
|
deleteClip: (id) => { clips = Core.deleteItem(clips, id); },
|
|
886
944
|
clearUnpinned: () => { clips = clips.filter((clip) => Core.isPinned(clip)); },
|
|
887
|
-
setGroupSharedAi: (group) => { if (
|
|
945
|
+
setGroupSharedAi: (group, shared) => { if (shared) demoSharedGroups.add(group); else demoSharedGroups.delete(group); }, // shared: the switch's new state
|
|
888
946
|
// Copy without pasting (the controller says "Copied"). A page cannot paste
|
|
889
947
|
// into another app, so the demo's click (activateClip) copies too.
|
|
890
948
|
copyClip: async (id) => {
|
|
@@ -959,12 +1017,13 @@
|
|
|
959
1017
|
const present = new Set(clips.map((clip) => Core.itemId(clip)));
|
|
960
1018
|
clips = Core.sortItems([...clips, ...snaps.filter((snap) => !present.has(Core.itemId(snap)))]);
|
|
961
1019
|
},
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1020
|
+
// "a, b" adds (or removes) both, on every clip given.
|
|
1021
|
+
groupAssignMany: (ids, text, shouldHave) => {
|
|
1022
|
+
const names = demoTagNames(text);
|
|
1023
|
+
if (shouldHave) registerDemoTags(names);
|
|
1024
|
+
let changed = false;
|
|
1025
|
+
for (const id of ids) for (const name of names) if (setDemoTag(id, name, shouldHave)) changed = true;
|
|
1026
|
+
return changed;
|
|
968
1027
|
},
|
|
969
1028
|
pasteMany: async (ids) => {
|
|
970
1029
|
// Text-only join, matching the app's pasteMany (images can't concatenate).
|
|
@@ -976,11 +1035,7 @@
|
|
|
976
1035
|
showToast(`Pasted ${texts.length}`);
|
|
977
1036
|
},
|
|
978
1037
|
startUnify: (ids) => runDemoUnify(ids),
|
|
979
|
-
offerUndo: ({
|
|
980
|
-
message: `Deleted ${count} clip${count === 1 ? "" : "s"}`,
|
|
981
|
-
actionLabel: "Undo",
|
|
982
|
-
onAction: undo,
|
|
983
|
-
}),
|
|
1038
|
+
offerUndo: ({ message, undo }) => Core.showActionToast(activeToast(), { message, actionLabel: "Undo", onAction: undo }),
|
|
984
1039
|
toast: (msg) => showToast(msg),
|
|
985
1040
|
// Commit-on-add for the demo editor's tag strip on a NEW note: commit the
|
|
986
1041
|
// draft through the shared editor commit (which records the created id).
|
|
@@ -1036,7 +1091,7 @@
|
|
|
1036
1091
|
// kept per visitor in localStorage (the app keeps them per device).
|
|
1037
1092
|
const DEMO_OPTIONS_HEIGHT_KEY = "boardclip-demo-options-height";
|
|
1038
1093
|
const DEMO_REGEX_KEY = "boardclip-demo-regex";
|
|
1039
|
-
const DEMO_JOINS_KEY = "boardclip-demo-joins";
|
|
1094
|
+
const DEMO_JOINS_KEY = "boardclip-demo-joins-2";
|
|
1040
1095
|
const DEMO_FIND_PREFS_KEY = "boardclip-demo-find";
|
|
1041
1096
|
let demoOptionsHeight = 0;
|
|
1042
1097
|
let demoRegex = false;
|
|
@@ -1050,6 +1105,15 @@
|
|
|
1050
1105
|
localStorage.removeItem("boardclip-demo-search-mode");
|
|
1051
1106
|
}
|
|
1052
1107
|
demoRegex = localStorage.getItem(DEMO_REGEX_KEY) === "1";
|
|
1108
|
+
// Several tags now join with AND by default: a remembered tag join goes,
|
|
1109
|
+
// once (the old key is folded in, then removed); the kinds' join stays.
|
|
1110
|
+
const oldJoins = localStorage.getItem("boardclip-demo-joins");
|
|
1111
|
+
if (oldJoins !== null) {
|
|
1112
|
+
localStorage.removeItem("boardclip-demo-joins");
|
|
1113
|
+
const kept = { ...(JSON.parse(oldJoins) || {}) };
|
|
1114
|
+
delete kept.group;
|
|
1115
|
+
if (localStorage.getItem(DEMO_JOINS_KEY) === null) localStorage.setItem(DEMO_JOINS_KEY, JSON.stringify(kept));
|
|
1116
|
+
}
|
|
1053
1117
|
demoJoins = JSON.parse(localStorage.getItem(DEMO_JOINS_KEY) || "null");
|
|
1054
1118
|
} catch {}
|
|
1055
1119
|
const demoSearchBox = Core.attachSearchBox(search, {
|
|
@@ -1095,6 +1159,7 @@
|
|
|
1095
1159
|
popupWin.close();
|
|
1096
1160
|
}
|
|
1097
1161
|
function openPopup() {
|
|
1162
|
+
controller.armQuickKeys(); // 1-9 and N act until the first press or other key
|
|
1098
1163
|
popupShown = true;
|
|
1099
1164
|
updateDock();
|
|
1100
1165
|
if (popupWin.isOpen()) popupWin.focus(); else popupWin.open();
|
|
@@ -1134,6 +1199,11 @@
|
|
|
1134
1199
|
// Keyboard nav (arrows/Enter/Escape + multi-select) when focus is inside the
|
|
1135
1200
|
// demo window, so the demo honors its own "type, press Enter, and paste" pitch.
|
|
1136
1201
|
demoWindowEl.addEventListener("keydown", (event) => controller.onKeydown(event));
|
|
1202
|
+
// Any press in the popup, the search field included, ends the quick keys.
|
|
1203
|
+
// They arm when the popup opens (openPopup, which focuses its search), as
|
|
1204
|
+
// the app's do; not at page load: the popup starts open but unfocused, and
|
|
1205
|
+
// the click that would focus it ends them.
|
|
1206
|
+
demoWindowEl.addEventListener("pointerdown", (event) => controller.onPointerdown(event), true);
|
|
1137
1207
|
|
|
1138
1208
|
// ONE clip window (the editor, the image viewer, Unify), a window on the
|
|
1139
1209
|
// page like the app's own windows: the shared Core.attachWebWindow moves,
|
|
@@ -1276,8 +1346,8 @@
|
|
|
1276
1346
|
let currentId = id;
|
|
1277
1347
|
let currentText = clip ? (clip.text || "") : "";
|
|
1278
1348
|
let currentTitle = clip ? Core.titleOf(clip) : "";
|
|
1279
|
-
const sourceGroups = id == null ? Core.sourceGroupsFromFilters(facetState().active) : [];
|
|
1280
|
-
sourceGroups
|
|
1349
|
+
const sourceGroups = id == null ? demoTagList(Core.sourceGroupsFromFilters(facetState().active)) : [];
|
|
1350
|
+
registerDemoTags(sourceGroups);
|
|
1281
1351
|
retireClipView();
|
|
1282
1352
|
demoEditorClipId = id;
|
|
1283
1353
|
demoEditor = Core.createEditor({
|
|
@@ -1304,7 +1374,7 @@
|
|
|
1304
1374
|
if (created) {
|
|
1305
1375
|
created.title = nextTitle || undefined;
|
|
1306
1376
|
created.titleUpdatedAt = Date.now();
|
|
1307
|
-
sourceGroups.forEach((group) =>
|
|
1377
|
+
sourceGroups.forEach((group) => setDemoTag(Core.itemId(created), group, true));
|
|
1308
1378
|
}
|
|
1309
1379
|
if (created) currentId = Core.itemId(created);
|
|
1310
1380
|
}
|
|
@@ -1339,7 +1409,7 @@
|
|
|
1339
1409
|
.sort((a, b) => (a.ts || 0) - (b.ts || 0));
|
|
1340
1410
|
if (sources.length < 2) return;
|
|
1341
1411
|
const total = sources.length - 1;
|
|
1342
|
-
const unionGroups =
|
|
1412
|
+
const unionGroups = demoTagList(sources.flatMap(Core.groupsOf));
|
|
1343
1413
|
let acc = clipSnapshot(sources[0]);
|
|
1344
1414
|
let step = 0;
|
|
1345
1415
|
retireClipView();
|
|
@@ -1352,7 +1422,8 @@
|
|
|
1352
1422
|
if (created) {
|
|
1353
1423
|
created.title = acc.title || undefined;
|
|
1354
1424
|
created.titleUpdatedAt = Date.now();
|
|
1355
|
-
|
|
1425
|
+
registerDemoTags(unionGroups);
|
|
1426
|
+
for (const g of unionGroups) setDemoTag(Core.itemId(created), g, true);
|
|
1356
1427
|
}
|
|
1357
1428
|
abort();
|
|
1358
1429
|
renderClips();
|