@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.
@@ -45,7 +45,7 @@ function readSettings() {
45
45
  }
46
46
  }
47
47
 
48
- function readHistory() {
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. Returns the clip\'s new rev.';
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 (with which are shared with AI), pinned and numpad-slot clips, and totals. The starting point for understanding what is available.',
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(readHistory(), readSettings())));
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('Only clips in this group.'),
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 res = mcpCore.listClips(readHistory(), readSettings(), {
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:), 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 plus a count of how many NON-shared clips also matched. Set include_unshared to run the full search over everything - that requires the app and pops an approval prompt.',
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 res = await searchClipsOffThread(readHistory(), readSettings(), {
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: z.string() },
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 = readSettings();
159
- const resolved = mcpCore.resolveForRead(readHistory(), settings, id);
160
- if (resolved.reason === 'not_found') return errorResult(`No clip with id "${id}".`);
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
- const sharedSet = mcpCore.sharedGroupSet(settings);
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, sharedSet));
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, group, timestamp). Set include_path to also get the local file path, which requires the app and pops an approval prompt.',
174
- inputSchema: { id: z.string(), include_path: z.boolean().optional() },
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 = readSettings();
178
- const resolved = mcpCore.resolveForRead(readHistory(), settings, id);
179
- if (resolved.reason === 'not_found') return errorResult(`No clip with id "${id}".`);
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, { sharedSet: mcpCore.sharedGroupSet(settings) }));
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 new text clip to history, optionally into a group. Pops an approval prompt unless you have allowed this action.',
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: z.string(),
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: z.string(), expected_rev: expectedRevSchema() },
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: z.string(), expected_rev: expectedRevSchema(), slot: z.number().int().min(1).max(9) },
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: 'Toggle a clip\'s membership in a group (adds if absent, removes if present).' + REV_NOTE,
214
- inputSchema: { id: z.string(), expected_rev: expectedRevSchema(), group: z.string().min(1) },
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 a new group.',
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 a group (clips stay in history, just lose this group label).',
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: z.string(), expected_rev: expectedRevSchema() },
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: z.string().optional(), text: z.string().optional() },
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: z.string() },
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:'group changes',
101
- create_group:'group changes', delete_group:'group changes', read_clip:'reading private clips',
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tobq/boardclip",
3
3
  "productName": "BoardClip",
4
- "version": "2.1.113",
4
+ "version": "2.1.115",
5
5
  "description": "Cross-platform clipboard history tray app with numpad quick-paste and native cloud sync",
6
6
  "keywords": [
7
7
  "clipboard",
package/preload.js CHANGED
@@ -31,11 +31,20 @@ 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: (name) => ipcRenderer.invoke('group-create', name),
38
- groupDelete: (name) => ipcRenderer.invoke('group-delete', name),
38
+ // Windows: the chord the keyboard hook took while a row records ({ shortcut, key },
39
+ // what the page recorder makes of a keydown); the page never sees those keys.
40
+ onShortcutRecorded: (callback) => {
41
+ const listener = (_, chord) => callback(chord);
42
+ ipcRenderer.on('shortcut-recorded', listener);
43
+ return () => ipcRenderer.removeListener('shortcut-recorded', listener);
44
+ },
45
+ groupCreate: (text) => ipcRenderer.invoke('group-create', text), // -> the tag names ("a, b" = two), or false
46
+ groupDelete: (name) => ipcRenderer.invoke('group-delete', name), // -> { ok, name, ids, shared }, or false
47
+ groupRestore: (payload) => ipcRenderer.invoke('group-restore', payload), // the delete's Undo
39
48
  groupAssign: (id, group, rev) => ipcRenderer.invoke('group-assign', id, group, rev),
40
49
  copyImagePath: (id) => ipcRenderer.invoke('copy-image-path', id),
41
50
  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. Groups nest with a slash, like work/api, and any clip can carry a title, so a snippet you keep reads as a note rather than a fragment.</dd>
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
- let labels = ["work", "work/api", "work/release", "code", "code/review", "support", "ideas", "legal", "sales", "design", "finance", "ops", "personal", "release", "meeting", "writing", "bug", "social"];
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 = [labels[index % labels.length]];
394
- if (index % 23 === 0) pin.groups = [...new Set([...(pin.groups || []), labels[(index * 3 + 4) % labels.length]])];
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
- function stripGroupFromClips(group) {
828
- clips = clips.map((clip) => {
829
- if (!clip.pin || !Array.isArray(clip.pin.groups)) return clip;
830
- const groups = clip.pin.groups.filter((label) => label !== group);
831
- const nextPin = { ...clip.pin };
832
- if (groups.length) nextPin.groups = groups; else delete nextPin.groups;
833
- return { ...clip, pin: Object.keys(nextPin).length ? nextPin : null };
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
- toggleGroup: (id, group) => { if (!labels.includes(group)) labels.push(group); clips = Core.toggleGroup(clips, id, group); }, // matches the app's applyGroupAssign (toggles)
878
- createGroup: (name) => { if (!labels.includes(name)) labels.push(name); },
879
- deleteGroup: (group) => {
880
- labels = labels.filter((l) => l !== group);
881
- state.query = Core.search.stripFacet(state.query, { kind: "group", value: group });
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
- stripGroupFromClips(group);
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 (demoSharedGroups.has(group)) demoSharedGroups.delete(group); else demoSharedGroups.add(group); },
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
- groupAssignMany: (ids, group, shouldHave) => {
963
- if (!labels.includes(group)) labels.push(group);
964
- for (const id of ids) {
965
- const clip = clips.find((item) => Core.itemId(item) === id);
966
- if (clip && Core.isInGroup(clip, group) !== shouldHave) clips = Core.toggleGroup(clips, id, group);
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: ({ count, undo }) => Core.showActionToast(activeToast(), {
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.forEach((group) => { if (!labels.includes(group)) labels.push(group); });
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) => { if (!Core.groupsOf(created).includes(group)) clips = Core.toggleGroup(clips, Core.itemId(created), 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 = new Set(sources.flatMap(Core.groupsOf));
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
- for (const g of unionGroups) { if (!labels.includes(g)) labels.push(g); if (!Core.groupsOf(created).includes(g)) clips = Core.toggleGroup(clips, Core.itemId(created), g); }
1425
+ registerDemoTags(unionGroups);
1426
+ for (const g of unionGroups) setDemoTag(Core.itemId(created), g, true);
1356
1427
  }
1357
1428
  abort();
1358
1429
  renderClips();