@tobq/boardclip 2.1.112 → 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 +830 -192
- 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/main.js
CHANGED
|
@@ -676,6 +676,28 @@ function effectiveQuickPasteShortcut() {
|
|
|
676
676
|
return settings.quick_paste_shortcut || defaultQuickPasteShortcut();
|
|
677
677
|
}
|
|
678
678
|
|
|
679
|
+
// Windows: Win+N, taken by the keyboard hook like Win+V (it is Windows' own
|
|
680
|
+
// notification panel chord, which RegisterHotKey cannot win). Elsewhere none
|
|
681
|
+
// until one is recorded.
|
|
682
|
+
function defaultNewNoteShortcut() {
|
|
683
|
+
return process.platform === 'win32' ? 'Super+N' : '';
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// A stored shortcut of SHORTCUT_OFF = none, even where the default is a chord
|
|
687
|
+
// (Windows: Win+N goes back to Windows' notification panel). '' = the default.
|
|
688
|
+
const SHORTCUT_OFF = 'none';
|
|
689
|
+
|
|
690
|
+
// The chord the user recorded ('' for the default or off).
|
|
691
|
+
function recordedNewNoteShortcut() {
|
|
692
|
+
const value = settings.new_note_shortcut || '';
|
|
693
|
+
return value === SHORTCUT_OFF ? '' : value;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function effectiveNewNoteShortcut() {
|
|
697
|
+
if (settings.new_note_shortcut === SHORTCUT_OFF) return '';
|
|
698
|
+
return settings.new_note_shortcut || defaultNewNoteShortcut();
|
|
699
|
+
}
|
|
700
|
+
|
|
679
701
|
function globalShowShortcut() {
|
|
680
702
|
if (process.platform === 'darwin' && shortcutUsesFn(settings.show_shortcut)) return '';
|
|
681
703
|
return settings.show_shortcut || (process.platform === 'win32' ? '' : DEFAULT_SHOW_SHORTCUT);
|
|
@@ -700,6 +722,14 @@ function normalizeQuickPasteShortcut(shortcut) {
|
|
|
700
722
|
return value;
|
|
701
723
|
}
|
|
702
724
|
|
|
725
|
+
function normalizeNewNoteShortcut(shortcut) {
|
|
726
|
+
const value = String(shortcut || '').trim();
|
|
727
|
+
if (!value || value === defaultNewNoteShortcut()) return '';
|
|
728
|
+
// Off is stored only where the default is a chord (on macOS it IS the default).
|
|
729
|
+
if (value.toLowerCase() === SHORTCUT_OFF) return defaultNewNoteShortcut() ? SHORTCUT_OFF : '';
|
|
730
|
+
return value;
|
|
731
|
+
}
|
|
732
|
+
|
|
703
733
|
function shortcutUsesFn(shortcut) {
|
|
704
734
|
return String(shortcut || '').split('+').some(part => {
|
|
705
735
|
const value = part.trim().toLowerCase();
|
|
@@ -840,6 +870,12 @@ let history = loadHistory();
|
|
|
840
870
|
for (const h of history) migrateItemPin(h);
|
|
841
871
|
for (const h of history) ensureItemId(h);
|
|
842
872
|
let conflicts = loadConflicts();
|
|
873
|
+
// Flat tags (14 Oct 2026): ONE pure plan over both files (clipboardModel.planTagState),
|
|
874
|
+
// run first thing in whenReady (a second instance never gets there: no stray copy) and
|
|
875
|
+
// persisted at once, settings then history. No done flag: nothing in the plan is a one-off
|
|
876
|
+
// edit, so a start that dies between the two writes (or an MCP read between them) gets the
|
|
877
|
+
// very same clips from the new settings and the old history.
|
|
878
|
+
let tagMigration = null;
|
|
843
879
|
|
|
844
880
|
// --- Pin model ---
|
|
845
881
|
// Unified state: item.pin is null/undefined for unpinned items, or an object
|
|
@@ -935,7 +971,7 @@ function applyExternalTextEdit({ id, originalText, originalTitle, sourceGroups,
|
|
|
935
971
|
originalTitle,
|
|
936
972
|
newText,
|
|
937
973
|
newTitle,
|
|
938
|
-
sourceGroups,
|
|
974
|
+
sourceGroups: normalizeTagNames(Array.isArray(sourceGroups) ? sourceGroups : []),
|
|
939
975
|
groupTombstones: settings.group_tombstones,
|
|
940
976
|
supersedes: settings.supersedes,
|
|
941
977
|
now: Date.now(),
|
|
@@ -1064,6 +1100,170 @@ function pruneHistory() {
|
|
|
1064
1100
|
}
|
|
1065
1101
|
}
|
|
1066
1102
|
|
|
1103
|
+
// --- Flat tags ---
|
|
1104
|
+
// The cutover (this device's first start of the flat-tags build) keeps a copy of the
|
|
1105
|
+
// three files plus an undo record (each changed clip's old tags) in clipboard-RECOVERED/
|
|
1106
|
+
// BEFORE anything changes in memory. No copy, no change: the app then runs on the old
|
|
1107
|
+
// names until the next start tries again.
|
|
1108
|
+
function migrateTagsAtLoad() {
|
|
1109
|
+
let plan;
|
|
1110
|
+
try {
|
|
1111
|
+
plan = clipboardModel.planTagState(history, settings, { aiGroupName: mcpCore.AI_GROUP_NAME });
|
|
1112
|
+
} catch (error) {
|
|
1113
|
+
diagnostics.record('groups.flatten.error', { stage: 'plan', error: error && error.message }, { forceFile: true });
|
|
1114
|
+
return null;
|
|
1115
|
+
}
|
|
1116
|
+
if (!plan.changed) return null;
|
|
1117
|
+
let evidence = '';
|
|
1118
|
+
if (plan.report.cutover && (plan.undo.length || plan.report.retired.length)) {
|
|
1119
|
+
try {
|
|
1120
|
+
evidence = saveFlattenEvidence(plan);
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
diagnostics.record('groups.flatten.error', { stage: 'evidence', error: error && error.message }, { forceFile: true });
|
|
1123
|
+
return null;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
history.splice(0, history.length, ...plan.history);
|
|
1127
|
+
Object.assign(settings, plan.settings);
|
|
1128
|
+
return { ...plan, evidence };
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function saveFlattenEvidence(plan) {
|
|
1132
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1133
|
+
// The local data dir, never a provider path.
|
|
1134
|
+
const evidenceDir = path.join(DATA_DIR, 'clipboard-RECOVERED', `flatten-groups-${stamp}`);
|
|
1135
|
+
fs.mkdirSync(evidenceDir, { recursive: true });
|
|
1136
|
+
for (const file of [DB_PATH, SETTINGS_PATH, CONFLICTS_PATH]) {
|
|
1137
|
+
if (fs.existsSync(file)) fs.copyFileSync(file, path.join(evidenceDir, path.basename(file)));
|
|
1138
|
+
}
|
|
1139
|
+
flattenLedgerCache = null;
|
|
1140
|
+
fs.writeFileSync(path.join(evidenceDir, 'undo.json'), JSON.stringify({
|
|
1141
|
+
note: 'Flat-tags cutover: each changed clip with the tags it had before, plus the old tag list and AI share list. Ids do not change, so the old tags can be put back by id.',
|
|
1142
|
+
at: Date.now(),
|
|
1143
|
+
report: plan.report,
|
|
1144
|
+
groups: settings.groups || [],
|
|
1145
|
+
groups_shared_with_ai: settings.groups_shared_with_ai || [],
|
|
1146
|
+
clips: plan.undo,
|
|
1147
|
+
}, null, 2));
|
|
1148
|
+
return evidenceDir;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// whenReady, before any other write.
|
|
1152
|
+
function persistTagMigration() {
|
|
1153
|
+
if (!tagMigration) return;
|
|
1154
|
+
saveSettingsFile();
|
|
1155
|
+
writeHistoryStorageFile();
|
|
1156
|
+
const r = tagMigration.report;
|
|
1157
|
+
diagnostics.record('groups.flatten', {
|
|
1158
|
+
cutover: r.cutover, retired: r.retired.length, clips_changed: r.clipsChanged,
|
|
1159
|
+
groups_before: r.groupsBefore, groups_after: r.groupsAfter,
|
|
1160
|
+
shared_before: r.sharedBefore, shared_after: r.sharedAfter,
|
|
1161
|
+
visible_before: r.visibleBefore, visible_after: r.visibleAfter,
|
|
1162
|
+
unshared_to_protect: r.unsharedToProtect.length,
|
|
1163
|
+
copy_path: tagMigration.evidence || '',
|
|
1164
|
+
}, { forceFile: true });
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// Every cutover undo ledger this device wrote (normally one; a start that died half way
|
|
1168
|
+
// leaves a second), the earliest first: unsplitLiterals reads each clip's first entry.
|
|
1169
|
+
let flattenLedgerCache = null;
|
|
1170
|
+
function flattenLedger() {
|
|
1171
|
+
if (flattenLedgerCache) return flattenLedgerCache;
|
|
1172
|
+
const entries = [];
|
|
1173
|
+
const recoveredRoot = path.join(DATA_DIR, 'clipboard-RECOVERED');
|
|
1174
|
+
let names = [];
|
|
1175
|
+
try { names = fs.readdirSync(recoveredRoot).filter(n => n.startsWith('flatten-groups-')).sort(); } catch {}
|
|
1176
|
+
for (const name of names) {
|
|
1177
|
+
try {
|
|
1178
|
+
const undo = blobStore.parseJsonText(fs.readFileSync(path.join(recoveredRoot, name, 'undo.json'), 'utf-8'));
|
|
1179
|
+
if (undo && Array.isArray(undo.clips)) entries.push(...undo.clips);
|
|
1180
|
+
} catch {}
|
|
1181
|
+
}
|
|
1182
|
+
flattenLedgerCache = entries;
|
|
1183
|
+
return entries;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// A name another device made with a plain '/' that this device's cutover split goes back
|
|
1187
|
+
// on this device's own clips (clipboardModel.unsplitLiterals). Returns the new history,
|
|
1188
|
+
// or null when nothing changed.
|
|
1189
|
+
function unsplitLiteralTags(list, source) {
|
|
1190
|
+
if (!(Number(settings.groups_flattened_at) > 0) || !(settings.group_literals || []).length) return null;
|
|
1191
|
+
const literalKeys = new Set(clipboardModel.normalizeGroupLiterals(settings.group_literals).map(r => clipSearch.tagKey(r.name)));
|
|
1192
|
+
if (!clipboardModel.normalizeGroupSplits(settings.group_splits).some(r => literalKeys.has(clipSearch.tagKey(r.name)))) return null;
|
|
1193
|
+
const result = clipboardModel.unsplitLiterals(list, flattenLedger(), settings);
|
|
1194
|
+
if (!result.ids.length) return null;
|
|
1195
|
+
diagnostics.record('groups.flatten.unsplit', { source, clips: result.ids.length }, { forceFile: true });
|
|
1196
|
+
return result;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// The tag rule applied to a name list against the current settings (retired names,
|
|
1200
|
+
// stored spellings, deleted tags). For every write boundary: typed input, AI tools,
|
|
1201
|
+
// new-note tags, unify and fork unions, restored clips.
|
|
1202
|
+
function currentTagContext(extraLists = []) {
|
|
1203
|
+
return clipboardModel.tagContext(settings, extraLists);
|
|
1204
|
+
}
|
|
1205
|
+
function normalizeTagNames(names, ctx = currentTagContext([names])) {
|
|
1206
|
+
return clipboardModel.normalizeTagList((Array.isArray(names) ? names : []).map(String), ctx);
|
|
1207
|
+
}
|
|
1208
|
+
// Names typed or sent in one go ("todo, forge"), in stored form.
|
|
1209
|
+
function tagInputNames(input) {
|
|
1210
|
+
return clipboardModel.tagInputNames(input, settings);
|
|
1211
|
+
}
|
|
1212
|
+
// Make sure settings.groups lists each tag (one entry per tag); a tag deleted
|
|
1213
|
+
// earlier is re-created (its tombstone records the re-create, so sync keeps it).
|
|
1214
|
+
function registerTags(names) {
|
|
1215
|
+
if (!settings.groups) settings.groups = [];
|
|
1216
|
+
const listed = new Set(settings.groups.map(clipSearch.tagKey));
|
|
1217
|
+
let changed = false;
|
|
1218
|
+
const now = Date.now();
|
|
1219
|
+
const tombstones = normalizeGroupTombstones(settings.group_tombstones);
|
|
1220
|
+
let restored = false;
|
|
1221
|
+
for (const name of names) {
|
|
1222
|
+
const key = clipSearch.tagKey(name);
|
|
1223
|
+
if (!key) continue;
|
|
1224
|
+
for (const t of tombstones) {
|
|
1225
|
+
if (clipSearch.tagKey(t.name) === key && t.deletedAt > (t.restoredAt || 0)) { t.restoredAt = now; restored = true; }
|
|
1226
|
+
}
|
|
1227
|
+
if (listed.has(key)) continue;
|
|
1228
|
+
listed.add(key);
|
|
1229
|
+
settings.groups.push(name);
|
|
1230
|
+
changed = true;
|
|
1231
|
+
}
|
|
1232
|
+
if (restored) settings.group_tombstones = normalizeGroupTombstones(tombstones);
|
|
1233
|
+
return changed || restored;
|
|
1234
|
+
}
|
|
1235
|
+
// Tags the user or an AI tool named (typed, picked, sent). A brand-new name with a '/' is
|
|
1236
|
+
// a plain tag here: group_literals records it, so a device on its first flat start can
|
|
1237
|
+
// never retire it as an old nested name. Never for names that arrive with data (sync,
|
|
1238
|
+
// restored clips, unions): an old device's nested name there must stay retirable.
|
|
1239
|
+
function registerTypedTags(names) {
|
|
1240
|
+
const listed = new Set((settings.groups || []).map(clipSearch.tagKey));
|
|
1241
|
+
const fresh = names.filter(name => name.includes('/') && !listed.has(clipSearch.tagKey(name)));
|
|
1242
|
+
let literal = false;
|
|
1243
|
+
if (fresh.length) {
|
|
1244
|
+
const before = JSON.stringify(settings.group_literals || []);
|
|
1245
|
+
const at = Date.now();
|
|
1246
|
+
settings.group_literals = clipboardModel.normalizeGroupLiterals([...(settings.group_literals || []), ...fresh.map(name => ({ name, at }))]);
|
|
1247
|
+
literal = JSON.stringify(settings.group_literals) !== before;
|
|
1248
|
+
}
|
|
1249
|
+
return registerTags(names) || literal;
|
|
1250
|
+
}
|
|
1251
|
+
function itemHasTag(item, name) {
|
|
1252
|
+
const key = clipSearch.tagKey(name);
|
|
1253
|
+
return groupsOf(item).some(g => clipSearch.tagKey(g) === key);
|
|
1254
|
+
}
|
|
1255
|
+
function setItemTag(item, name, shouldHave) {
|
|
1256
|
+
if (!!shouldHave === itemHasTag(item, name)) return false;
|
|
1257
|
+
const pin = ensurePin(item);
|
|
1258
|
+
const key = clipSearch.tagKey(name);
|
|
1259
|
+
if (shouldHave) pin.groups = [...(pin.groups || []), name];
|
|
1260
|
+
else {
|
|
1261
|
+
pin.groups = (pin.groups || []).filter(g => clipSearch.tagKey(g) !== key);
|
|
1262
|
+
if (!pin.groups.length) delete pin.groups;
|
|
1263
|
+
}
|
|
1264
|
+
return true;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1067
1267
|
// --- Migration: old settings.numpad_slots -> per-item pin ---
|
|
1068
1268
|
// Runs once on first launch after upgrading from the Python-era config.
|
|
1069
1269
|
function migrateNumpad() {
|
|
@@ -1098,10 +1298,6 @@ function normalizeSupersedes(list) {
|
|
|
1098
1298
|
return clipboardModel.normalizeSupersedes(list);
|
|
1099
1299
|
}
|
|
1100
1300
|
|
|
1101
|
-
function groupTombstoneNames(list) {
|
|
1102
|
-
return clipboardModel.groupTombstoneNames(list);
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
1301
|
function tombstoneIds(list) {
|
|
1106
1302
|
return clipboardModel.tombstoneIds(list);
|
|
1107
1303
|
}
|
|
@@ -1129,11 +1325,11 @@ function addSupersede(record) {
|
|
|
1129
1325
|
]);
|
|
1130
1326
|
}
|
|
1131
1327
|
|
|
1132
|
-
function addGroupTombstone(name) {
|
|
1328
|
+
function addGroupTombstone(name, deletedAt = Date.now()) {
|
|
1133
1329
|
if (!name) return;
|
|
1134
1330
|
settings.group_tombstones = normalizeGroupTombstones([
|
|
1135
1331
|
...(settings.group_tombstones || []),
|
|
1136
|
-
{ name, deletedAt:
|
|
1332
|
+
{ name, deletedAt, anyCase: true },
|
|
1137
1333
|
]);
|
|
1138
1334
|
}
|
|
1139
1335
|
|
|
@@ -1168,11 +1364,20 @@ function mergeItems(localItem, remoteItem) {
|
|
|
1168
1364
|
// each event+clip is logged at most once an hour.
|
|
1169
1365
|
const forensicsLastLogged = new Map();
|
|
1170
1366
|
const FORENSICS_REPEAT_MS = 60 * 60 * 1000;
|
|
1367
|
+
// Ids of LOCAL clips the last merge put in stored tag form (a tag deleted on another
|
|
1368
|
+
// device, a spelling twin arriving): applyRemoteEnvelope counts them as touched.
|
|
1369
|
+
let lastMergeNormalisedIds = new Set();
|
|
1171
1370
|
function mergeHistories(local, remote) {
|
|
1172
1371
|
const report = [];
|
|
1173
1372
|
const merged = clipboardModel.mergeHistories(local, remote, settings, report);
|
|
1174
1373
|
const now = Date.now();
|
|
1374
|
+
lastMergeNormalisedIds = new Set(report.filter(e => e.event === 'normalised').map(e => e.id));
|
|
1375
|
+
// A tag deleted or respelled elsewhere is routine; a retired nested name still on a local
|
|
1376
|
+
// clip is not (it should have gone at the cutover).
|
|
1377
|
+
const stragglers = report.filter(e => e.event === 'normalised' && e.split).length;
|
|
1378
|
+
if (stragglers) diagnostics.record('groups.flatten.straggler', { source: 'merge', local_clips: stragglers }, { forceFile: true });
|
|
1175
1379
|
for (const entry of report) {
|
|
1380
|
+
if (entry.event === 'normalised') continue;
|
|
1176
1381
|
const key = `${entry.event}:${entry.reason || ''}:${entry.id}`;
|
|
1177
1382
|
if (now - (forensicsLastLogged.get(key) || 0) < FORENSICS_REPEAT_MS) continue;
|
|
1178
1383
|
forensicsLastLogged.set(key, now);
|
|
@@ -1196,7 +1401,7 @@ function mergeHistories(local, remote) {
|
|
|
1196
1401
|
}
|
|
1197
1402
|
|
|
1198
1403
|
function mergeGroups(local, remote) {
|
|
1199
|
-
return clipboardModel.mergeGroups(local, remote, settings.group_tombstones);
|
|
1404
|
+
return clipboardModel.mergeGroups(local, remote, settings.group_tombstones, currentTagContext([local || [], remote || []]));
|
|
1200
1405
|
}
|
|
1201
1406
|
|
|
1202
1407
|
// Settings the save-settings IPC may write without touching history or sync
|
|
@@ -1217,6 +1422,7 @@ function remoteSettingsPayload() {
|
|
|
1217
1422
|
delete remoteSave.sync_disabled_paths;
|
|
1218
1423
|
delete remoteSave.show_shortcut;
|
|
1219
1424
|
delete remoteSave.quick_paste_shortcut;
|
|
1425
|
+
delete remoteSave.new_note_shortcut;
|
|
1220
1426
|
delete remoteSave.quick_paste_restore;
|
|
1221
1427
|
delete remoteSave.capture_rich;
|
|
1222
1428
|
delete remoteSave.quick_paste_restore_delay_ms;
|
|
@@ -1241,8 +1447,12 @@ function remoteSettingsPayload() {
|
|
|
1241
1447
|
delete remoteSave.facet_joins;
|
|
1242
1448
|
delete remoteSave.find_regex;
|
|
1243
1449
|
delete remoteSave.find_case;
|
|
1244
|
-
//
|
|
1245
|
-
|
|
1450
|
+
// When this device cut over to flat tags: per device.
|
|
1451
|
+
delete remoteSave.groups_flattened_at;
|
|
1452
|
+
// The AI share list itself is per device (switch flips travel as ai_share_stamps).
|
|
1453
|
+
delete remoteSave.groups_shared_with_ai;
|
|
1454
|
+
// AI Access: per-machine, never synced. The AI share switches sync per tag through
|
|
1455
|
+
// ai_share_stamps (newest flip wins); the list itself is each device's own.
|
|
1246
1456
|
delete remoteSave.mcp_secret;
|
|
1247
1457
|
delete remoteSave.ai_access_enabled;
|
|
1248
1458
|
delete remoteSave.ai_always_allow;
|
|
@@ -1266,6 +1476,40 @@ function mergeSyncedSettings(remoteSettings) {
|
|
|
1266
1476
|
...(settings.supersedes || []),
|
|
1267
1477
|
...(remoteSettings.supersedes || []),
|
|
1268
1478
|
]);
|
|
1479
|
+
// Retired nested tag names and plain '/' names (both permanent, every device agrees on
|
|
1480
|
+
// the earliest record).
|
|
1481
|
+
const splitsBefore = clipboardModel.splitMapOf(settings);
|
|
1482
|
+
const literalsBefore = (settings.group_literals || []).length;
|
|
1483
|
+
settings.group_splits = clipboardModel.normalizeGroupSplits([
|
|
1484
|
+
...(settings.group_splits || []),
|
|
1485
|
+
...(remoteSettings.group_splits || []),
|
|
1486
|
+
]);
|
|
1487
|
+
settings.group_literals = clipboardModel.normalizeGroupLiterals([
|
|
1488
|
+
...(settings.group_literals || []),
|
|
1489
|
+
...(remoteSettings.group_literals || []),
|
|
1490
|
+
]);
|
|
1491
|
+
if (settings.group_literals.length !== literalsBefore) unsplitPending = true;
|
|
1492
|
+
// A split new here (another device's late cutover) follows the cutover's share rule.
|
|
1493
|
+
const newSplits = [...clipboardModel.splitMapOf(settings)].filter(([key]) => !splitsBefore.has(key)).map(([name, parts]) => ({ name, parts }));
|
|
1494
|
+
if (newSplits.length) {
|
|
1495
|
+
const held = [...(settings.groups || []), ...history.flatMap(h => groupsOf(h))];
|
|
1496
|
+
const result = clipboardModel.shareListAfterNewSplits(settings.groups_shared_with_ai, newSplits, held);
|
|
1497
|
+
if (result.unshared.length) {
|
|
1498
|
+
settings.groups_shared_with_ai = result.shared;
|
|
1499
|
+
diagnostics.record('groups.flatten.late_split', { splits: newSplits.length, unshared: result.unshared.length }, { forceFile: true });
|
|
1500
|
+
notifyAiAccessChangedSoon();
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
// AI share switches: a newer flip on another device applies here.
|
|
1504
|
+
const stamps = clipboardModel.mergeShareStamps(settings.ai_share_stamps, remoteSettings.ai_share_stamps);
|
|
1505
|
+
if (JSON.stringify(stamps) !== JSON.stringify(clipboardModel.normalizeShareStamps(settings.ai_share_stamps))) {
|
|
1506
|
+
settings.ai_share_stamps = stamps;
|
|
1507
|
+
const shared = clipboardModel.applyShareStamps(settings.groups_shared_with_ai, stamps, currentTagContext().spellings);
|
|
1508
|
+
if (shared !== settings.groups_shared_with_ai) {
|
|
1509
|
+
settings.groups_shared_with_ai = shared;
|
|
1510
|
+
notifyAiAccessChangedSoon();
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1269
1513
|
const secrets = [settings.p2p_secret, remoteSettings.p2p_secret].filter(Boolean).sort();
|
|
1270
1514
|
if (secrets.length) settings.p2p_secret = secrets[0];
|
|
1271
1515
|
if (remoteSettings.p2p_enabled !== undefined && settings.p2p_enabled === undefined) {
|
|
@@ -1345,13 +1589,32 @@ function captureTitleConflicts(localHistory, remoteHistory, source) {
|
|
|
1345
1589
|
}
|
|
1346
1590
|
}
|
|
1347
1591
|
|
|
1592
|
+
// Set when a plain '/' name arrives (mergeSyncedSettings): the next fold puts it back on
|
|
1593
|
+
// the clips this device's cutover split, before that fold's merge.
|
|
1594
|
+
let unsplitPending = false;
|
|
1348
1595
|
function foldRemoteState(canonicalHistory, remoteHistory, remoteSettings, remoteConflicts) {
|
|
1349
1596
|
mergeSyncedSettings(remoteSettings);
|
|
1350
1597
|
mergeSyncedConflicts(remoteConflicts);
|
|
1598
|
+
let unsplitIds = [];
|
|
1599
|
+
if (unsplitPending) {
|
|
1600
|
+
unsplitPending = false;
|
|
1601
|
+
const result = unsplitLiteralTags(canonicalHistory, 'sync');
|
|
1602
|
+
if (result) { canonicalHistory = result.history; unsplitIds = result.ids; }
|
|
1603
|
+
}
|
|
1351
1604
|
captureTitleConflicts(canonicalHistory, remoteHistory, 'sync');
|
|
1352
1605
|
const historyGroups = canonicalHistory.flatMap(h => groupsOf(h));
|
|
1353
|
-
|
|
1354
|
-
|
|
1606
|
+
const remoteGroups = Array.isArray(remoteHistory) ? remoteHistory.flatMap(h => groupsOf(h || {})) : [];
|
|
1607
|
+
const listed = [...(remoteSettings && remoteSettings.groups || []), ...historyGroups];
|
|
1608
|
+
const ctx = currentTagContext([settings.groups || [], listed, remoteGroups]);
|
|
1609
|
+
settings.groups = clipboardModel.mergeGroups(settings.groups, listed, settings.group_tombstones, ctx);
|
|
1610
|
+
if (Array.isArray(settings.groups_shared_with_ai)) {
|
|
1611
|
+
const shared = clipSearch.expandTagNames(settings.groups_shared_with_ai, null, ctx.spellings);
|
|
1612
|
+
if (shared !== settings.groups_shared_with_ai) settings.groups_shared_with_ai = shared;
|
|
1613
|
+
}
|
|
1614
|
+
const merged = mergeHistories(canonicalHistory, remoteHistory);
|
|
1615
|
+
for (const id of unsplitIds) lastMergeNormalisedIds.add(id);
|
|
1616
|
+
if ((settings.group_literals || []).length) settings.groups = clipboardModel.pruneLiteralParts(settings.groups, settings, merged.flatMap(h => groupsOf(h)));
|
|
1617
|
+
return merged;
|
|
1355
1618
|
}
|
|
1356
1619
|
|
|
1357
1620
|
// ---- Sync change feed (lib/sync-delta): one revision counter + per-entry
|
|
@@ -2718,7 +2981,9 @@ async function applyRemoteEnvelope(envelope, { peer = null, reason, source, tran
|
|
|
2718
2981
|
const previousConflictsJson = JSON.stringify(conflictModel.normalizeConflictState(conflicts));
|
|
2719
2982
|
const before = history.slice();
|
|
2720
2983
|
const canonicalHistory = foldRemoteState(history.slice(), remoteHistory, remoteSettings, conflictModel.normalizeConflictState(remoteConflicts));
|
|
2721
|
-
const
|
|
2984
|
+
const touched = syncDelta.touchedIds(envelope);
|
|
2985
|
+
for (const id of lastMergeNormalisedIds) touched.add(id);
|
|
2986
|
+
const localChanged = syncDelta.historyChangedBy(before, canonicalHistory, touched);
|
|
2722
2987
|
const { settingsChanged, conflictsChanged } = commitRemoteFold(canonicalHistory, { localChanged, previousSettingsJson, previousConflictsJson, notifyPeers });
|
|
2723
2988
|
const peerName = peer ? (peer.deviceName || peer.deviceId) : (envelope.deviceName || envelope.deviceId);
|
|
2724
2989
|
if (localChanged || settingsChanged || conflictsChanged) {
|
|
@@ -3663,6 +3928,12 @@ function historyEntryDiagnostics(entry) {
|
|
|
3663
3928
|
function addToHistory(entry, matchFn) {
|
|
3664
3929
|
const startedAt = Date.now();
|
|
3665
3930
|
ensureItemId(entry);
|
|
3931
|
+
// The same clip is the same id (ids hash the content). A long clip kept as a text
|
|
3932
|
+
// blob holds only a preview in item.text, so a text compare alone missed it and
|
|
3933
|
+
// added a second entry with the SAME id.
|
|
3934
|
+
const entryKey = itemKey(entry);
|
|
3935
|
+
const callerMatch = matchFn;
|
|
3936
|
+
matchFn = (it) => !!it && (itemKey(it) === entryKey || callerMatch(it));
|
|
3666
3937
|
const beforeTombstones = normalizeTombstones(settings.tombstones);
|
|
3667
3938
|
settings.tombstones = beforeTombstones.filter(t => t.id !== itemKey(entry));
|
|
3668
3939
|
const tombstoneRemoved = settings.tombstones.length !== beforeTombstones.length;
|
|
@@ -4467,10 +4738,10 @@ function createPopup() {
|
|
|
4467
4738
|
setTimeout(() => {
|
|
4468
4739
|
if (Date.now() < ignoreBlurUntil || !isPopupOpen()) return;
|
|
4469
4740
|
if (!win.isFocused()) {
|
|
4470
|
-
// Focus moved to one of BoardClip's OWN windows
|
|
4471
|
-
//
|
|
4472
|
-
//
|
|
4473
|
-
// (getFocusedWindow() is null then)
|
|
4741
|
+
// Focus moved to one of BoardClip's OWN windows: a clip window's own
|
|
4742
|
+
// focus hook (trackClipWindow) closes the popup; any other (the AI
|
|
4743
|
+
// approval prompt) leaves it open. Focus leaving to another app
|
|
4744
|
+
// (getFocusedWindow() is null then) closes it here.
|
|
4474
4745
|
const focused = BrowserWindow.getFocusedWindow();
|
|
4475
4746
|
if (focused && focused !== win) return;
|
|
4476
4747
|
hidePopup();
|
|
@@ -5405,9 +5676,20 @@ function restoreWindowMaximized(childWin, key, options) {
|
|
|
5405
5676
|
// (lib/window-placement.js). The work area is the popup's display, else the
|
|
5406
5677
|
// saved spot's, else the cursor's.
|
|
5407
5678
|
const clipWindows = new Set();
|
|
5679
|
+
// Going INTO a clip window (clicking it, or any other way it takes the focus)
|
|
5680
|
+
// closes the popup, on every platform: the "open several" gestures leave the
|
|
5681
|
+
// popup up only while the new windows open behind the scenes (showInactive).
|
|
5682
|
+
// Owner 2026-10-13: "still doesnt close going to clip. inconsisnte" - the
|
|
5683
|
+
// popup's blur handler kept it open whenever the focus went to one of our own
|
|
5684
|
+
// windows, and macOS has no blur-to-hide at all.
|
|
5685
|
+
let clipFocusHideHeldUntil = 0;
|
|
5408
5686
|
function trackClipWindow(childWin) {
|
|
5409
5687
|
clipWindows.add(childWin);
|
|
5410
5688
|
try { placedBounds.set(childWin, childWin.getBounds()); } catch {}
|
|
5689
|
+
childWin.on('focus', () => {
|
|
5690
|
+
if (Date.now() < clipFocusHideHeldUntil || !isPopupOpen()) return;
|
|
5691
|
+
hidePopup();
|
|
5692
|
+
});
|
|
5411
5693
|
childWin.once('closed', () => clipWindows.delete(childWin));
|
|
5412
5694
|
}
|
|
5413
5695
|
function clipWindowBoundsFromSettings(key, defaults, options) {
|
|
@@ -5509,9 +5791,10 @@ function createEditorWindow(session, presentOptions = {}) {
|
|
|
5509
5791
|
}
|
|
5510
5792
|
|
|
5511
5793
|
function sourceGroupsForNewNote(options) {
|
|
5512
|
-
|
|
5513
|
-
.map(group =>
|
|
5514
|
-
.filter(group => group && !group.startsWith('__'))
|
|
5794
|
+
const names = (Array.isArray(options && options.sourceGroups) ? options.sourceGroups : [])
|
|
5795
|
+
.map(group => clipSearch.cleanTagName(group))
|
|
5796
|
+
.filter(group => group && !group.startsWith('__'));
|
|
5797
|
+
return normalizeTagNames(names);
|
|
5515
5798
|
}
|
|
5516
5799
|
|
|
5517
5800
|
// Open the editor for clip `id`, or a blank new-note editor when id is null.
|
|
@@ -5525,6 +5808,9 @@ function sourceGroupsForNewNote(options) {
|
|
|
5525
5808
|
function presentSecondaryWindow(targetWin, options = {}) {
|
|
5526
5809
|
if (!targetWin || targetWin.isDestroyed()) return;
|
|
5527
5810
|
if (options && options.keepPopup) {
|
|
5811
|
+
// Backstop: a focus event the inactive show raises on its own must not
|
|
5812
|
+
// close the popup the user is still opening clips from.
|
|
5813
|
+
clipFocusHideHeldUntil = Date.now() + 300;
|
|
5528
5814
|
targetWin.showInactive();
|
|
5529
5815
|
if (typeof targetWin.moveTop === 'function') { try { targetWin.moveTop(); } catch {} }
|
|
5530
5816
|
return;
|
|
@@ -5593,9 +5879,7 @@ function openEditor(id, options = {}) {
|
|
|
5593
5879
|
originalId = itemKey(item);
|
|
5594
5880
|
} else {
|
|
5595
5881
|
sourceGroups = sourceGroupsForNewNote(options);
|
|
5596
|
-
|
|
5597
|
-
if (!settings.groups || !settings.groups.includes(group)) applyGroupCreate(group);
|
|
5598
|
-
}
|
|
5882
|
+
if (registerTags(sourceGroups)) saveSettingsFile();
|
|
5599
5883
|
}
|
|
5600
5884
|
const baseHash = clipboardModel.textHashForText(baseText);
|
|
5601
5885
|
editSessionSeq += 1;
|
|
@@ -5780,8 +6064,7 @@ function foldForkInto(fork, note) {
|
|
|
5780
6064
|
const forkId = itemKey(fork);
|
|
5781
6065
|
const noteId = itemKey(note);
|
|
5782
6066
|
const forkSlot = numpadSlotOf(fork);
|
|
5783
|
-
const
|
|
5784
|
-
const newGroups = groupsOf(fork).filter(group => !noteGroups.includes(group));
|
|
6067
|
+
const newGroups = normalizeTagNames(groupsOf(fork)).filter(group => !itemHasTag(note, group));
|
|
5785
6068
|
if (newGroups.length) {
|
|
5786
6069
|
const pin = ensurePin(note);
|
|
5787
6070
|
pin.groups = [...(pin.groups || []), ...newGroups];
|
|
@@ -6048,13 +6331,10 @@ function applyUnify(session) {
|
|
|
6048
6331
|
const item = history.find(it => it.type !== 'image' && it.text === mergedText);
|
|
6049
6332
|
if (item) {
|
|
6050
6333
|
if (mergedTitle) clipboardModel.setTitleMetadata(item, mergedTitle);
|
|
6051
|
-
|
|
6052
|
-
|
|
6053
|
-
|
|
6054
|
-
|
|
6055
|
-
if (!pin.groups.includes(group)) pin.groups.push(group);
|
|
6056
|
-
}
|
|
6057
|
-
if (session.unionGroups.length) touchPinGroups(item);
|
|
6334
|
+
const unionGroups = normalizeTagNames(session.unionGroups);
|
|
6335
|
+
if (registerTags(unionGroups)) saveSettingsFile();
|
|
6336
|
+
for (const group of unionGroups) setItemTag(item, group, true);
|
|
6337
|
+
if (unionGroups.length) touchPinGroups(item);
|
|
6058
6338
|
if (session.pinned && !item.pin) item.pin = {};
|
|
6059
6339
|
saveHistory();
|
|
6060
6340
|
if (session.numberSlot != null) applyNumpadAssign(itemKey(item), session.numberSlot, clipboardModel.clipRevision(item));
|
|
@@ -6133,9 +6413,11 @@ const historyFeedSenders = new Set();
|
|
|
6133
6413
|
function historyForRenderer() {
|
|
6134
6414
|
return history.map(item => ({ ...item, rev: clipboardModel.clipRevision(item) }));
|
|
6135
6415
|
}
|
|
6416
|
+
// What an MCP change says about its clip: the id and rev an AI holds for it (the
|
|
6417
|
+
// lib/mcp-core boundary: a real id only for a clip it can read, a keyed rev).
|
|
6136
6418
|
function mcpMutationResult(id, ok) {
|
|
6137
6419
|
const item = findHistoryItem(id);
|
|
6138
|
-
return { ok: !!ok, id, rev: item ?
|
|
6420
|
+
return { ok: !!ok, id: item ? mcpCore.publicId(item, settings) : null, rev: item ? mcpCore.publicRev(item, settings) : null };
|
|
6139
6421
|
}
|
|
6140
6422
|
|
|
6141
6423
|
// ---- Reusable mutation primitives (shared by IPC + MCP) ----
|
|
@@ -6173,56 +6455,127 @@ function applyNumpadAssign(id, slot, rev) {
|
|
|
6173
6455
|
return true;
|
|
6174
6456
|
}
|
|
6175
6457
|
|
|
6176
|
-
|
|
6177
|
-
|
|
6178
|
-
|
|
6179
|
-
|
|
6180
|
-
|
|
6181
|
-
|
|
6458
|
+
// Create tags from typed input ("todo, forge" = two). A name matching an existing tag
|
|
6459
|
+
// in any case reuses it. Returns the tag names, or false for nothing to create.
|
|
6460
|
+
function applyGroupCreate(input) {
|
|
6461
|
+
const names = tagInputNames(input);
|
|
6462
|
+
if (!names.length) return false;
|
|
6463
|
+
registerTypedTags(names);
|
|
6182
6464
|
saveSettingsFile();
|
|
6465
|
+
return names;
|
|
6466
|
+
}
|
|
6467
|
+
|
|
6468
|
+
// A deleted tag is shared with no one, so re-creating it later starts private on every
|
|
6469
|
+
// device: the unshare travels as a share stamp, but only once the Undo window has passed
|
|
6470
|
+
// (an Undo must not share or unshare anything on another device).
|
|
6471
|
+
const GROUP_DELETE_UNDO_MS = 15000;
|
|
6472
|
+
const pendingDeleteStamps = new Map();
|
|
6473
|
+
function commitDeleteStamp(key) {
|
|
6474
|
+
const pending = pendingDeleteStamps.get(key);
|
|
6475
|
+
if (!pending) return false;
|
|
6476
|
+
clearTimeout(pending.timer);
|
|
6477
|
+
pendingDeleteStamps.delete(key);
|
|
6478
|
+
if ((settings.groups || []).some(g => clipSearch.tagKey(g) === key)) return false;
|
|
6479
|
+
stampShareSwitch(pending.name, false, pending.at);
|
|
6183
6480
|
return true;
|
|
6184
6481
|
}
|
|
6482
|
+
function stampDeletedTagUnshared(name, at) {
|
|
6483
|
+
const key = clipSearch.tagKey(name);
|
|
6484
|
+
cancelDeleteStamp(key);
|
|
6485
|
+
const timer = setTimeout(() => { if (commitDeleteStamp(key)) saveSettingsFile(); }, GROUP_DELETE_UNDO_MS);
|
|
6486
|
+
if (timer.unref) timer.unref();
|
|
6487
|
+
pendingDeleteStamps.set(key, { timer, name, at });
|
|
6488
|
+
}
|
|
6489
|
+
function cancelDeleteStamp(key) {
|
|
6490
|
+
const pending = pendingDeleteStamps.get(key);
|
|
6491
|
+
if (pending) clearTimeout(pending.timer);
|
|
6492
|
+
pendingDeleteStamps.delete(key);
|
|
6493
|
+
}
|
|
6494
|
+
// Quitting inside an Undo window: the delete stands, so its unshare goes out now.
|
|
6495
|
+
function flushDeleteStamps() {
|
|
6496
|
+
let changed = false;
|
|
6497
|
+
for (const key of [...pendingDeleteStamps.keys()]) if (commitDeleteStamp(key)) changed = true;
|
|
6498
|
+
if (changed) saveSettingsFile();
|
|
6499
|
+
}
|
|
6185
6500
|
|
|
6501
|
+
// Delete ONE tag by its name (any case; never split: this is destructive). Only the
|
|
6502
|
+
// clips that had it are touched. The AI tag cannot be deleted. Returns what an Undo needs
|
|
6503
|
+
// (applyGroupRestore).
|
|
6186
6504
|
function applyGroupDelete(name) {
|
|
6505
|
+
const key = clipSearch.tagKey(name);
|
|
6187
6506
|
const groups = settings.groups || [];
|
|
6188
|
-
const idx = groups.
|
|
6189
|
-
if (idx < 0) return false;
|
|
6190
|
-
|
|
6191
|
-
groups.splice(idx, 1);
|
|
6507
|
+
const idx = groups.findIndex(g => clipSearch.tagKey(g) === key);
|
|
6508
|
+
if (!key || idx < 0 || clipSearch.sameTag(name, mcpCore.AI_GROUP_NAME)) return false;
|
|
6509
|
+
const stored = groups[idx];
|
|
6192
6510
|
const now = Date.now();
|
|
6511
|
+
addGroupTombstone(stored, now);
|
|
6512
|
+
groups.splice(idx, 1);
|
|
6513
|
+
const ids = [];
|
|
6193
6514
|
for (const h of history) {
|
|
6194
|
-
if (h
|
|
6195
|
-
|
|
6196
|
-
|
|
6197
|
-
touchPinGroups(h, now);
|
|
6198
|
-
}
|
|
6515
|
+
if (!setItemTag(h, stored, false)) continue;
|
|
6516
|
+
touchPinGroups(h, now);
|
|
6517
|
+
ids.push(itemKey(h));
|
|
6199
6518
|
}
|
|
6200
|
-
|
|
6201
|
-
if (
|
|
6202
|
-
settings.groups_shared_with_ai = settings.groups_shared_with_ai.filter(g => g !==
|
|
6519
|
+
const shared = Array.isArray(settings.groups_shared_with_ai) && settings.groups_shared_with_ai.some(g => clipSearch.tagKey(g) === key);
|
|
6520
|
+
if (shared) {
|
|
6521
|
+
settings.groups_shared_with_ai = settings.groups_shared_with_ai.filter(g => clipSearch.tagKey(g) !== key);
|
|
6522
|
+
stampDeletedTagUnshared(stored, now);
|
|
6203
6523
|
}
|
|
6204
6524
|
saveSettingsFile();
|
|
6205
6525
|
saveHistory();
|
|
6526
|
+
if (shared) notifyAiAccessChanged();
|
|
6527
|
+
return { ok: true, name: stored, ids, shared, index: idx };
|
|
6528
|
+
}
|
|
6529
|
+
|
|
6530
|
+
// Undo of a tag delete: the tag back where it was in the list (a re-create, which beats
|
|
6531
|
+
// the delete in sync), on the clips that had it, and shared with AI here again if it was.
|
|
6532
|
+
function applyGroupRestore(payload) {
|
|
6533
|
+
const typed = clipSearch.cleanTagName(payload && payload.name);
|
|
6534
|
+
if (!typed) return false;
|
|
6535
|
+
const key = clipSearch.tagKey(typed);
|
|
6536
|
+
const name = currentTagContext([[typed]]).spellings.get(key) || typed;
|
|
6537
|
+
cancelDeleteStamp(key);
|
|
6538
|
+
if (registerTags([name]) && Number.isInteger(payload.index) && payload.index >= 0) {
|
|
6539
|
+
const groups = settings.groups;
|
|
6540
|
+
const at = groups.findIndex(g => clipSearch.tagKey(g) === key);
|
|
6541
|
+
const index = Math.min(payload.index, groups.length - 1);
|
|
6542
|
+
if (at > index) groups.splice(index, 0, ...groups.splice(at, 1));
|
|
6543
|
+
}
|
|
6544
|
+
const ids = new Set(Array.isArray(payload.ids) ? payload.ids.map(String) : []);
|
|
6545
|
+
const now = Date.now();
|
|
6546
|
+
let changed = false;
|
|
6547
|
+
for (const h of history) {
|
|
6548
|
+
if (!ids.has(itemKey(h)) || !setItemTag(h, name, true)) continue;
|
|
6549
|
+
touchPinGroups(h, now);
|
|
6550
|
+
changed = true;
|
|
6551
|
+
}
|
|
6552
|
+
if (payload.shared) {
|
|
6553
|
+
if (!Array.isArray(settings.groups_shared_with_ai)) settings.groups_shared_with_ai = [];
|
|
6554
|
+
if (!settings.groups_shared_with_ai.some(g => clipSearch.tagKey(g) === key)) settings.groups_shared_with_ai.push(name);
|
|
6555
|
+
}
|
|
6556
|
+
saveSettingsFile();
|
|
6557
|
+
if (changed) saveHistory();
|
|
6558
|
+
if (payload.shared) notifyAiAccessChanged();
|
|
6206
6559
|
return true;
|
|
6207
6560
|
}
|
|
6208
6561
|
|
|
6209
|
-
//
|
|
6562
|
+
// The tag toggle (a row's picker, an AI tool's assign_group). Several tags at once
|
|
6563
|
+
// ("todo, forge", or a retired name like todo/forge) are ONE decision: the missing ones
|
|
6564
|
+
// are added; if the clip already has them all, they are all removed.
|
|
6565
|
+
function planTagToggle(item, input) {
|
|
6566
|
+
return clipSearch.tagTogglePlan(groupsOf(item), tagInputNames(input));
|
|
6567
|
+
}
|
|
6568
|
+
|
|
6210
6569
|
function applyGroupAssign(id, group, rev) {
|
|
6211
|
-
if (!group) return false;
|
|
6212
6570
|
const item = assertClipRevision(id, rev);
|
|
6213
|
-
|
|
6214
|
-
|
|
6215
|
-
if (
|
|
6216
|
-
const
|
|
6217
|
-
|
|
6218
|
-
pin.groups.splice(gIdx, 1);
|
|
6219
|
-
if (pin.groups.length === 0) delete pin.groups;
|
|
6220
|
-
} else {
|
|
6221
|
-
pin.groups.push(group);
|
|
6222
|
-
}
|
|
6571
|
+
const plan = planTagToggle(item, group);
|
|
6572
|
+
if (!plan.add.length && !plan.remove.length) return false;
|
|
6573
|
+
if (registerTypedTags(plan.add)) saveSettingsFile();
|
|
6574
|
+
for (const name of plan.add) setItemTag(item, name, true);
|
|
6575
|
+
for (const name of plan.remove) setItemTag(item, name, false);
|
|
6223
6576
|
touchPinGroups(item);
|
|
6224
6577
|
saveHistory();
|
|
6225
|
-
return true;
|
|
6578
|
+
return { ok: true, added: plan.add, removed: plan.remove };
|
|
6226
6579
|
}
|
|
6227
6580
|
|
|
6228
6581
|
function applyDeleteItem(id, rev) {
|
|
@@ -6265,13 +6618,19 @@ function applyDeleteItems(targets) {
|
|
|
6265
6618
|
// tombstone so a later sync can't resurrect the deletion.
|
|
6266
6619
|
function applyRestoreItems(snapshots) {
|
|
6267
6620
|
let changed = false;
|
|
6621
|
+
const ctx = currentTagContext((Array.isArray(snapshots) ? snapshots : []).map(snap => groupsOf(snap || {})));
|
|
6268
6622
|
for (const snap of (Array.isArray(snapshots) ? snapshots : [])) {
|
|
6269
6623
|
if (!snap) continue;
|
|
6270
|
-
const restored = cloneHistoryItem(snap);
|
|
6624
|
+
const restored = clipboardModel.normalizeItemTags(cloneHistoryItem(snap), ctx);
|
|
6625
|
+
if (registerTags(groupsOf(restored))) changed = true;
|
|
6271
6626
|
ensureItemId(restored);
|
|
6272
6627
|
const id = itemKey(restored);
|
|
6273
6628
|
if (findHistoryIndex(id) >= 0) continue;
|
|
6274
6629
|
settings.tombstones = normalizeTombstones(settings.tombstones).filter(t => t.id !== id);
|
|
6630
|
+
// The delete may already be on a peer or a provider and comes back with their
|
|
6631
|
+
// tombstones: a clock newer than the delete (its time-of-capture clock, so the clip
|
|
6632
|
+
// keeps its place) makes this copy beat it there too, as a re-copy does.
|
|
6633
|
+
restored.tsUpdatedAt = Date.now();
|
|
6275
6634
|
history.push(restored);
|
|
6276
6635
|
changed = true;
|
|
6277
6636
|
}
|
|
@@ -6289,27 +6648,22 @@ function applyRestoreItems(snapshots) {
|
|
|
6289
6648
|
// Explicit (non-toggle) group membership, mirroring applyGroupAssign's pin
|
|
6290
6649
|
// bookkeeping. Returns true if it changed the item.
|
|
6291
6650
|
function setItemGroupMembership(item, group, shouldHave) {
|
|
6292
|
-
|
|
6293
|
-
if (!pin.groups) pin.groups = [];
|
|
6294
|
-
const idx = pin.groups.indexOf(group);
|
|
6295
|
-
const has = idx >= 0;
|
|
6296
|
-
if (!!shouldHave === has) return false;
|
|
6297
|
-
if (shouldHave) pin.groups.push(group);
|
|
6298
|
-
else { pin.groups.splice(idx, 1); if (pin.groups.length === 0) delete pin.groups; }
|
|
6651
|
+
if (!setItemTag(item, group, shouldHave)) return false;
|
|
6299
6652
|
touchPinGroups(item);
|
|
6300
6653
|
return true;
|
|
6301
6654
|
}
|
|
6302
6655
|
|
|
6303
|
-
// Bulk add/remove
|
|
6304
|
-
// decided in the shared controller).
|
|
6656
|
+
// Bulk add/remove tags across many clips in one save (smart tri-state toggle
|
|
6657
|
+
// decided in the shared controller). "todo, forge" adds or removes both.
|
|
6305
6658
|
function applyGroupAssignMany(targets, group, shouldHave) {
|
|
6306
|
-
|
|
6307
|
-
|
|
6659
|
+
const names = tagInputNames(group);
|
|
6660
|
+
if (!names.length) return false;
|
|
6661
|
+
// targets: [{ id, rev }]. All-or-nothing: checked before any tag is even created.
|
|
6308
6662
|
const resolved = assertClipRevisions(targets);
|
|
6309
|
-
if (
|
|
6663
|
+
if (shouldHave && registerTypedTags(names)) saveSettingsFile();
|
|
6310
6664
|
let changed = false;
|
|
6311
6665
|
for (const { item } of resolved) {
|
|
6312
|
-
if (setItemGroupMembership(item,
|
|
6666
|
+
for (const name of names) if (setItemGroupMembership(item, name, !!shouldHave)) changed = true;
|
|
6313
6667
|
}
|
|
6314
6668
|
if (changed) saveHistory();
|
|
6315
6669
|
return changed;
|
|
@@ -6328,17 +6682,15 @@ function applyAddText(text, group) {
|
|
|
6328
6682
|
const value = String(text || '');
|
|
6329
6683
|
if (!value) return false;
|
|
6330
6684
|
addToHistory({ type: 'text', text: value, ts: Date.now() / 1000 }, it => it.type !== 'image' && it.text === value);
|
|
6331
|
-
|
|
6332
|
-
|
|
6685
|
+
const names = group ? tagInputNames(group) : [];
|
|
6686
|
+
if (names.length) {
|
|
6687
|
+
const key = itemKey({ type: 'text', text: value });
|
|
6688
|
+
const item = history.find(it => it.type !== 'image' && itemKey(it) === key);
|
|
6333
6689
|
if (item) {
|
|
6334
|
-
if (
|
|
6335
|
-
|
|
6336
|
-
if (
|
|
6337
|
-
if (
|
|
6338
|
-
pin.groups.push(group);
|
|
6339
|
-
touchPinGroups(item);
|
|
6340
|
-
saveHistory();
|
|
6341
|
-
}
|
|
6690
|
+
if (registerTypedTags(names)) saveSettingsFile();
|
|
6691
|
+
let changed = false;
|
|
6692
|
+
for (const name of names) if (setItemTag(item, name, true)) changed = true;
|
|
6693
|
+
if (changed) { touchPinGroups(item); saveHistory(); }
|
|
6342
6694
|
}
|
|
6343
6695
|
}
|
|
6344
6696
|
return true;
|
|
@@ -6432,30 +6784,46 @@ function mcpUnregisterAll() {
|
|
|
6432
6784
|
|
|
6433
6785
|
// ---- Allowlist / AI group ----
|
|
6434
6786
|
function ensureAiGroupShared() {
|
|
6435
|
-
let changed =
|
|
6436
|
-
if (!settings.groups) settings.groups = [];
|
|
6437
|
-
if (!settings.groups.includes(mcpCore.AI_GROUP_NAME)) {
|
|
6438
|
-
applyGroupCreate(mcpCore.AI_GROUP_NAME);
|
|
6439
|
-
}
|
|
6787
|
+
let changed = registerTags([mcpCore.AI_GROUP_NAME]);
|
|
6440
6788
|
if (!Array.isArray(settings.groups_shared_with_ai)) settings.groups_shared_with_ai = [];
|
|
6441
|
-
if (!settings.groups_shared_with_ai.
|
|
6789
|
+
if (!settings.groups_shared_with_ai.some(g => clipSearch.sameTag(g, mcpCore.AI_GROUP_NAME))) {
|
|
6442
6790
|
settings.groups_shared_with_ai.push(mcpCore.AI_GROUP_NAME);
|
|
6443
6791
|
changed = true;
|
|
6444
6792
|
}
|
|
6445
6793
|
if (changed) saveSettingsFile();
|
|
6446
6794
|
}
|
|
6447
6795
|
|
|
6448
|
-
|
|
6449
|
-
|
|
6796
|
+
// One flip of a tag's AI share switch: the list here, plus a stamp that carries the
|
|
6797
|
+
// flip to the other devices (newest flip per tag wins there).
|
|
6798
|
+
function stampShareSwitch(name, shared, at = Date.now()) {
|
|
6799
|
+
const key = clipSearch.tagKey(name);
|
|
6800
|
+
if (!key) return false;
|
|
6450
6801
|
if (!Array.isArray(settings.groups_shared_with_ai)) settings.groups_shared_with_ai = [];
|
|
6451
|
-
const has = settings.groups_shared_with_ai.
|
|
6452
|
-
if (shared && !has) settings.groups_shared_with_ai.push(name);
|
|
6453
|
-
else if (!shared && has) settings.groups_shared_with_ai = settings.groups_shared_with_ai.filter(g => g !==
|
|
6454
|
-
|
|
6802
|
+
const has = settings.groups_shared_with_ai.some(g => clipSearch.tagKey(g) === key);
|
|
6803
|
+
if (shared && !has) settings.groups_shared_with_ai.push(clipSearch.cleanTagName(name));
|
|
6804
|
+
else if (!shared && has) settings.groups_shared_with_ai = settings.groups_shared_with_ai.filter(g => clipSearch.tagKey(g) !== key);
|
|
6805
|
+
settings.ai_share_stamps = clipboardModel.mergeShareStamps(settings.ai_share_stamps, { [key]: { name: clipSearch.cleanTagName(name), shared: !!shared, at } });
|
|
6806
|
+
return shared !== has;
|
|
6807
|
+
}
|
|
6808
|
+
|
|
6809
|
+
// A share switch takes one existing tag, never typed input: splitting "a, b" here
|
|
6810
|
+
// would share two tags at once.
|
|
6811
|
+
function setGroupSharedWithAi(name, shared) {
|
|
6812
|
+
const key = clipSearch.tagKey(name);
|
|
6813
|
+
const stored = (settings.groups || []).find(g => clipSearch.tagKey(g) === key) || (clipSearch.sameTag(name, mcpCore.AI_GROUP_NAME) ? mcpCore.AI_GROUP_NAME : '');
|
|
6814
|
+
if (!stored) return;
|
|
6815
|
+
stampShareSwitch(stored, shared);
|
|
6455
6816
|
saveSettingsFile();
|
|
6456
6817
|
notifyAiAccessChanged();
|
|
6457
6818
|
}
|
|
6458
6819
|
|
|
6820
|
+
let aiAccessNotifyTimer = null;
|
|
6821
|
+
function notifyAiAccessChangedSoon() {
|
|
6822
|
+
if (aiAccessNotifyTimer) return;
|
|
6823
|
+
aiAccessNotifyTimer = setTimeout(() => { aiAccessNotifyTimer = null; notifyAiAccessChanged(); }, 50);
|
|
6824
|
+
if (aiAccessNotifyTimer.unref) aiAccessNotifyTimer.unref();
|
|
6825
|
+
}
|
|
6826
|
+
|
|
6459
6827
|
function isItemSharedWithAi(item) {
|
|
6460
6828
|
return item ? mcpCore.isShared(item, mcpCore.sharedGroupSet(settings)) : false;
|
|
6461
6829
|
}
|
|
@@ -6587,6 +6955,36 @@ function requestApproval(request, { signal } = {}) {
|
|
|
6587
6955
|
});
|
|
6588
6956
|
}
|
|
6589
6957
|
|
|
6958
|
+
// ---- AI tag tools: the planned change, read by both the gate and the prompt ----
|
|
6959
|
+
// The clip an add_clip lands on when its text is already in history: the add moves
|
|
6960
|
+
// that clip to the top and tags it (applyAddText), it makes no second one.
|
|
6961
|
+
function mcpAddTarget(text) {
|
|
6962
|
+
const value = String(text || '');
|
|
6963
|
+
if (!value) return null;
|
|
6964
|
+
return history.find(it => it.type !== 'image' && it.text === value)
|
|
6965
|
+
|| findHistoryItem(clipboardModel.itemKey({ type: 'text', text: value }));
|
|
6966
|
+
}
|
|
6967
|
+
// What an AI tag change does to a clip: assign_group's toggle (planTagToggle) or
|
|
6968
|
+
// add_clip's tags (added only). `after` = the clip's tags once it ran.
|
|
6969
|
+
function plannedTagChange(item, tool, input) {
|
|
6970
|
+
const before = groupsOf(item);
|
|
6971
|
+
if (tool === 'add_clip') {
|
|
6972
|
+
const add = input ? tagInputNames(input).filter(name => !itemHasTag(item, name)) : [];
|
|
6973
|
+
return { add, remove: [], after: [...before, ...add] };
|
|
6974
|
+
}
|
|
6975
|
+
const plan = planTagToggle(item, input);
|
|
6976
|
+
const after = plan.add.length ? [...before, ...plan.add] : before.filter(g => !plan.remove.some(name => clipSearch.sameTag(name, g)));
|
|
6977
|
+
return { ...plan, after };
|
|
6978
|
+
}
|
|
6979
|
+
// Would a clip with these tags be readable by AI?
|
|
6980
|
+
function tagsVisibleToAi(names) {
|
|
6981
|
+
return mcpCore.isShared({ pin: { groups: names } }, mcpCore.sharedGroupSet(settings));
|
|
6982
|
+
}
|
|
6983
|
+
// The tag a name refers to (any case), as delete_group and the prompt see it.
|
|
6984
|
+
function existingTag(name) {
|
|
6985
|
+
return (settings.groups || []).find(g => clipSearch.sameTag(g, name)) || null;
|
|
6986
|
+
}
|
|
6987
|
+
|
|
6590
6988
|
function buildApprovalRequest(tool, args, targetItem, client) {
|
|
6591
6989
|
const dangerTools = new Set(['delete_clip', 'edit_clip', 'copy_to_clipboard', 'paste_clip']);
|
|
6592
6990
|
// The prompt must say, in plain words, WHAT will happen, to WHICH clip, what
|
|
@@ -6600,7 +6998,41 @@ function buildApprovalRequest(tool, args, targetItem, client) {
|
|
|
6600
6998
|
const shared = item ? isItemSharedWithAi(item) : false;
|
|
6601
6999
|
const quote = s => `\u201c${String(s || '').replace(/\s+/g, ' ').trim().slice(0, 60)}\u201d`;
|
|
6602
7000
|
const clipLabel = item ? (titleOf(item) ? quote(titleOf(item)) : (item.type === 'image' ? 'an image clip' : quote(String(item.text || '').split(/\r?\n/).find(l => l.trim()) || ''))) : '';
|
|
7001
|
+
// Group names in a sentence: “a”, “b” and “c”.
|
|
7002
|
+
const nameList = names => {
|
|
7003
|
+
const q = names.map(quote);
|
|
7004
|
+
return q.length < 2 ? (q[0] || '') : `${q.slice(0, -1).join(', ')} and ${q[q.length - 1]}`;
|
|
7005
|
+
};
|
|
7006
|
+
const tagWord = names => (names.length === 1 ? 'group' : 'groups');
|
|
7007
|
+
const tagsText = names => (names.length ? names.join(', ') : 'none');
|
|
7008
|
+
// A retired nested name in the input says what it now means.
|
|
7009
|
+
const splits = clipboardModel.splitMapOf(settings);
|
|
7010
|
+
const retiredNote = input => clipSearch.splitTagInput(input)
|
|
7011
|
+
.filter(name => !existingTag(name) && splits.has(clipSearch.tagKey(name)))
|
|
7012
|
+
.map(name => {
|
|
7013
|
+
const parts = splits.get(clipSearch.tagKey(name));
|
|
7014
|
+
return `${quote(name)} is an old nested name for the ${tagWord(parts)} ${nameList(parts)}.`;
|
|
7015
|
+
})
|
|
7016
|
+
.join(' ');
|
|
7017
|
+
// What the change does to what AI assistants can read.
|
|
7018
|
+
const sharedSet = mcpCore.sharedGroupSet(settings);
|
|
7019
|
+
const sharingNote = (before, after) => {
|
|
7020
|
+
const was = tagsVisibleToAi(before);
|
|
7021
|
+
const will = tagsVisibleToAi(after);
|
|
7022
|
+
if (!was && will) {
|
|
7023
|
+
const via = after.filter(g => sharedSet.has(g));
|
|
7024
|
+
return `AI assistants will then be able to read this clip, because ${nameList(via)} ${via.length === 1 ? 'is' : 'are'} shared with AI.`;
|
|
7025
|
+
}
|
|
7026
|
+
if (was && !will) return 'AI assistants will then no longer be able to read this clip: none of its groups will be shared with AI.';
|
|
7027
|
+
return '';
|
|
7028
|
+
};
|
|
7029
|
+
const sentences = (...parts) => parts.filter(Boolean).join(' ');
|
|
6603
7030
|
const facts = [];
|
|
7031
|
+
// The Groups fact shows the change: before -> after.
|
|
7032
|
+
const tagFact = (before, after) => {
|
|
7033
|
+
const row = facts.find(f => f[0] === 'Groups');
|
|
7034
|
+
if (row) row[1] = `${tagsText(before)} \u2192 ${tagsText(after)}`;
|
|
7035
|
+
};
|
|
6604
7036
|
let preview = null;
|
|
6605
7037
|
if (item) {
|
|
6606
7038
|
textBlobStore.hydrateTextItem(item, TEXT_DIR);
|
|
@@ -6608,25 +7040,37 @@ function buildApprovalRequest(tool, args, targetItem, client) {
|
|
|
6608
7040
|
const lines = text ? text.split(/\r?\n/).length : 0;
|
|
6609
7041
|
facts.push(['Clip', item.type === 'image' ? `image ${item.width || '?'}x${item.height || '?'}` : `${text.length} chars, ${lines} line${lines === 1 ? '' : 's'}`]);
|
|
6610
7042
|
if (item.ts) facts.push(['Captured', new Date(item.ts * 1000).toLocaleString()]);
|
|
6611
|
-
facts.push(['Groups', groups
|
|
7043
|
+
facts.push(['Groups', tagsText(groups)]);
|
|
6612
7044
|
if (item.pin && typeof item.pin.number === 'number') facts.push(['Numpad', `slot ${item.pin.number}`]);
|
|
6613
7045
|
if (item.type !== 'image') preview = { label: 'Clip text', text };
|
|
6614
7046
|
}
|
|
6615
7047
|
let title = '';
|
|
6616
7048
|
let explain = '';
|
|
6617
7049
|
let why = '';
|
|
6618
|
-
const gatedWhy = 'This action always asks, whatever
|
|
7050
|
+
const gatedWhy = 'This action always asks, whatever groups the clip is in.';
|
|
7051
|
+
const privateWhy = 'Asking because this clip is in no group shared with AI.';
|
|
6619
7052
|
const sharedWhy = shared
|
|
6620
7053
|
? 'The clip is in a group shared with AI; asking because you have not allowed this action for the session.'
|
|
6621
|
-
:
|
|
7054
|
+
: privateWhy;
|
|
6622
7055
|
switch (tool) {
|
|
6623
7056
|
case 'assign_group': {
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
|
|
6629
|
-
|
|
7057
|
+
// main's own toggle rule (planTagToggle): the named tags it lacks are
|
|
7058
|
+
// added; if it has them all, they are all removed.
|
|
7059
|
+
const plan = item ? plannedTagChange(item, tool, a.group) : { add: [], remove: [], after: groups };
|
|
7060
|
+
const already = plan.add.length ? tagInputNames(a.group).filter(name => itemHasTag(item, name)) : [];
|
|
7061
|
+
const unchanged = 'Its text is not changed and nothing is deleted.';
|
|
7062
|
+
if (plan.add.length) {
|
|
7063
|
+
title = `Add clip to ${tagWord(plan.add)} ${nameList(plan.add)}${already.length ? ` (it is already in ${nameList(already)})` : ''}`;
|
|
7064
|
+
explain = sentences(`The clip ${clipLabel} will be added to ${tagWord(plan.add)} ${nameList(plan.add)}.`, unchanged);
|
|
7065
|
+
} else if (plan.remove.length) {
|
|
7066
|
+
title = `Remove clip from ${tagWord(plan.remove)} ${nameList(plan.remove)}`;
|
|
7067
|
+
explain = sentences(`The clip ${clipLabel} will leave the ${tagWord(plan.remove)} ${nameList(plan.remove)}.`, unchanged);
|
|
7068
|
+
} else {
|
|
7069
|
+
title = "Change a clip's groups";
|
|
7070
|
+
explain = 'No group was named, so nothing will change.';
|
|
7071
|
+
}
|
|
7072
|
+
explain = sentences(explain, retiredNote(a.group), sharingNote(groups, plan.after));
|
|
7073
|
+
tagFact(groups, plan.after);
|
|
6630
7074
|
why = sharedWhy;
|
|
6631
7075
|
break;
|
|
6632
7076
|
}
|
|
@@ -6665,22 +7109,62 @@ function buildApprovalRequest(tool, args, targetItem, client) {
|
|
|
6665
7109
|
why = gatedWhy;
|
|
6666
7110
|
break;
|
|
6667
7111
|
}
|
|
6668
|
-
case 'add_clip':
|
|
6669
|
-
|
|
6670
|
-
|
|
6671
|
-
|
|
6672
|
-
|
|
7112
|
+
case 'add_clip': {
|
|
7113
|
+
const sessionWhy = 'Asking because you have not allowed clip creation for the session.';
|
|
7114
|
+
if (item) {
|
|
7115
|
+
// The text is already a clip: the add moves and tags that clip.
|
|
7116
|
+
const plan = plannedTagChange(item, tool, a.group);
|
|
7117
|
+
title = plan.add.length ? `Add an existing clip to ${tagWord(plan.add)} ${nameList(plan.add)}` : 'Move an existing clip to the top';
|
|
7118
|
+
explain = sentences(
|
|
7119
|
+
`This text is already in your history as the clip ${clipLabel}, so no new clip is made: that clip moves to the top${plan.add.length ? ` and is added to ${tagWord(plan.add)} ${nameList(plan.add)}` : ''}.`,
|
|
7120
|
+
retiredNote(a.group),
|
|
7121
|
+
sharingNote(groups, plan.after),
|
|
7122
|
+
);
|
|
7123
|
+
tagFact(groups, plan.after);
|
|
7124
|
+
if (!tagsVisibleToAi(groups) && tagsVisibleToAi(plan.after)) why = 'Asking because this would let the AI read a clip it cannot read now.';
|
|
7125
|
+
else if (!shared) why = 'Asking because this text is already a clip the AI cannot read.';
|
|
7126
|
+
else why = sessionWhy;
|
|
7127
|
+
} else {
|
|
7128
|
+
const names = a.group ? tagInputNames(a.group) : [];
|
|
7129
|
+
title = 'Add a new clip';
|
|
7130
|
+
explain = sentences(`A new clip of ${String(a.text || '').length} characters will be added to your history${names.length ? `, in the ${tagWord(names)} ${nameList(names)}` : ''}. Nothing existing is changed.`, retiredNote(a.group));
|
|
7131
|
+
why = sessionWhy;
|
|
7132
|
+
preview = { label: 'New clip text', text: String(a.text || '') };
|
|
7133
|
+
}
|
|
6673
7134
|
break;
|
|
6674
|
-
|
|
6675
|
-
|
|
6676
|
-
|
|
6677
|
-
|
|
7135
|
+
}
|
|
7136
|
+
case 'create_group': {
|
|
7137
|
+
const names = tagInputNames(a.name);
|
|
7138
|
+
const fresh = names.filter(name => !existingTag(name));
|
|
7139
|
+
const have = names.filter(name => existingTag(name));
|
|
7140
|
+
title = names.length ? `Create ${tagWord(names)} ${nameList(names)}` : 'Create a group';
|
|
7141
|
+
explain = sentences(
|
|
7142
|
+
fresh.length ? `${fresh.length === 1 ? 'A new, empty group' : 'New, empty groups'} ${nameList(fresh)} will appear in your filters.` : '',
|
|
7143
|
+
have.length ? `${nameList(have)} already ${have.length === 1 ? 'exists' : 'exist'}, so nothing changes for ${have.length === 1 ? 'it' : 'them'}.` : '',
|
|
7144
|
+
names.length ? retiredNote(a.name) : 'No group was named, so nothing will change.',
|
|
7145
|
+
);
|
|
7146
|
+
why = 'Asking because you have not allowed creating groups for the session.';
|
|
6678
7147
|
break;
|
|
6679
|
-
|
|
6680
|
-
|
|
6681
|
-
|
|
6682
|
-
|
|
7148
|
+
}
|
|
7149
|
+
case 'delete_group': {
|
|
7150
|
+
// One group by its full name, any case; never split (this is destructive).
|
|
7151
|
+
const tag = existingTag(a.name);
|
|
7152
|
+
const tagged = tag ? history.filter(h => itemHasTag(h, tag)) : [];
|
|
7153
|
+
const hidden = tag && sharedSet.has(tag)
|
|
7154
|
+
? tagged.filter(h => !tagsVisibleToAi(groupsOf(h).filter(g => !clipSearch.sameTag(g, tag)))).length
|
|
7155
|
+
: 0;
|
|
7156
|
+
title = `Delete group ${quote(tag || a.name)}`;
|
|
7157
|
+
explain = tag
|
|
7158
|
+
? sentences(
|
|
7159
|
+
tagged.length
|
|
7160
|
+
? `The group ${quote(tag)} will be removed from ${tagged.length === 1 ? '1 clip' : `${tagged.length} clips`} and from your filters. ${tagged.length === 1 ? 'The clip stays' : 'The clips stay'} in your history.`
|
|
7161
|
+
: `The group ${quote(tag)} will be removed from your filters. No clip is in it.`,
|
|
7162
|
+
hidden ? `AI assistants will no longer be able to read ${hidden === tagged.length ? (hidden === 1 ? 'it' : 'them') : `${hidden} of them`}.` : '',
|
|
7163
|
+
)
|
|
7164
|
+
: `There is no group named ${quote(a.name)}, so nothing will change.`;
|
|
7165
|
+
why = tag && !sharedSet.has(tag) ? 'Asking because this group is not shared with AI.' : 'Asking because you have not allowed deleting groups for the session.';
|
|
6683
7166
|
break;
|
|
7167
|
+
}
|
|
6684
7168
|
case 'copy_to_clipboard':
|
|
6685
7169
|
title = 'Put text on your clipboard';
|
|
6686
7170
|
explain = `${a.text != null ? `${String(a.text).length} characters of AI-provided text` : `The clip ${clipLabel}`} will replace whatever is on your system clipboard right now.`;
|
|
@@ -6695,11 +7179,11 @@ function buildApprovalRequest(tool, args, targetItem, client) {
|
|
|
6695
7179
|
case 'read_clip':
|
|
6696
7180
|
title = 'Let the AI read a private clip';
|
|
6697
7181
|
explain = `The full text of the clip ${clipLabel} will be sent to the AI. Nothing in your history changes.`;
|
|
6698
|
-
why =
|
|
7182
|
+
why = privateWhy;
|
|
6699
7183
|
break;
|
|
6700
7184
|
case 'search_all':
|
|
6701
7185
|
title = 'Search all clips, private ones included';
|
|
6702
|
-
explain = `Matching clips (title and a short preview) from your ENTIRE history, including
|
|
7186
|
+
explain = `Matching clips (title and a short preview) from your ENTIRE history, including clips in no group shared with AI, will be sent to the AI. Nothing changes.`;
|
|
6703
7187
|
why = gatedWhy;
|
|
6704
7188
|
preview = { label: 'Query', text: String(a.query || '') };
|
|
6705
7189
|
break;
|
|
@@ -6742,32 +7226,97 @@ function previewForItem(item) {
|
|
|
6742
7226
|
// like delete_clip rather than being free-on-shared. Users can still grant a
|
|
6743
7227
|
// per-tool "always allow" from the modal to make repeated edits frictionless.
|
|
6744
7228
|
const MCP_ALWAYS_GATED = new Set(['delete_clip', 'edit_clip', 'copy_to_clipboard', 'paste_clip', 'read_clip', 'search_all', 'image_path']);
|
|
7229
|
+
// Free on what the AI can already read, asked on anything else.
|
|
6745
7230
|
const MCP_MANAGE_FREE_ON_SHARED = new Set(['pin_clip', 'set_numpad', 'assign_group', 'create_group', 'delete_group', 'add_clip']);
|
|
6746
7231
|
|
|
6747
|
-
function mcpNeedsApproval(tool, targetItem) {
|
|
7232
|
+
function mcpNeedsApproval(tool, targetItem, args = {}) {
|
|
6748
7233
|
if (mcpSessionAllow.has(tool)) return false;
|
|
6749
7234
|
if (Array.isArray(settings.ai_always_allow) && settings.ai_always_allow.includes(tool)) return false;
|
|
6750
7235
|
if (MCP_ALWAYS_GATED.has(tool)) return true;
|
|
6751
7236
|
if (MCP_MANAGE_FREE_ON_SHARED.has(tool)) {
|
|
6752
|
-
|
|
7237
|
+
// Naming a group not shared with AI stays free (agents file into private groups,
|
|
7238
|
+
// e.g. "lawyer notes"): the answer echoes such a name as typed, the same for an
|
|
7239
|
+
// existing private group as for a brand-new one (mcpCore.groupToolView), so a free
|
|
7240
|
+
// answer tells nothing, while a prompt only for existing ones would.
|
|
7241
|
+
// add_clip's target is the clip that already holds its text (mcpAddTarget): the
|
|
7242
|
+
// add moves (and may tag) that clip, so it is free only on a clip the AI can
|
|
7243
|
+
// read, as every other change is. A free add onto a private clip would also
|
|
7244
|
+
// tell the AI whether a guessed text is in the history (no new clip is made).
|
|
7245
|
+
if (tool === 'add_clip') return !!targetItem && !isItemSharedWithAi(targetItem);
|
|
7246
|
+
// A group not shared with AI holds clips the AI cannot read: deleting it asks.
|
|
7247
|
+
if (tool === 'delete_group') {
|
|
7248
|
+
const tag = existingTag(args.name);
|
|
7249
|
+
return !!tag && !mcpCore.sharedGroupSet(settings).has(tag);
|
|
7250
|
+
}
|
|
7251
|
+
if (!targetItem) return false; // create_group
|
|
6753
7252
|
return !isItemSharedWithAi(targetItem); // free on shared clips, gated on non-shared
|
|
6754
7253
|
}
|
|
6755
7254
|
return true;
|
|
6756
7255
|
}
|
|
6757
7256
|
|
|
7257
|
+
// ---- The AI's ids and revs (the lib/mcp-core boundary) ----
|
|
7258
|
+
// An AI names a clip it can read by its real id and any other by the opaque handle it
|
|
7259
|
+
// was given, and every rev it holds is keyed. mcpResolveArgs turns them into the
|
|
7260
|
+
// app's own before anything runs (or any prompt opens), refusing in the AI's own
|
|
7261
|
+
// terms an id it may not address or a rev that moved; the apply* primitives then
|
|
7262
|
+
// check the real rev again after a (possibly long) approval.
|
|
7263
|
+
const MCP_ID_TOOLS = new Set([...MCP_REV_GUARDED_TOOLS, 'read_clip', 'image_path', 'copy_to_clipboard', 'paste_clip']);
|
|
7264
|
+
function mcpConflict(code, details) {
|
|
7265
|
+
return new clipRevisionCheck.RevisionConflict(code, details);
|
|
7266
|
+
}
|
|
7267
|
+
function mcpResolveArgs(tool, args) {
|
|
7268
|
+
if (!MCP_ID_TOOLS.has(tool) || (tool === 'copy_to_clipboard' && args.text != null)) return { args, item: null };
|
|
7269
|
+
const id = String(args.id == null ? '' : args.id);
|
|
7270
|
+
const found = mcpCore.resolvePublicId(history, settings, id);
|
|
7271
|
+
if (MCP_REV_GUARDED_TOOLS.has(tool)) {
|
|
7272
|
+
const rev = typeof args.expected_rev === 'string' ? args.expected_rev.trim() : '';
|
|
7273
|
+
if (!rev) throw mcpConflict('rev_required', { id });
|
|
7274
|
+
if (!found.item) throw found.movedTo ? mcpConflict('superseded', { id, currentId: found.movedTo }) : mcpConflict('not_found', { id });
|
|
7275
|
+
const currentRev = mcpCore.publicRev(found.item, settings);
|
|
7276
|
+
if (rev !== currentRev) throw mcpConflict('stale_revision', { id, expectedRev: rev, currentRev });
|
|
7277
|
+
return { args: { ...args, id: found.realId, expected_rev: clipboardModel.clipRevision(found.item) }, item: found.item };
|
|
7278
|
+
}
|
|
7279
|
+
if (!found.item) throw new Error(found.movedTo ? `Clip ${id} was edited and is now clip ${found.movedTo}.` : `No clip with id "${id}".`);
|
|
7280
|
+
return { args: { ...args, id: found.realId }, item: found.item };
|
|
7281
|
+
}
|
|
7282
|
+
// A conflict the apply* primitives raised after an approval, in the AI's terms.
|
|
7283
|
+
function mcpPublicError(err, publicArgs) {
|
|
7284
|
+
if (!clipRevisionCheck.isRevisionConflict(err)) return err;
|
|
7285
|
+
const d = err.details || {};
|
|
7286
|
+
const id = String(publicArgs.id == null ? '' : publicArgs.id);
|
|
7287
|
+
if (err.code === 'stale_revision') {
|
|
7288
|
+
const item = findHistoryItem(d.id);
|
|
7289
|
+
return mcpConflict('stale_revision', { id, expectedRev: publicArgs.expected_rev, currentRev: item ? mcpCore.publicRev(item, settings) : null });
|
|
7290
|
+
}
|
|
7291
|
+
if (err.code === 'superseded') {
|
|
7292
|
+
const current = findHistoryItem(d.currentId);
|
|
7293
|
+
return current ? mcpConflict('superseded', { id, currentId: mcpCore.publicId(current, settings) }) : mcpConflict('not_found', { id });
|
|
7294
|
+
}
|
|
7295
|
+
return mcpConflict(err.code, { id });
|
|
7296
|
+
}
|
|
7297
|
+
|
|
6758
7298
|
async function mcpHandleRequest(reqPath, payload, ctx = {}) {
|
|
6759
|
-
const { tool, args = {}, client } = payload || {};
|
|
7299
|
+
const { tool, args: publicArgs = {}, client } = payload || {};
|
|
6760
7300
|
if (!tool) throw new Error('missing_tool');
|
|
6761
7301
|
if (!settings.ai_access_enabled) throw new Error('ai_access_disabled');
|
|
6762
|
-
|
|
6763
|
-
|
|
6764
|
-
|
|
6765
|
-
|
|
6766
|
-
|
|
6767
|
-
|
|
7302
|
+
// The AI group is the one every assistant can always read: never deleted (the
|
|
7303
|
+
// app's own UI cannot delete it either), and no prompt asks about it.
|
|
7304
|
+
if (tool === 'delete_group' && clipSearch.sameTag(publicArgs.name, mcpCore.AI_GROUP_NAME)) {
|
|
7305
|
+
diagnostics.record('mcp.tool', { tool, client, decision: 'refused' }, { forceFile: true });
|
|
7306
|
+
return { ok: false, reason: `The "${mcpCore.AI_GROUP_NAME}" group cannot be deleted: it is the group every AI assistant can always read. To take a clip out of it, use assign_group.` };
|
|
7307
|
+
}
|
|
7308
|
+
// Refuse a stale/missing rev (or an id the AI may not name) BEFORE asking the
|
|
7309
|
+
// human to approve a change that would be refused anyway.
|
|
7310
|
+
const { args, item: resolvedItem } = mcpResolveArgs(tool, publicArgs);
|
|
7311
|
+
// The app's own check on the real id and rev, as every caller's (mcpExecute's
|
|
7312
|
+
// primitives repeat it after the approval, since the clip can change meanwhile).
|
|
7313
|
+
if (MCP_REV_GUARDED_TOOLS.has(tool)) {
|
|
7314
|
+
try { assertClipRevision(args.id, args.expected_rev); } catch (err) { throw mcpPublicError(err, publicArgs); }
|
|
7315
|
+
}
|
|
7316
|
+
const targetItem = tool === 'add_clip' ? mcpAddTarget(args.text) : resolvedItem;
|
|
6768
7317
|
|
|
6769
7318
|
let decision = 'auto';
|
|
6770
|
-
if (mcpNeedsApproval(tool, targetItem)) {
|
|
7319
|
+
if (mcpNeedsApproval(tool, targetItem, args)) {
|
|
6771
7320
|
decision = await requestApproval(buildApprovalRequest(tool, args, targetItem, client), { signal: ctx.signal });
|
|
6772
7321
|
if (decision === 'deny') throw new Error('denied');
|
|
6773
7322
|
if (decision === 'timeout') throw new Error('timed_out');
|
|
@@ -6783,18 +7332,23 @@ async function mcpHandleRequest(reqPath, payload, ctx = {}) {
|
|
|
6783
7332
|
// Re-check after the (possibly long) approval await: if AI access was toggled
|
|
6784
7333
|
// off while the modal was open, refuse before performing the action.
|
|
6785
7334
|
if (!settings.ai_access_enabled) throw new Error('ai_access_disabled');
|
|
6786
|
-
|
|
7335
|
+
try {
|
|
7336
|
+
return await mcpExecute(tool, args, ctx, publicArgs);
|
|
7337
|
+
} catch (err) {
|
|
7338
|
+
throw mcpPublicError(err, publicArgs);
|
|
7339
|
+
}
|
|
6787
7340
|
}
|
|
6788
7341
|
|
|
6789
|
-
|
|
7342
|
+
// `args` are the app's own (real ids, real revs, mcpResolveArgs); `publicArgs` what
|
|
7343
|
+
// the AI sent. Every result goes back in the AI's terms (lib/mcp-core boundary).
|
|
7344
|
+
function mcpExecute(tool, args, ctx = {}, publicArgs = args) {
|
|
6790
7345
|
switch (tool) {
|
|
6791
7346
|
case 'read_clip': {
|
|
6792
7347
|
const item = findHistoryItem(args.id);
|
|
6793
7348
|
if (!item) throw new Error('not_found');
|
|
6794
|
-
|
|
6795
|
-
if (item.type === 'image') return mcpCore.clipView(item, { sharedSet });
|
|
7349
|
+
if (item.type === 'image') return mcpCore.clipView(item, { settings });
|
|
6796
7350
|
textBlobStore.hydrateTextItem(item, TEXT_DIR);
|
|
6797
|
-
return { ...mcpCore.fullTextResult(item,
|
|
7351
|
+
return { ...mcpCore.fullTextResult(item, { settings }), viaApproval: true };
|
|
6798
7352
|
}
|
|
6799
7353
|
case 'search_all':
|
|
6800
7354
|
// A /regex/ runs on a worker thread: it must never hang this process.
|
|
@@ -6818,20 +7372,46 @@ function mcpExecute(tool, args, ctx = {}) {
|
|
|
6818
7372
|
newTitle: args.title != null ? String(args.title) : undefined,
|
|
6819
7373
|
});
|
|
6820
7374
|
const saved = result && result.item ? result.item : item;
|
|
6821
|
-
return { ok: !!(result && result.changed), reason: result && result.reason, id:
|
|
7375
|
+
return { ok: !!(result && result.changed), reason: result && result.reason, id: mcpCore.publicId(saved, settings), rev: mcpCore.publicRev(saved, settings) };
|
|
6822
7376
|
}
|
|
6823
7377
|
case 'pin_clip':
|
|
6824
7378
|
return mcpMutationResult(args.id, applyPinToggle(args.id, args.expected_rev));
|
|
6825
7379
|
case 'set_numpad':
|
|
6826
7380
|
return mcpMutationResult(args.id, applyNumpadAssign(args.id, args.slot, args.expected_rev));
|
|
6827
|
-
case 'assign_group':
|
|
6828
|
-
|
|
6829
|
-
|
|
6830
|
-
|
|
6831
|
-
|
|
6832
|
-
|
|
7381
|
+
case 'assign_group': {
|
|
7382
|
+
// What the AI typed, read before the change (the names it means may be created).
|
|
7383
|
+
// The answer names only groups shared with AI (mcpCore.groupToolView): whether a
|
|
7384
|
+
// private group existed, or was on the clip, is never in it.
|
|
7385
|
+
const tokens = mcpCore.namedGroupTokens(args.group, settings);
|
|
7386
|
+
const result = applyGroupAssign(args.id, args.group, args.expected_rev);
|
|
7387
|
+
const item = findHistoryItem(args.id);
|
|
7388
|
+
const view = mcpCore.groupToolView(tokens, settings, { added: result ? result.added : [], removed: result ? result.removed : [] });
|
|
7389
|
+
const out = {
|
|
7390
|
+
...mcpMutationResult(args.id, result),
|
|
7391
|
+
added: view.added,
|
|
7392
|
+
removed: view.removed,
|
|
7393
|
+
groups: item ? mcpCore.visibleGroups(item, mcpCore.sharedGroupSet(settings)) : [],
|
|
7394
|
+
...(view.notShared ? { notShared: view.notShared } : {}),
|
|
7395
|
+
};
|
|
7396
|
+
if (!result) out.reason = 'No group was named, so nothing changed.';
|
|
7397
|
+
return out;
|
|
7398
|
+
}
|
|
7399
|
+
case 'create_group': {
|
|
7400
|
+
const tokens = mcpCore.namedGroupTokens(args.name, settings);
|
|
7401
|
+
const created = applyGroupCreate(args.name);
|
|
7402
|
+
if (!created) return { ok: false, reason: 'No group was named, so nothing changed.' };
|
|
7403
|
+
return { ok: true, ...mcpCore.groupToolView(tokens, settings, { groups: created }) };
|
|
7404
|
+
}
|
|
7405
|
+
case 'delete_group': {
|
|
7406
|
+
// One group by its full name (any case), never split: a comma or a '/' is part of it.
|
|
7407
|
+
const shared = mcpCore.sharedGroupSet(settings).has(args.name);
|
|
7408
|
+
const deleted = applyGroupDelete(args.name);
|
|
7409
|
+
if (!deleted) return { ok: false, reason: `No group is named "${String(args.name || '')}", so nothing changed. delete_group takes one group by its full name.` };
|
|
7410
|
+
// A group not shared with AI: only the name as the AI typed it, nothing about its clips.
|
|
7411
|
+
return shared ? { ok: true, name: deleted.name, clips: deleted.ids.length } : { ok: true, name: clipSearch.cleanTagName(args.name) };
|
|
7412
|
+
}
|
|
6833
7413
|
case 'delete_clip':
|
|
6834
|
-
return { ok: applyDeleteItem(args.id, args.expected_rev), id:
|
|
7414
|
+
return { ok: applyDeleteItem(args.id, args.expected_rev), id: String(publicArgs.id) };
|
|
6835
7415
|
case 'copy_to_clipboard': {
|
|
6836
7416
|
if (args.text != null) { clipboard.writeText(String(args.text)); return { ok: true }; }
|
|
6837
7417
|
const item = findHistoryItem(args.id);
|
|
@@ -6899,6 +7479,9 @@ function setupIPC() {
|
|
|
6899
7479
|
quickPaste: effectiveQuickPasteShortcut(),
|
|
6900
7480
|
quickPasteCustom: !!settings.quick_paste_shortcut,
|
|
6901
7481
|
quickPasteDefault: defaultQuickPasteShortcut(),
|
|
7482
|
+
newNote: effectiveNewNoteShortcut(),
|
|
7483
|
+
newNoteCustom: !!settings.new_note_shortcut,
|
|
7484
|
+
newNoteDefault: defaultNewNoteShortcut(),
|
|
6902
7485
|
windows_hook: process.platform === 'win32',
|
|
6903
7486
|
},
|
|
6904
7487
|
}));
|
|
@@ -7025,6 +7608,7 @@ function setupIPC() {
|
|
|
7025
7608
|
|
|
7026
7609
|
ipcMain.handle('set-show-shortcut', (_, shortcut) => setShowShortcut(shortcut));
|
|
7027
7610
|
ipcMain.handle('set-quick-paste-shortcut', (_, shortcut) => setQuickPasteShortcut(shortcut));
|
|
7611
|
+
ipcMain.handle('set-new-note-shortcut', (_, shortcut) => setNewNoteShortcut(shortcut));
|
|
7028
7612
|
ipcMain.handle('suspend-shortcuts', () => suspendShortcutsForRecording());
|
|
7029
7613
|
ipcMain.handle('resume-shortcuts', () => resumeShortcutsAfterRecording());
|
|
7030
7614
|
ipcMain.handle('resolve-show-shortcut', (_, shortcut) => {
|
|
@@ -7035,6 +7619,7 @@ function setupIPC() {
|
|
|
7035
7619
|
ipcMain.handle('group-create', (_, name) => applyGroupCreate(name));
|
|
7036
7620
|
|
|
7037
7621
|
ipcMain.handle('group-delete', (_, name) => applyGroupDelete(name));
|
|
7622
|
+
ipcMain.handle('group-restore', (_, payload) => applyGroupRestore(payload));
|
|
7038
7623
|
|
|
7039
7624
|
ipcMain.handle('group-assign', (_, id, group, rev) => applyGroupAssign(id, group, rev));
|
|
7040
7625
|
|
|
@@ -7358,56 +7943,49 @@ async function handleQuickPaste(slot) {
|
|
|
7358
7943
|
await runNumpadSlotAction(slot, { source: 'shortcut' });
|
|
7359
7944
|
}
|
|
7360
7945
|
|
|
7361
|
-
|
|
7362
|
-
|
|
7363
|
-
|
|
7364
|
-
|
|
7365
|
-
|
|
7946
|
+
// The global new-note shortcut (Win+N through the hook, or a recorded chord):
|
|
7947
|
+
// the popup's New note action, as a hand-off.
|
|
7948
|
+
function handleNewNoteShortcut() {
|
|
7949
|
+
diagnostics.record('shortcut.new_note', { popup_open: isPopupOpen() });
|
|
7950
|
+
if (process.platform === 'darwin') app.focus({ steal: true }); // an accessory app is not active
|
|
7951
|
+
openEditor(null, {});
|
|
7952
|
+
}
|
|
7953
|
+
|
|
7954
|
+
// Why a recorded chord cannot be saved ('' = it can). Only the popup
|
|
7955
|
+
// shortcut takes Globe (macOS's own hotkey path); `key` names what follows
|
|
7956
|
+
// the modifiers in the hint.
|
|
7957
|
+
function recordedShortcutError(shortcut, { globe = false, key = 'a key' } = {}) {
|
|
7958
|
+
if (shortcutUsesFn(shortcut)) {
|
|
7959
|
+
if (!globe) return 'Globe shortcuts are only supported for the popup shortcut.';
|
|
7960
|
+
if (process.platform !== 'darwin') return 'Globe shortcuts are only supported on macOS.';
|
|
7366
7961
|
}
|
|
7367
|
-
if (
|
|
7962
|
+
if (!shortcutHasKeyAndModifier(shortcut)) {
|
|
7368
7963
|
const modifiers = process.platform === 'darwin'
|
|
7369
|
-
? 'Command, Control, Option, or Globe'
|
|
7964
|
+
? (globe ? 'Command, Control, Option, or Globe' : 'Command, Control, or Option')
|
|
7370
7965
|
: 'Command, Control, Alt, or Win';
|
|
7371
|
-
return
|
|
7372
|
-
}
|
|
7373
|
-
|
|
7374
|
-
settings.show_shortcut = next;
|
|
7375
|
-
const result = registerShortcuts();
|
|
7376
|
-
if (!result.showShortcutRegistered) {
|
|
7377
|
-
settings.show_shortcut = previous;
|
|
7378
|
-
registerShortcuts();
|
|
7379
|
-
return { ok: false, error: 'Shortcut is already in use or not supported.' };
|
|
7966
|
+
return `Use ${modifiers} with ${key}.`;
|
|
7380
7967
|
}
|
|
7968
|
+
return '';
|
|
7969
|
+
}
|
|
7381
7970
|
|
|
7382
|
-
|
|
7971
|
+
function setShowShortcut(shortcut) {
|
|
7972
|
+
const next = normalizeShowShortcut(shortcut);
|
|
7973
|
+
const invalid = next && recordedShortcutError(next, { globe: true });
|
|
7974
|
+
if (invalid) return { ok: false, error: invalid };
|
|
7975
|
+
const saved = commitShortcutSetting('show_shortcut', next, 'showShortcutRegistered', 'Shortcut is already in use or not supported.');
|
|
7976
|
+
if (!saved.ok) return saved;
|
|
7383
7977
|
return { ok: true, shortcut: effectiveShowShortcut(), custom: !!settings.show_shortcut };
|
|
7384
7978
|
}
|
|
7385
7979
|
|
|
7386
7980
|
function setQuickPasteShortcut(shortcut) {
|
|
7387
|
-
const previous = settings.quick_paste_shortcut || '';
|
|
7388
7981
|
const next = normalizeQuickPasteShortcut(shortcut);
|
|
7389
|
-
|
|
7390
|
-
|
|
7391
|
-
}
|
|
7392
|
-
if (next && !shortcutHasKeyAndModifier(next)) {
|
|
7393
|
-
const modifiers = process.platform === 'darwin'
|
|
7394
|
-
? 'Command, Control, or Option'
|
|
7395
|
-
: 'Command, Control, Alt, or Win';
|
|
7396
|
-
return { ok: false, error: `Use ${modifiers} with a number.` };
|
|
7397
|
-
}
|
|
7982
|
+
const invalid = next && recordedShortcutError(next, { key: 'a number' });
|
|
7983
|
+
if (invalid) return { ok: false, error: invalid };
|
|
7398
7984
|
if (next && quickPasteSlotFromShortcut(next) == null) {
|
|
7399
7985
|
return { ok: false, error: 'Press a shortcut ending in 1-9.' };
|
|
7400
7986
|
}
|
|
7401
|
-
|
|
7402
|
-
|
|
7403
|
-
const result = registerShortcuts();
|
|
7404
|
-
if (!result.quickPasteRegistered) {
|
|
7405
|
-
settings.quick_paste_shortcut = previous;
|
|
7406
|
-
registerShortcuts();
|
|
7407
|
-
return { ok: false, error: 'Quick paste shortcut is already in use or not supported.' };
|
|
7408
|
-
}
|
|
7409
|
-
|
|
7410
|
-
saveSettingsFile();
|
|
7987
|
+
const saved = commitShortcutSetting('quick_paste_shortcut', next, 'quickPasteRegistered', 'Quick paste shortcut is already in use or not supported.');
|
|
7988
|
+
if (!saved.ok) return saved;
|
|
7411
7989
|
return {
|
|
7412
7990
|
ok: true,
|
|
7413
7991
|
shortcut: effectiveQuickPasteShortcut(),
|
|
@@ -7415,20 +7993,62 @@ function setQuickPasteShortcut(shortcut) {
|
|
|
7415
7993
|
};
|
|
7416
7994
|
}
|
|
7417
7995
|
|
|
7996
|
+
// '' = the default (Windows: Win+N through the hook), SHORTCUT_OFF = none
|
|
7997
|
+
// (the row's Turn off), else a recorded chord.
|
|
7998
|
+
function setNewNoteShortcut(shortcut) {
|
|
7999
|
+
const next = normalizeNewNoteShortcut(shortcut);
|
|
8000
|
+
const invalid = next && next !== SHORTCUT_OFF && recordedShortcutError(next);
|
|
8001
|
+
if (invalid) return { ok: false, error: invalid };
|
|
8002
|
+
const saved = commitShortcutSetting('new_note_shortcut', next, 'newNoteRegistered', 'New note shortcut is already in use or not supported.');
|
|
8003
|
+
if (!saved.ok) return saved;
|
|
8004
|
+
return { ok: true, shortcut: effectiveNewNoteShortcut(), custom: !!settings.new_note_shortcut };
|
|
8005
|
+
}
|
|
8006
|
+
|
|
8007
|
+
// Save one recorded shortcut: register it, or put the previous one back.
|
|
8008
|
+
// Saving ends the recording, so the registration really happens (while
|
|
8009
|
+
// suspended, registerShortcuts registers nothing and reports success: a
|
|
8010
|
+
// recorded shortcut stayed dead, every other one with it, until a restart).
|
|
8011
|
+
function commitShortcutSetting(key, next, registeredFlag, error) {
|
|
8012
|
+
const previous = settings[key] || '';
|
|
8013
|
+
shortcutsSuspended = false;
|
|
8014
|
+
settings[key] = next;
|
|
8015
|
+
if (!registerShortcuts()[registeredFlag]) {
|
|
8016
|
+
settings[key] = previous;
|
|
8017
|
+
registerShortcuts();
|
|
8018
|
+
return { ok: false, error };
|
|
8019
|
+
}
|
|
8020
|
+
saveSettingsFile();
|
|
8021
|
+
return { ok: true };
|
|
8022
|
+
}
|
|
8023
|
+
|
|
7418
8024
|
function registerShortcuts() {
|
|
8025
|
+
// What each shortcut needs: an OS registration (globalShortcut / Carbon), the
|
|
8026
|
+
// Windows hook, or nothing. A recorded new-note chord is an ordinary global
|
|
8027
|
+
// shortcut; Windows' default (Win+N) is the hook's; off is neither.
|
|
8028
|
+
const macosShowKey = process.platform === 'darwin' ? effectiveShowShortcut() : '';
|
|
8029
|
+
const showKey = process.platform === 'darwin' ? '' : globalShowShortcut();
|
|
8030
|
+
const quickPasteKey = globalQuickPasteShortcut();
|
|
8031
|
+
const recordedNewNote = recordedNewNoteShortcut();
|
|
8032
|
+
const newNoteKey = shortcutUsesFn(recordedNewNote) ? '' : recordedNewNote;
|
|
8033
|
+
const newNoteHookKey = process.platform === 'win32' && !settings.new_note_shortcut;
|
|
8034
|
+
|
|
7419
8035
|
// A throwaway QA instance (BOARDCLIP_ISOLATED=1) never takes the keyboard: its
|
|
7420
8036
|
// low-level hook would sit in front of the real app's, so Win+V would open the
|
|
7421
8037
|
// sandbox and an assigned numpad key would paste sandbox text into the user's
|
|
7422
8038
|
// app through the real clipboard. Windows hook, macOS hotkey and globalShortcut
|
|
7423
|
-
// alike.
|
|
8039
|
+
// alike. A shortcut that needs no OS registration (the hook's, or none) reports
|
|
8040
|
+
// live, as it does for real (it cannot fail), so its row can be saved in QA.
|
|
7424
8041
|
if (process.env.BOARDCLIP_ISOLATED === '1') {
|
|
7425
|
-
|
|
7426
|
-
|
|
7427
|
-
showShortcutRegistered: false,
|
|
8042
|
+
const isolated = {
|
|
8043
|
+
showShortcutRegistered: !(macosShowKey || showKey),
|
|
7428
8044
|
showShortcut: effectiveShowShortcut(),
|
|
7429
|
-
quickPasteRegistered:
|
|
8045
|
+
quickPasteRegistered: !quickPasteKey,
|
|
7430
8046
|
quickPasteShortcut: effectiveQuickPasteShortcut(),
|
|
8047
|
+
newNoteRegistered: !newNoteKey,
|
|
8048
|
+
newNoteShortcut: effectiveNewNoteShortcut(),
|
|
7431
8049
|
};
|
|
8050
|
+
diagnostics.record('shortcut.register', { platform: process.platform, isolated: true, show_registered: isolated.showShortcutRegistered, quick_paste_registered: isolated.quickPasteRegistered, new_note_registered: isolated.newNoteRegistered });
|
|
8051
|
+
return isolated;
|
|
7432
8052
|
}
|
|
7433
8053
|
if (shortcutsSuspended) {
|
|
7434
8054
|
return {
|
|
@@ -7436,15 +8056,15 @@ function registerShortcuts() {
|
|
|
7436
8056
|
showShortcut: effectiveShowShortcut(),
|
|
7437
8057
|
quickPasteRegistered: true,
|
|
7438
8058
|
quickPasteShortcut: effectiveQuickPasteShortcut(),
|
|
8059
|
+
newNoteRegistered: true,
|
|
8060
|
+
newNoteShortcut: effectiveNewNoteShortcut(),
|
|
7439
8061
|
};
|
|
7440
8062
|
}
|
|
7441
8063
|
globalShortcut.unregisterAll();
|
|
7442
8064
|
|
|
7443
8065
|
let showShortcutRegistered = true;
|
|
7444
8066
|
let quickPasteRegistered = true;
|
|
7445
|
-
|
|
7446
|
-
const showKey = process.platform === 'darwin' ? '' : globalShowShortcut();
|
|
7447
|
-
const quickPasteKey = globalQuickPasteShortcut();
|
|
8067
|
+
let newNoteRegistered = true;
|
|
7448
8068
|
|
|
7449
8069
|
if (process.platform === 'win32') {
|
|
7450
8070
|
// Windows Clipboard History owns Win+V and Win+Numpad1-9, so we can't use
|
|
@@ -7455,7 +8075,9 @@ function registerShortcuts() {
|
|
|
7455
8075
|
windowsHook = install({
|
|
7456
8076
|
onShowPopup: showPopup,
|
|
7457
8077
|
onNumpadPaste: handleNumpad,
|
|
8078
|
+
onNewNote: handleNewNoteShortcut,
|
|
7458
8079
|
});
|
|
8080
|
+
windowsHook.setNewNoteHotkey(newNoteHookKey);
|
|
7459
8081
|
// Seed the shared state with current history so plain numpad keys
|
|
7460
8082
|
// immediately intercept for already-assigned slots.
|
|
7461
8083
|
syncHookState();
|
|
@@ -7520,6 +8142,10 @@ function registerShortcuts() {
|
|
|
7520
8142
|
}
|
|
7521
8143
|
}
|
|
7522
8144
|
}
|
|
8145
|
+
if (newNoteKey) {
|
|
8146
|
+
try { newNoteRegistered = globalShortcut.register(newNoteKey, handleNewNoteShortcut); } catch { newNoteRegistered = false; }
|
|
8147
|
+
if (!newNoteRegistered) logSafe(`Warning: Could not register new note shortcut ${newNoteKey}`);
|
|
8148
|
+
}
|
|
7523
8149
|
diagnostics.record('shortcut.register', {
|
|
7524
8150
|
platform: process.platform,
|
|
7525
8151
|
show_key: macosShowKey || showKey,
|
|
@@ -7528,6 +8154,9 @@ function registerShortcuts() {
|
|
|
7528
8154
|
quick_paste_key: quickPasteKey,
|
|
7529
8155
|
quick_paste_registered: quickPasteRegistered,
|
|
7530
8156
|
quick_paste_registrations: quickPasteRegistrations,
|
|
8157
|
+
new_note_key: newNoteKey || (newNoteHookKey ? defaultNewNoteShortcut() : ''),
|
|
8158
|
+
new_note_registered: newNoteRegistered,
|
|
8159
|
+
new_note_backend: newNoteKey ? 'electron' : (newNoteHookKey ? 'hook' : ''),
|
|
7531
8160
|
}, { forceFile: true });
|
|
7532
8161
|
|
|
7533
8162
|
return {
|
|
@@ -7535,12 +8164,16 @@ function registerShortcuts() {
|
|
|
7535
8164
|
showShortcut: effectiveShowShortcut(),
|
|
7536
8165
|
quickPasteRegistered,
|
|
7537
8166
|
quickPasteShortcut: effectiveQuickPasteShortcut(),
|
|
8167
|
+
newNoteRegistered,
|
|
8168
|
+
newNoteShortcut: effectiveNewNoteShortcut(),
|
|
7538
8169
|
};
|
|
7539
8170
|
}
|
|
7540
8171
|
|
|
7541
8172
|
function suspendShortcutsForRecording() {
|
|
7542
8173
|
shortcutsSuspended = true;
|
|
7543
8174
|
globalShortcut.unregisterAll();
|
|
8175
|
+
// The hook's Win+N too: a recording never opens a note (resume re-arms it).
|
|
8176
|
+
if (windowsHook) windowsHook.setNewNoteHotkey(false);
|
|
7544
8177
|
if (macosHotkey) {
|
|
7545
8178
|
macosHotkey.clearRuntimeShortcut();
|
|
7546
8179
|
macosHotkey.clearQuickPasteShortcuts();
|
|
@@ -7632,6 +8265,10 @@ app.whenReady().then(() => {
|
|
|
7632
8265
|
|
|
7633
8266
|
if (process.platform === 'darwin') app.dock.hide();
|
|
7634
8267
|
|
|
8268
|
+
tagMigration = migrateTagsAtLoad();
|
|
8269
|
+
persistTagMigration();
|
|
8270
|
+
const unsplit = unsplitLiteralTags(history, 'load');
|
|
8271
|
+
if (unsplit) history.splice(0, history.length, ...unsplit.history);
|
|
7635
8272
|
migrateNumpad();
|
|
7636
8273
|
recoverOrphanedEdits(); // restore any external edit orphaned by a prior crash/restart
|
|
7637
8274
|
writeHistoryStorageFile();
|
|
@@ -7673,6 +8310,7 @@ app.whenReady().then(() => {
|
|
|
7673
8310
|
// heartbeat is therefore an external kill or a native crash, never a quit.
|
|
7674
8311
|
app.on('before-quit', () => {
|
|
7675
8312
|
app.isQuitting = true;
|
|
8313
|
+
try { flushDeleteStamps(); } catch {}
|
|
7676
8314
|
if (!quitReason) quitReason = 'quit';
|
|
7677
8315
|
diagnostics.record('app.quit', { reason: quitReason, build: BUILD_INFO.label, uptime_s: Math.round(process.uptime()) }, { forceFile: true });
|
|
7678
8316
|
});
|