@tobq/boardclip 2.1.113 → 2.1.115
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/editor-preload.js +2 -0
- package/editor.html +12 -0
- package/index.html +115 -58
- package/lib/clipboard-model.js +588 -23
- package/lib/mcp-core.js +240 -69
- package/lib/sync-delta.js +5 -2
- package/lib/windows-hook-policy.js +245 -0
- package/lib/windows-hook-worker.js +18 -77
- package/lib/windows-hook.js +32 -9
- package/main.js +865 -202
- package/mcp/boardclip-mcp.js +68 -39
- package/mcp-approval.html +4 -2
- package/package.json +1 -1
- package/preload.js +11 -2
- package/site/index.html +108 -37
- package/site/shared/clip-search.js +291 -134
- package/site/shared/clipboard-popup.css +8 -20
- package/site/shared/clipboard-ui-core.js +405 -329
- package/viewer.html +1 -1
package/main.js
CHANGED
|
@@ -50,6 +50,7 @@ const conflictModel = require('./lib/conflict-model');
|
|
|
50
50
|
const { ControlServer } = require('./lib/control-server');
|
|
51
51
|
const windowsConsole = require('./lib/windows-console');
|
|
52
52
|
const windowsDwm = require('./lib/windows-dwm');
|
|
53
|
+
const hookPolicy = require('./lib/windows-hook-policy');
|
|
53
54
|
const appearance = require('./lib/appearance');
|
|
54
55
|
const { createNotifier } = require('./lib/notifications');
|
|
55
56
|
|
|
@@ -676,9 +677,20 @@ function effectiveQuickPasteShortcut() {
|
|
|
676
677
|
return settings.quick_paste_shortcut || defaultQuickPasteShortcut();
|
|
677
678
|
}
|
|
678
679
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
680
|
+
// Windows: Win+N, taken by the keyboard hook like every Win chord (it is
|
|
681
|
+
// Windows' own notification panel chord, which RegisterHotKey cannot win).
|
|
682
|
+
// Elsewhere none until one is recorded.
|
|
683
|
+
function defaultNewNoteShortcut() {
|
|
684
|
+
return process.platform === 'win32' ? 'Super+N' : '';
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// A stored shortcut of SHORTCUT_OFF = none, even where the default is a chord
|
|
688
|
+
// (Windows: Win+N goes back to Windows' notification panel). '' = the default.
|
|
689
|
+
const SHORTCUT_OFF = 'none';
|
|
690
|
+
|
|
691
|
+
function effectiveNewNoteShortcut() {
|
|
692
|
+
if (settings.new_note_shortcut === SHORTCUT_OFF) return '';
|
|
693
|
+
return settings.new_note_shortcut || defaultNewNoteShortcut();
|
|
682
694
|
}
|
|
683
695
|
|
|
684
696
|
function globalQuickPasteShortcut() {
|
|
@@ -700,6 +712,14 @@ function normalizeQuickPasteShortcut(shortcut) {
|
|
|
700
712
|
return value;
|
|
701
713
|
}
|
|
702
714
|
|
|
715
|
+
function normalizeNewNoteShortcut(shortcut) {
|
|
716
|
+
const value = String(shortcut || '').trim();
|
|
717
|
+
if (!value || value === defaultNewNoteShortcut()) return '';
|
|
718
|
+
// Off is stored only where the default is a chord (on macOS it IS the default).
|
|
719
|
+
if (value.toLowerCase() === SHORTCUT_OFF) return defaultNewNoteShortcut() ? SHORTCUT_OFF : '';
|
|
720
|
+
return value;
|
|
721
|
+
}
|
|
722
|
+
|
|
703
723
|
function shortcutUsesFn(shortcut) {
|
|
704
724
|
return String(shortcut || '').split('+').some(part => {
|
|
705
725
|
const value = part.trim().toLowerCase();
|
|
@@ -731,7 +751,8 @@ function quickPasteShortcutForSlot(shortcut, slot) {
|
|
|
731
751
|
if (!parts.length) return '';
|
|
732
752
|
const keyIndex = parts.length - 1;
|
|
733
753
|
if (quickPasteSlotFromShortcut(parts[keyIndex]) == null) return '';
|
|
734
|
-
|
|
754
|
+
// A numpad digit stays a numpad digit (Ctrl+Alt+num1 is Ctrl+Alt+num1..num9).
|
|
755
|
+
parts[keyIndex] = (/^num/i.test(parts[keyIndex]) ? 'num' : '') + slot;
|
|
735
756
|
return parts.join('+');
|
|
736
757
|
}
|
|
737
758
|
|
|
@@ -840,6 +861,12 @@ let history = loadHistory();
|
|
|
840
861
|
for (const h of history) migrateItemPin(h);
|
|
841
862
|
for (const h of history) ensureItemId(h);
|
|
842
863
|
let conflicts = loadConflicts();
|
|
864
|
+
// Flat tags (14 Oct 2026): ONE pure plan over both files (clipboardModel.planTagState),
|
|
865
|
+
// run first thing in whenReady (a second instance never gets there: no stray copy) and
|
|
866
|
+
// persisted at once, settings then history. No done flag: nothing in the plan is a one-off
|
|
867
|
+
// edit, so a start that dies between the two writes (or an MCP read between them) gets the
|
|
868
|
+
// very same clips from the new settings and the old history.
|
|
869
|
+
let tagMigration = null;
|
|
843
870
|
|
|
844
871
|
// --- Pin model ---
|
|
845
872
|
// Unified state: item.pin is null/undefined for unpinned items, or an object
|
|
@@ -935,7 +962,7 @@ function applyExternalTextEdit({ id, originalText, originalTitle, sourceGroups,
|
|
|
935
962
|
originalTitle,
|
|
936
963
|
newText,
|
|
937
964
|
newTitle,
|
|
938
|
-
sourceGroups,
|
|
965
|
+
sourceGroups: normalizeTagNames(Array.isArray(sourceGroups) ? sourceGroups : []),
|
|
939
966
|
groupTombstones: settings.group_tombstones,
|
|
940
967
|
supersedes: settings.supersedes,
|
|
941
968
|
now: Date.now(),
|
|
@@ -1064,6 +1091,170 @@ function pruneHistory() {
|
|
|
1064
1091
|
}
|
|
1065
1092
|
}
|
|
1066
1093
|
|
|
1094
|
+
// --- Flat tags ---
|
|
1095
|
+
// The cutover (this device's first start of the flat-tags build) keeps a copy of the
|
|
1096
|
+
// three files plus an undo record (each changed clip's old tags) in clipboard-RECOVERED/
|
|
1097
|
+
// BEFORE anything changes in memory. No copy, no change: the app then runs on the old
|
|
1098
|
+
// names until the next start tries again.
|
|
1099
|
+
function migrateTagsAtLoad() {
|
|
1100
|
+
let plan;
|
|
1101
|
+
try {
|
|
1102
|
+
plan = clipboardModel.planTagState(history, settings, { aiGroupName: mcpCore.AI_GROUP_NAME });
|
|
1103
|
+
} catch (error) {
|
|
1104
|
+
diagnostics.record('groups.flatten.error', { stage: 'plan', error: error && error.message }, { forceFile: true });
|
|
1105
|
+
return null;
|
|
1106
|
+
}
|
|
1107
|
+
if (!plan.changed) return null;
|
|
1108
|
+
let evidence = '';
|
|
1109
|
+
if (plan.report.cutover && (plan.undo.length || plan.report.retired.length)) {
|
|
1110
|
+
try {
|
|
1111
|
+
evidence = saveFlattenEvidence(plan);
|
|
1112
|
+
} catch (error) {
|
|
1113
|
+
diagnostics.record('groups.flatten.error', { stage: 'evidence', error: error && error.message }, { forceFile: true });
|
|
1114
|
+
return null;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
history.splice(0, history.length, ...plan.history);
|
|
1118
|
+
Object.assign(settings, plan.settings);
|
|
1119
|
+
return { ...plan, evidence };
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function saveFlattenEvidence(plan) {
|
|
1123
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1124
|
+
// The local data dir, never a provider path.
|
|
1125
|
+
const evidenceDir = path.join(DATA_DIR, 'clipboard-RECOVERED', `flatten-groups-${stamp}`);
|
|
1126
|
+
fs.mkdirSync(evidenceDir, { recursive: true });
|
|
1127
|
+
for (const file of [DB_PATH, SETTINGS_PATH, CONFLICTS_PATH]) {
|
|
1128
|
+
if (fs.existsSync(file)) fs.copyFileSync(file, path.join(evidenceDir, path.basename(file)));
|
|
1129
|
+
}
|
|
1130
|
+
flattenLedgerCache = null;
|
|
1131
|
+
fs.writeFileSync(path.join(evidenceDir, 'undo.json'), JSON.stringify({
|
|
1132
|
+
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.',
|
|
1133
|
+
at: Date.now(),
|
|
1134
|
+
report: plan.report,
|
|
1135
|
+
groups: settings.groups || [],
|
|
1136
|
+
groups_shared_with_ai: settings.groups_shared_with_ai || [],
|
|
1137
|
+
clips: plan.undo,
|
|
1138
|
+
}, null, 2));
|
|
1139
|
+
return evidenceDir;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// whenReady, before any other write.
|
|
1143
|
+
function persistTagMigration() {
|
|
1144
|
+
if (!tagMigration) return;
|
|
1145
|
+
saveSettingsFile();
|
|
1146
|
+
writeHistoryStorageFile();
|
|
1147
|
+
const r = tagMigration.report;
|
|
1148
|
+
diagnostics.record('groups.flatten', {
|
|
1149
|
+
cutover: r.cutover, retired: r.retired.length, clips_changed: r.clipsChanged,
|
|
1150
|
+
groups_before: r.groupsBefore, groups_after: r.groupsAfter,
|
|
1151
|
+
shared_before: r.sharedBefore, shared_after: r.sharedAfter,
|
|
1152
|
+
visible_before: r.visibleBefore, visible_after: r.visibleAfter,
|
|
1153
|
+
unshared_to_protect: r.unsharedToProtect.length,
|
|
1154
|
+
copy_path: tagMigration.evidence || '',
|
|
1155
|
+
}, { forceFile: true });
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Every cutover undo ledger this device wrote (normally one; a start that died half way
|
|
1159
|
+
// leaves a second), the earliest first: unsplitLiterals reads each clip's first entry.
|
|
1160
|
+
let flattenLedgerCache = null;
|
|
1161
|
+
function flattenLedger() {
|
|
1162
|
+
if (flattenLedgerCache) return flattenLedgerCache;
|
|
1163
|
+
const entries = [];
|
|
1164
|
+
const recoveredRoot = path.join(DATA_DIR, 'clipboard-RECOVERED');
|
|
1165
|
+
let names = [];
|
|
1166
|
+
try { names = fs.readdirSync(recoveredRoot).filter(n => n.startsWith('flatten-groups-')).sort(); } catch {}
|
|
1167
|
+
for (const name of names) {
|
|
1168
|
+
try {
|
|
1169
|
+
const undo = blobStore.parseJsonText(fs.readFileSync(path.join(recoveredRoot, name, 'undo.json'), 'utf-8'));
|
|
1170
|
+
if (undo && Array.isArray(undo.clips)) entries.push(...undo.clips);
|
|
1171
|
+
} catch {}
|
|
1172
|
+
}
|
|
1173
|
+
flattenLedgerCache = entries;
|
|
1174
|
+
return entries;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// A name another device made with a plain '/' that this device's cutover split goes back
|
|
1178
|
+
// on this device's own clips (clipboardModel.unsplitLiterals). Returns the new history,
|
|
1179
|
+
// or null when nothing changed.
|
|
1180
|
+
function unsplitLiteralTags(list, source) {
|
|
1181
|
+
if (!(Number(settings.groups_flattened_at) > 0) || !(settings.group_literals || []).length) return null;
|
|
1182
|
+
const literalKeys = new Set(clipboardModel.normalizeGroupLiterals(settings.group_literals).map(r => clipSearch.tagKey(r.name)));
|
|
1183
|
+
if (!clipboardModel.normalizeGroupSplits(settings.group_splits).some(r => literalKeys.has(clipSearch.tagKey(r.name)))) return null;
|
|
1184
|
+
const result = clipboardModel.unsplitLiterals(list, flattenLedger(), settings);
|
|
1185
|
+
if (!result.ids.length) return null;
|
|
1186
|
+
diagnostics.record('groups.flatten.unsplit', { source, clips: result.ids.length }, { forceFile: true });
|
|
1187
|
+
return result;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// The tag rule applied to a name list against the current settings (retired names,
|
|
1191
|
+
// stored spellings, deleted tags). For every write boundary: typed input, AI tools,
|
|
1192
|
+
// new-note tags, unify and fork unions, restored clips.
|
|
1193
|
+
function currentTagContext(extraLists = []) {
|
|
1194
|
+
return clipboardModel.tagContext(settings, extraLists);
|
|
1195
|
+
}
|
|
1196
|
+
function normalizeTagNames(names, ctx = currentTagContext([names])) {
|
|
1197
|
+
return clipboardModel.normalizeTagList((Array.isArray(names) ? names : []).map(String), ctx);
|
|
1198
|
+
}
|
|
1199
|
+
// Names typed or sent in one go ("todo, forge"), in stored form.
|
|
1200
|
+
function tagInputNames(input) {
|
|
1201
|
+
return clipboardModel.tagInputNames(input, settings);
|
|
1202
|
+
}
|
|
1203
|
+
// Make sure settings.groups lists each tag (one entry per tag); a tag deleted
|
|
1204
|
+
// earlier is re-created (its tombstone records the re-create, so sync keeps it).
|
|
1205
|
+
function registerTags(names) {
|
|
1206
|
+
if (!settings.groups) settings.groups = [];
|
|
1207
|
+
const listed = new Set(settings.groups.map(clipSearch.tagKey));
|
|
1208
|
+
let changed = false;
|
|
1209
|
+
const now = Date.now();
|
|
1210
|
+
const tombstones = normalizeGroupTombstones(settings.group_tombstones);
|
|
1211
|
+
let restored = false;
|
|
1212
|
+
for (const name of names) {
|
|
1213
|
+
const key = clipSearch.tagKey(name);
|
|
1214
|
+
if (!key) continue;
|
|
1215
|
+
for (const t of tombstones) {
|
|
1216
|
+
if (clipSearch.tagKey(t.name) === key && t.deletedAt > (t.restoredAt || 0)) { t.restoredAt = now; restored = true; }
|
|
1217
|
+
}
|
|
1218
|
+
if (listed.has(key)) continue;
|
|
1219
|
+
listed.add(key);
|
|
1220
|
+
settings.groups.push(name);
|
|
1221
|
+
changed = true;
|
|
1222
|
+
}
|
|
1223
|
+
if (restored) settings.group_tombstones = normalizeGroupTombstones(tombstones);
|
|
1224
|
+
return changed || restored;
|
|
1225
|
+
}
|
|
1226
|
+
// Tags the user or an AI tool named (typed, picked, sent). A brand-new name with a '/' is
|
|
1227
|
+
// a plain tag here: group_literals records it, so a device on its first flat start can
|
|
1228
|
+
// never retire it as an old nested name. Never for names that arrive with data (sync,
|
|
1229
|
+
// restored clips, unions): an old device's nested name there must stay retirable.
|
|
1230
|
+
function registerTypedTags(names) {
|
|
1231
|
+
const listed = new Set((settings.groups || []).map(clipSearch.tagKey));
|
|
1232
|
+
const fresh = names.filter(name => name.includes('/') && !listed.has(clipSearch.tagKey(name)));
|
|
1233
|
+
let literal = false;
|
|
1234
|
+
if (fresh.length) {
|
|
1235
|
+
const before = JSON.stringify(settings.group_literals || []);
|
|
1236
|
+
const at = Date.now();
|
|
1237
|
+
settings.group_literals = clipboardModel.normalizeGroupLiterals([...(settings.group_literals || []), ...fresh.map(name => ({ name, at }))]);
|
|
1238
|
+
literal = JSON.stringify(settings.group_literals) !== before;
|
|
1239
|
+
}
|
|
1240
|
+
return registerTags(names) || literal;
|
|
1241
|
+
}
|
|
1242
|
+
function itemHasTag(item, name) {
|
|
1243
|
+
const key = clipSearch.tagKey(name);
|
|
1244
|
+
return groupsOf(item).some(g => clipSearch.tagKey(g) === key);
|
|
1245
|
+
}
|
|
1246
|
+
function setItemTag(item, name, shouldHave) {
|
|
1247
|
+
if (!!shouldHave === itemHasTag(item, name)) return false;
|
|
1248
|
+
const pin = ensurePin(item);
|
|
1249
|
+
const key = clipSearch.tagKey(name);
|
|
1250
|
+
if (shouldHave) pin.groups = [...(pin.groups || []), name];
|
|
1251
|
+
else {
|
|
1252
|
+
pin.groups = (pin.groups || []).filter(g => clipSearch.tagKey(g) !== key);
|
|
1253
|
+
if (!pin.groups.length) delete pin.groups;
|
|
1254
|
+
}
|
|
1255
|
+
return true;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1067
1258
|
// --- Migration: old settings.numpad_slots -> per-item pin ---
|
|
1068
1259
|
// Runs once on first launch after upgrading from the Python-era config.
|
|
1069
1260
|
function migrateNumpad() {
|
|
@@ -1098,10 +1289,6 @@ function normalizeSupersedes(list) {
|
|
|
1098
1289
|
return clipboardModel.normalizeSupersedes(list);
|
|
1099
1290
|
}
|
|
1100
1291
|
|
|
1101
|
-
function groupTombstoneNames(list) {
|
|
1102
|
-
return clipboardModel.groupTombstoneNames(list);
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
1292
|
function tombstoneIds(list) {
|
|
1106
1293
|
return clipboardModel.tombstoneIds(list);
|
|
1107
1294
|
}
|
|
@@ -1129,11 +1316,11 @@ function addSupersede(record) {
|
|
|
1129
1316
|
]);
|
|
1130
1317
|
}
|
|
1131
1318
|
|
|
1132
|
-
function addGroupTombstone(name) {
|
|
1319
|
+
function addGroupTombstone(name, deletedAt = Date.now()) {
|
|
1133
1320
|
if (!name) return;
|
|
1134
1321
|
settings.group_tombstones = normalizeGroupTombstones([
|
|
1135
1322
|
...(settings.group_tombstones || []),
|
|
1136
|
-
{ name, deletedAt:
|
|
1323
|
+
{ name, deletedAt, anyCase: true },
|
|
1137
1324
|
]);
|
|
1138
1325
|
}
|
|
1139
1326
|
|
|
@@ -1168,11 +1355,20 @@ function mergeItems(localItem, remoteItem) {
|
|
|
1168
1355
|
// each event+clip is logged at most once an hour.
|
|
1169
1356
|
const forensicsLastLogged = new Map();
|
|
1170
1357
|
const FORENSICS_REPEAT_MS = 60 * 60 * 1000;
|
|
1358
|
+
// Ids of LOCAL clips the last merge put in stored tag form (a tag deleted on another
|
|
1359
|
+
// device, a spelling twin arriving): applyRemoteEnvelope counts them as touched.
|
|
1360
|
+
let lastMergeNormalisedIds = new Set();
|
|
1171
1361
|
function mergeHistories(local, remote) {
|
|
1172
1362
|
const report = [];
|
|
1173
1363
|
const merged = clipboardModel.mergeHistories(local, remote, settings, report);
|
|
1174
1364
|
const now = Date.now();
|
|
1365
|
+
lastMergeNormalisedIds = new Set(report.filter(e => e.event === 'normalised').map(e => e.id));
|
|
1366
|
+
// A tag deleted or respelled elsewhere is routine; a retired nested name still on a local
|
|
1367
|
+
// clip is not (it should have gone at the cutover).
|
|
1368
|
+
const stragglers = report.filter(e => e.event === 'normalised' && e.split).length;
|
|
1369
|
+
if (stragglers) diagnostics.record('groups.flatten.straggler', { source: 'merge', local_clips: stragglers }, { forceFile: true });
|
|
1175
1370
|
for (const entry of report) {
|
|
1371
|
+
if (entry.event === 'normalised') continue;
|
|
1176
1372
|
const key = `${entry.event}:${entry.reason || ''}:${entry.id}`;
|
|
1177
1373
|
if (now - (forensicsLastLogged.get(key) || 0) < FORENSICS_REPEAT_MS) continue;
|
|
1178
1374
|
forensicsLastLogged.set(key, now);
|
|
@@ -1196,7 +1392,7 @@ function mergeHistories(local, remote) {
|
|
|
1196
1392
|
}
|
|
1197
1393
|
|
|
1198
1394
|
function mergeGroups(local, remote) {
|
|
1199
|
-
return clipboardModel.mergeGroups(local, remote, settings.group_tombstones);
|
|
1395
|
+
return clipboardModel.mergeGroups(local, remote, settings.group_tombstones, currentTagContext([local || [], remote || []]));
|
|
1200
1396
|
}
|
|
1201
1397
|
|
|
1202
1398
|
// Settings the save-settings IPC may write without touching history or sync
|
|
@@ -1217,6 +1413,7 @@ function remoteSettingsPayload() {
|
|
|
1217
1413
|
delete remoteSave.sync_disabled_paths;
|
|
1218
1414
|
delete remoteSave.show_shortcut;
|
|
1219
1415
|
delete remoteSave.quick_paste_shortcut;
|
|
1416
|
+
delete remoteSave.new_note_shortcut;
|
|
1220
1417
|
delete remoteSave.quick_paste_restore;
|
|
1221
1418
|
delete remoteSave.capture_rich;
|
|
1222
1419
|
delete remoteSave.quick_paste_restore_delay_ms;
|
|
@@ -1241,8 +1438,12 @@ function remoteSettingsPayload() {
|
|
|
1241
1438
|
delete remoteSave.facet_joins;
|
|
1242
1439
|
delete remoteSave.find_regex;
|
|
1243
1440
|
delete remoteSave.find_case;
|
|
1244
|
-
//
|
|
1245
|
-
|
|
1441
|
+
// When this device cut over to flat tags: per device.
|
|
1442
|
+
delete remoteSave.groups_flattened_at;
|
|
1443
|
+
// The AI share list itself is per device (switch flips travel as ai_share_stamps).
|
|
1444
|
+
delete remoteSave.groups_shared_with_ai;
|
|
1445
|
+
// AI Access: per-machine, never synced. The AI share switches sync per tag through
|
|
1446
|
+
// ai_share_stamps (newest flip wins); the list itself is each device's own.
|
|
1246
1447
|
delete remoteSave.mcp_secret;
|
|
1247
1448
|
delete remoteSave.ai_access_enabled;
|
|
1248
1449
|
delete remoteSave.ai_always_allow;
|
|
@@ -1266,6 +1467,40 @@ function mergeSyncedSettings(remoteSettings) {
|
|
|
1266
1467
|
...(settings.supersedes || []),
|
|
1267
1468
|
...(remoteSettings.supersedes || []),
|
|
1268
1469
|
]);
|
|
1470
|
+
// Retired nested tag names and plain '/' names (both permanent, every device agrees on
|
|
1471
|
+
// the earliest record).
|
|
1472
|
+
const splitsBefore = clipboardModel.splitMapOf(settings);
|
|
1473
|
+
const literalsBefore = (settings.group_literals || []).length;
|
|
1474
|
+
settings.group_splits = clipboardModel.normalizeGroupSplits([
|
|
1475
|
+
...(settings.group_splits || []),
|
|
1476
|
+
...(remoteSettings.group_splits || []),
|
|
1477
|
+
]);
|
|
1478
|
+
settings.group_literals = clipboardModel.normalizeGroupLiterals([
|
|
1479
|
+
...(settings.group_literals || []),
|
|
1480
|
+
...(remoteSettings.group_literals || []),
|
|
1481
|
+
]);
|
|
1482
|
+
if (settings.group_literals.length !== literalsBefore) unsplitPending = true;
|
|
1483
|
+
// A split new here (another device's late cutover) follows the cutover's share rule.
|
|
1484
|
+
const newSplits = [...clipboardModel.splitMapOf(settings)].filter(([key]) => !splitsBefore.has(key)).map(([name, parts]) => ({ name, parts }));
|
|
1485
|
+
if (newSplits.length) {
|
|
1486
|
+
const held = [...(settings.groups || []), ...history.flatMap(h => groupsOf(h))];
|
|
1487
|
+
const result = clipboardModel.shareListAfterNewSplits(settings.groups_shared_with_ai, newSplits, held);
|
|
1488
|
+
if (result.unshared.length) {
|
|
1489
|
+
settings.groups_shared_with_ai = result.shared;
|
|
1490
|
+
diagnostics.record('groups.flatten.late_split', { splits: newSplits.length, unshared: result.unshared.length }, { forceFile: true });
|
|
1491
|
+
notifyAiAccessChangedSoon();
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
// AI share switches: a newer flip on another device applies here.
|
|
1495
|
+
const stamps = clipboardModel.mergeShareStamps(settings.ai_share_stamps, remoteSettings.ai_share_stamps);
|
|
1496
|
+
if (JSON.stringify(stamps) !== JSON.stringify(clipboardModel.normalizeShareStamps(settings.ai_share_stamps))) {
|
|
1497
|
+
settings.ai_share_stamps = stamps;
|
|
1498
|
+
const shared = clipboardModel.applyShareStamps(settings.groups_shared_with_ai, stamps, currentTagContext().spellings);
|
|
1499
|
+
if (shared !== settings.groups_shared_with_ai) {
|
|
1500
|
+
settings.groups_shared_with_ai = shared;
|
|
1501
|
+
notifyAiAccessChangedSoon();
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1269
1504
|
const secrets = [settings.p2p_secret, remoteSettings.p2p_secret].filter(Boolean).sort();
|
|
1270
1505
|
if (secrets.length) settings.p2p_secret = secrets[0];
|
|
1271
1506
|
if (remoteSettings.p2p_enabled !== undefined && settings.p2p_enabled === undefined) {
|
|
@@ -1345,13 +1580,32 @@ function captureTitleConflicts(localHistory, remoteHistory, source) {
|
|
|
1345
1580
|
}
|
|
1346
1581
|
}
|
|
1347
1582
|
|
|
1583
|
+
// Set when a plain '/' name arrives (mergeSyncedSettings): the next fold puts it back on
|
|
1584
|
+
// the clips this device's cutover split, before that fold's merge.
|
|
1585
|
+
let unsplitPending = false;
|
|
1348
1586
|
function foldRemoteState(canonicalHistory, remoteHistory, remoteSettings, remoteConflicts) {
|
|
1349
1587
|
mergeSyncedSettings(remoteSettings);
|
|
1350
1588
|
mergeSyncedConflicts(remoteConflicts);
|
|
1589
|
+
let unsplitIds = [];
|
|
1590
|
+
if (unsplitPending) {
|
|
1591
|
+
unsplitPending = false;
|
|
1592
|
+
const result = unsplitLiteralTags(canonicalHistory, 'sync');
|
|
1593
|
+
if (result) { canonicalHistory = result.history; unsplitIds = result.ids; }
|
|
1594
|
+
}
|
|
1351
1595
|
captureTitleConflicts(canonicalHistory, remoteHistory, 'sync');
|
|
1352
1596
|
const historyGroups = canonicalHistory.flatMap(h => groupsOf(h));
|
|
1353
|
-
|
|
1354
|
-
|
|
1597
|
+
const remoteGroups = Array.isArray(remoteHistory) ? remoteHistory.flatMap(h => groupsOf(h || {})) : [];
|
|
1598
|
+
const listed = [...(remoteSettings && remoteSettings.groups || []), ...historyGroups];
|
|
1599
|
+
const ctx = currentTagContext([settings.groups || [], listed, remoteGroups]);
|
|
1600
|
+
settings.groups = clipboardModel.mergeGroups(settings.groups, listed, settings.group_tombstones, ctx);
|
|
1601
|
+
if (Array.isArray(settings.groups_shared_with_ai)) {
|
|
1602
|
+
const shared = clipSearch.expandTagNames(settings.groups_shared_with_ai, null, ctx.spellings);
|
|
1603
|
+
if (shared !== settings.groups_shared_with_ai) settings.groups_shared_with_ai = shared;
|
|
1604
|
+
}
|
|
1605
|
+
const merged = mergeHistories(canonicalHistory, remoteHistory);
|
|
1606
|
+
for (const id of unsplitIds) lastMergeNormalisedIds.add(id);
|
|
1607
|
+
if ((settings.group_literals || []).length) settings.groups = clipboardModel.pruneLiteralParts(settings.groups, settings, merged.flatMap(h => groupsOf(h)));
|
|
1608
|
+
return merged;
|
|
1355
1609
|
}
|
|
1356
1610
|
|
|
1357
1611
|
// ---- Sync change feed (lib/sync-delta): one revision counter + per-entry
|
|
@@ -2718,7 +2972,9 @@ async function applyRemoteEnvelope(envelope, { peer = null, reason, source, tran
|
|
|
2718
2972
|
const previousConflictsJson = JSON.stringify(conflictModel.normalizeConflictState(conflicts));
|
|
2719
2973
|
const before = history.slice();
|
|
2720
2974
|
const canonicalHistory = foldRemoteState(history.slice(), remoteHistory, remoteSettings, conflictModel.normalizeConflictState(remoteConflicts));
|
|
2721
|
-
const
|
|
2975
|
+
const touched = syncDelta.touchedIds(envelope);
|
|
2976
|
+
for (const id of lastMergeNormalisedIds) touched.add(id);
|
|
2977
|
+
const localChanged = syncDelta.historyChangedBy(before, canonicalHistory, touched);
|
|
2722
2978
|
const { settingsChanged, conflictsChanged } = commitRemoteFold(canonicalHistory, { localChanged, previousSettingsJson, previousConflictsJson, notifyPeers });
|
|
2723
2979
|
const peerName = peer ? (peer.deviceName || peer.deviceId) : (envelope.deviceName || envelope.deviceId);
|
|
2724
2980
|
if (localChanged || settingsChanged || conflictsChanged) {
|
|
@@ -3663,6 +3919,12 @@ function historyEntryDiagnostics(entry) {
|
|
|
3663
3919
|
function addToHistory(entry, matchFn) {
|
|
3664
3920
|
const startedAt = Date.now();
|
|
3665
3921
|
ensureItemId(entry);
|
|
3922
|
+
// The same clip is the same id (ids hash the content). A long clip kept as a text
|
|
3923
|
+
// blob holds only a preview in item.text, so a text compare alone missed it and
|
|
3924
|
+
// added a second entry with the SAME id.
|
|
3925
|
+
const entryKey = itemKey(entry);
|
|
3926
|
+
const callerMatch = matchFn;
|
|
3927
|
+
matchFn = (it) => !!it && (itemKey(it) === entryKey || callerMatch(it));
|
|
3666
3928
|
const beforeTombstones = normalizeTombstones(settings.tombstones);
|
|
3667
3929
|
settings.tombstones = beforeTombstones.filter(t => t.id !== itemKey(entry));
|
|
3668
3930
|
const tombstoneRemoved = settings.tombstones.length !== beforeTombstones.length;
|
|
@@ -5520,9 +5782,10 @@ function createEditorWindow(session, presentOptions = {}) {
|
|
|
5520
5782
|
}
|
|
5521
5783
|
|
|
5522
5784
|
function sourceGroupsForNewNote(options) {
|
|
5523
|
-
|
|
5524
|
-
.map(group =>
|
|
5525
|
-
.filter(group => group && !group.startsWith('__'))
|
|
5785
|
+
const names = (Array.isArray(options && options.sourceGroups) ? options.sourceGroups : [])
|
|
5786
|
+
.map(group => clipSearch.cleanTagName(group))
|
|
5787
|
+
.filter(group => group && !group.startsWith('__'));
|
|
5788
|
+
return normalizeTagNames(names);
|
|
5526
5789
|
}
|
|
5527
5790
|
|
|
5528
5791
|
// Open the editor for clip `id`, or a blank new-note editor when id is null.
|
|
@@ -5607,9 +5870,7 @@ function openEditor(id, options = {}) {
|
|
|
5607
5870
|
originalId = itemKey(item);
|
|
5608
5871
|
} else {
|
|
5609
5872
|
sourceGroups = sourceGroupsForNewNote(options);
|
|
5610
|
-
|
|
5611
|
-
if (!settings.groups || !settings.groups.includes(group)) applyGroupCreate(group);
|
|
5612
|
-
}
|
|
5873
|
+
if (registerTags(sourceGroups)) saveSettingsFile();
|
|
5613
5874
|
}
|
|
5614
5875
|
const baseHash = clipboardModel.textHashForText(baseText);
|
|
5615
5876
|
editSessionSeq += 1;
|
|
@@ -5794,8 +6055,7 @@ function foldForkInto(fork, note) {
|
|
|
5794
6055
|
const forkId = itemKey(fork);
|
|
5795
6056
|
const noteId = itemKey(note);
|
|
5796
6057
|
const forkSlot = numpadSlotOf(fork);
|
|
5797
|
-
const
|
|
5798
|
-
const newGroups = groupsOf(fork).filter(group => !noteGroups.includes(group));
|
|
6058
|
+
const newGroups = normalizeTagNames(groupsOf(fork)).filter(group => !itemHasTag(note, group));
|
|
5799
6059
|
if (newGroups.length) {
|
|
5800
6060
|
const pin = ensurePin(note);
|
|
5801
6061
|
pin.groups = [...(pin.groups || []), ...newGroups];
|
|
@@ -6062,13 +6322,10 @@ function applyUnify(session) {
|
|
|
6062
6322
|
const item = history.find(it => it.type !== 'image' && it.text === mergedText);
|
|
6063
6323
|
if (item) {
|
|
6064
6324
|
if (mergedTitle) clipboardModel.setTitleMetadata(item, mergedTitle);
|
|
6065
|
-
|
|
6066
|
-
|
|
6067
|
-
|
|
6068
|
-
|
|
6069
|
-
if (!pin.groups.includes(group)) pin.groups.push(group);
|
|
6070
|
-
}
|
|
6071
|
-
if (session.unionGroups.length) touchPinGroups(item);
|
|
6325
|
+
const unionGroups = normalizeTagNames(session.unionGroups);
|
|
6326
|
+
if (registerTags(unionGroups)) saveSettingsFile();
|
|
6327
|
+
for (const group of unionGroups) setItemTag(item, group, true);
|
|
6328
|
+
if (unionGroups.length) touchPinGroups(item);
|
|
6072
6329
|
if (session.pinned && !item.pin) item.pin = {};
|
|
6073
6330
|
saveHistory();
|
|
6074
6331
|
if (session.numberSlot != null) applyNumpadAssign(itemKey(item), session.numberSlot, clipboardModel.clipRevision(item));
|
|
@@ -6147,9 +6404,11 @@ const historyFeedSenders = new Set();
|
|
|
6147
6404
|
function historyForRenderer() {
|
|
6148
6405
|
return history.map(item => ({ ...item, rev: clipboardModel.clipRevision(item) }));
|
|
6149
6406
|
}
|
|
6407
|
+
// What an MCP change says about its clip: the id and rev an AI holds for it (the
|
|
6408
|
+
// lib/mcp-core boundary: a real id only for a clip it can read, a keyed rev).
|
|
6150
6409
|
function mcpMutationResult(id, ok) {
|
|
6151
6410
|
const item = findHistoryItem(id);
|
|
6152
|
-
return { ok: !!ok, id, rev: item ?
|
|
6411
|
+
return { ok: !!ok, id: item ? mcpCore.publicId(item, settings) : null, rev: item ? mcpCore.publicRev(item, settings) : null };
|
|
6153
6412
|
}
|
|
6154
6413
|
|
|
6155
6414
|
// ---- Reusable mutation primitives (shared by IPC + MCP) ----
|
|
@@ -6187,56 +6446,127 @@ function applyNumpadAssign(id, slot, rev) {
|
|
|
6187
6446
|
return true;
|
|
6188
6447
|
}
|
|
6189
6448
|
|
|
6190
|
-
|
|
6191
|
-
|
|
6192
|
-
|
|
6193
|
-
|
|
6194
|
-
|
|
6195
|
-
|
|
6449
|
+
// Create tags from typed input ("todo, forge" = two). A name matching an existing tag
|
|
6450
|
+
// in any case reuses it. Returns the tag names, or false for nothing to create.
|
|
6451
|
+
function applyGroupCreate(input) {
|
|
6452
|
+
const names = tagInputNames(input);
|
|
6453
|
+
if (!names.length) return false;
|
|
6454
|
+
registerTypedTags(names);
|
|
6196
6455
|
saveSettingsFile();
|
|
6456
|
+
return names;
|
|
6457
|
+
}
|
|
6458
|
+
|
|
6459
|
+
// A deleted tag is shared with no one, so re-creating it later starts private on every
|
|
6460
|
+
// device: the unshare travels as a share stamp, but only once the Undo window has passed
|
|
6461
|
+
// (an Undo must not share or unshare anything on another device).
|
|
6462
|
+
const GROUP_DELETE_UNDO_MS = 15000;
|
|
6463
|
+
const pendingDeleteStamps = new Map();
|
|
6464
|
+
function commitDeleteStamp(key) {
|
|
6465
|
+
const pending = pendingDeleteStamps.get(key);
|
|
6466
|
+
if (!pending) return false;
|
|
6467
|
+
clearTimeout(pending.timer);
|
|
6468
|
+
pendingDeleteStamps.delete(key);
|
|
6469
|
+
if ((settings.groups || []).some(g => clipSearch.tagKey(g) === key)) return false;
|
|
6470
|
+
stampShareSwitch(pending.name, false, pending.at);
|
|
6197
6471
|
return true;
|
|
6198
6472
|
}
|
|
6473
|
+
function stampDeletedTagUnshared(name, at) {
|
|
6474
|
+
const key = clipSearch.tagKey(name);
|
|
6475
|
+
cancelDeleteStamp(key);
|
|
6476
|
+
const timer = setTimeout(() => { if (commitDeleteStamp(key)) saveSettingsFile(); }, GROUP_DELETE_UNDO_MS);
|
|
6477
|
+
if (timer.unref) timer.unref();
|
|
6478
|
+
pendingDeleteStamps.set(key, { timer, name, at });
|
|
6479
|
+
}
|
|
6480
|
+
function cancelDeleteStamp(key) {
|
|
6481
|
+
const pending = pendingDeleteStamps.get(key);
|
|
6482
|
+
if (pending) clearTimeout(pending.timer);
|
|
6483
|
+
pendingDeleteStamps.delete(key);
|
|
6484
|
+
}
|
|
6485
|
+
// Quitting inside an Undo window: the delete stands, so its unshare goes out now.
|
|
6486
|
+
function flushDeleteStamps() {
|
|
6487
|
+
let changed = false;
|
|
6488
|
+
for (const key of [...pendingDeleteStamps.keys()]) if (commitDeleteStamp(key)) changed = true;
|
|
6489
|
+
if (changed) saveSettingsFile();
|
|
6490
|
+
}
|
|
6199
6491
|
|
|
6492
|
+
// Delete ONE tag by its name (any case; never split: this is destructive). Only the
|
|
6493
|
+
// clips that had it are touched. The AI tag cannot be deleted. Returns what an Undo needs
|
|
6494
|
+
// (applyGroupRestore).
|
|
6200
6495
|
function applyGroupDelete(name) {
|
|
6496
|
+
const key = clipSearch.tagKey(name);
|
|
6201
6497
|
const groups = settings.groups || [];
|
|
6202
|
-
const idx = groups.
|
|
6203
|
-
if (idx < 0) return false;
|
|
6204
|
-
|
|
6205
|
-
groups.splice(idx, 1);
|
|
6498
|
+
const idx = groups.findIndex(g => clipSearch.tagKey(g) === key);
|
|
6499
|
+
if (!key || idx < 0 || clipSearch.sameTag(name, mcpCore.AI_GROUP_NAME)) return false;
|
|
6500
|
+
const stored = groups[idx];
|
|
6206
6501
|
const now = Date.now();
|
|
6502
|
+
addGroupTombstone(stored, now);
|
|
6503
|
+
groups.splice(idx, 1);
|
|
6504
|
+
const ids = [];
|
|
6207
6505
|
for (const h of history) {
|
|
6208
|
-
if (h
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
touchPinGroups(h, now);
|
|
6212
|
-
}
|
|
6506
|
+
if (!setItemTag(h, stored, false)) continue;
|
|
6507
|
+
touchPinGroups(h, now);
|
|
6508
|
+
ids.push(itemKey(h));
|
|
6213
6509
|
}
|
|
6214
|
-
|
|
6215
|
-
if (
|
|
6216
|
-
settings.groups_shared_with_ai = settings.groups_shared_with_ai.filter(g => g !==
|
|
6510
|
+
const shared = Array.isArray(settings.groups_shared_with_ai) && settings.groups_shared_with_ai.some(g => clipSearch.tagKey(g) === key);
|
|
6511
|
+
if (shared) {
|
|
6512
|
+
settings.groups_shared_with_ai = settings.groups_shared_with_ai.filter(g => clipSearch.tagKey(g) !== key);
|
|
6513
|
+
stampDeletedTagUnshared(stored, now);
|
|
6217
6514
|
}
|
|
6218
6515
|
saveSettingsFile();
|
|
6219
6516
|
saveHistory();
|
|
6517
|
+
if (shared) notifyAiAccessChanged();
|
|
6518
|
+
return { ok: true, name: stored, ids, shared, index: idx };
|
|
6519
|
+
}
|
|
6520
|
+
|
|
6521
|
+
// Undo of a tag delete: the tag back where it was in the list (a re-create, which beats
|
|
6522
|
+
// the delete in sync), on the clips that had it, and shared with AI here again if it was.
|
|
6523
|
+
function applyGroupRestore(payload) {
|
|
6524
|
+
const typed = clipSearch.cleanTagName(payload && payload.name);
|
|
6525
|
+
if (!typed) return false;
|
|
6526
|
+
const key = clipSearch.tagKey(typed);
|
|
6527
|
+
const name = currentTagContext([[typed]]).spellings.get(key) || typed;
|
|
6528
|
+
cancelDeleteStamp(key);
|
|
6529
|
+
if (registerTags([name]) && Number.isInteger(payload.index) && payload.index >= 0) {
|
|
6530
|
+
const groups = settings.groups;
|
|
6531
|
+
const at = groups.findIndex(g => clipSearch.tagKey(g) === key);
|
|
6532
|
+
const index = Math.min(payload.index, groups.length - 1);
|
|
6533
|
+
if (at > index) groups.splice(index, 0, ...groups.splice(at, 1));
|
|
6534
|
+
}
|
|
6535
|
+
const ids = new Set(Array.isArray(payload.ids) ? payload.ids.map(String) : []);
|
|
6536
|
+
const now = Date.now();
|
|
6537
|
+
let changed = false;
|
|
6538
|
+
for (const h of history) {
|
|
6539
|
+
if (!ids.has(itemKey(h)) || !setItemTag(h, name, true)) continue;
|
|
6540
|
+
touchPinGroups(h, now);
|
|
6541
|
+
changed = true;
|
|
6542
|
+
}
|
|
6543
|
+
if (payload.shared) {
|
|
6544
|
+
if (!Array.isArray(settings.groups_shared_with_ai)) settings.groups_shared_with_ai = [];
|
|
6545
|
+
if (!settings.groups_shared_with_ai.some(g => clipSearch.tagKey(g) === key)) settings.groups_shared_with_ai.push(name);
|
|
6546
|
+
}
|
|
6547
|
+
saveSettingsFile();
|
|
6548
|
+
if (changed) saveHistory();
|
|
6549
|
+
if (payload.shared) notifyAiAccessChanged();
|
|
6220
6550
|
return true;
|
|
6221
6551
|
}
|
|
6222
6552
|
|
|
6223
|
-
//
|
|
6553
|
+
// The tag toggle (a row's picker, an AI tool's assign_group). Several tags at once
|
|
6554
|
+
// ("todo, forge", or a retired name like todo/forge) are ONE decision: the missing ones
|
|
6555
|
+
// are added; if the clip already has them all, they are all removed.
|
|
6556
|
+
function planTagToggle(item, input) {
|
|
6557
|
+
return clipSearch.tagTogglePlan(groupsOf(item), tagInputNames(input));
|
|
6558
|
+
}
|
|
6559
|
+
|
|
6224
6560
|
function applyGroupAssign(id, group, rev) {
|
|
6225
|
-
if (!group) return false;
|
|
6226
6561
|
const item = assertClipRevision(id, rev);
|
|
6227
|
-
|
|
6228
|
-
|
|
6229
|
-
if (
|
|
6230
|
-
const
|
|
6231
|
-
|
|
6232
|
-
pin.groups.splice(gIdx, 1);
|
|
6233
|
-
if (pin.groups.length === 0) delete pin.groups;
|
|
6234
|
-
} else {
|
|
6235
|
-
pin.groups.push(group);
|
|
6236
|
-
}
|
|
6562
|
+
const plan = planTagToggle(item, group);
|
|
6563
|
+
if (!plan.add.length && !plan.remove.length) return false;
|
|
6564
|
+
if (registerTypedTags(plan.add)) saveSettingsFile();
|
|
6565
|
+
for (const name of plan.add) setItemTag(item, name, true);
|
|
6566
|
+
for (const name of plan.remove) setItemTag(item, name, false);
|
|
6237
6567
|
touchPinGroups(item);
|
|
6238
6568
|
saveHistory();
|
|
6239
|
-
return true;
|
|
6569
|
+
return { ok: true, added: plan.add, removed: plan.remove };
|
|
6240
6570
|
}
|
|
6241
6571
|
|
|
6242
6572
|
function applyDeleteItem(id, rev) {
|
|
@@ -6279,13 +6609,19 @@ function applyDeleteItems(targets) {
|
|
|
6279
6609
|
// tombstone so a later sync can't resurrect the deletion.
|
|
6280
6610
|
function applyRestoreItems(snapshots) {
|
|
6281
6611
|
let changed = false;
|
|
6612
|
+
const ctx = currentTagContext((Array.isArray(snapshots) ? snapshots : []).map(snap => groupsOf(snap || {})));
|
|
6282
6613
|
for (const snap of (Array.isArray(snapshots) ? snapshots : [])) {
|
|
6283
6614
|
if (!snap) continue;
|
|
6284
|
-
const restored = cloneHistoryItem(snap);
|
|
6615
|
+
const restored = clipboardModel.normalizeItemTags(cloneHistoryItem(snap), ctx);
|
|
6616
|
+
if (registerTags(groupsOf(restored))) changed = true;
|
|
6285
6617
|
ensureItemId(restored);
|
|
6286
6618
|
const id = itemKey(restored);
|
|
6287
6619
|
if (findHistoryIndex(id) >= 0) continue;
|
|
6288
6620
|
settings.tombstones = normalizeTombstones(settings.tombstones).filter(t => t.id !== id);
|
|
6621
|
+
// The delete may already be on a peer or a provider and comes back with their
|
|
6622
|
+
// tombstones: a clock newer than the delete (its time-of-capture clock, so the clip
|
|
6623
|
+
// keeps its place) makes this copy beat it there too, as a re-copy does.
|
|
6624
|
+
restored.tsUpdatedAt = Date.now();
|
|
6289
6625
|
history.push(restored);
|
|
6290
6626
|
changed = true;
|
|
6291
6627
|
}
|
|
@@ -6303,27 +6639,22 @@ function applyRestoreItems(snapshots) {
|
|
|
6303
6639
|
// Explicit (non-toggle) group membership, mirroring applyGroupAssign's pin
|
|
6304
6640
|
// bookkeeping. Returns true if it changed the item.
|
|
6305
6641
|
function setItemGroupMembership(item, group, shouldHave) {
|
|
6306
|
-
|
|
6307
|
-
if (!pin.groups) pin.groups = [];
|
|
6308
|
-
const idx = pin.groups.indexOf(group);
|
|
6309
|
-
const has = idx >= 0;
|
|
6310
|
-
if (!!shouldHave === has) return false;
|
|
6311
|
-
if (shouldHave) pin.groups.push(group);
|
|
6312
|
-
else { pin.groups.splice(idx, 1); if (pin.groups.length === 0) delete pin.groups; }
|
|
6642
|
+
if (!setItemTag(item, group, shouldHave)) return false;
|
|
6313
6643
|
touchPinGroups(item);
|
|
6314
6644
|
return true;
|
|
6315
6645
|
}
|
|
6316
6646
|
|
|
6317
|
-
// Bulk add/remove
|
|
6318
|
-
// decided in the shared controller).
|
|
6647
|
+
// Bulk add/remove tags across many clips in one save (smart tri-state toggle
|
|
6648
|
+
// decided in the shared controller). "todo, forge" adds or removes both.
|
|
6319
6649
|
function applyGroupAssignMany(targets, group, shouldHave) {
|
|
6320
|
-
|
|
6321
|
-
|
|
6650
|
+
const names = tagInputNames(group);
|
|
6651
|
+
if (!names.length) return false;
|
|
6652
|
+
// targets: [{ id, rev }]. All-or-nothing: checked before any tag is even created.
|
|
6322
6653
|
const resolved = assertClipRevisions(targets);
|
|
6323
|
-
if (
|
|
6654
|
+
if (shouldHave && registerTypedTags(names)) saveSettingsFile();
|
|
6324
6655
|
let changed = false;
|
|
6325
6656
|
for (const { item } of resolved) {
|
|
6326
|
-
if (setItemGroupMembership(item,
|
|
6657
|
+
for (const name of names) if (setItemGroupMembership(item, name, !!shouldHave)) changed = true;
|
|
6327
6658
|
}
|
|
6328
6659
|
if (changed) saveHistory();
|
|
6329
6660
|
return changed;
|
|
@@ -6342,17 +6673,15 @@ function applyAddText(text, group) {
|
|
|
6342
6673
|
const value = String(text || '');
|
|
6343
6674
|
if (!value) return false;
|
|
6344
6675
|
addToHistory({ type: 'text', text: value, ts: Date.now() / 1000 }, it => it.type !== 'image' && it.text === value);
|
|
6345
|
-
|
|
6346
|
-
|
|
6676
|
+
const names = group ? tagInputNames(group) : [];
|
|
6677
|
+
if (names.length) {
|
|
6678
|
+
const key = itemKey({ type: 'text', text: value });
|
|
6679
|
+
const item = history.find(it => it.type !== 'image' && itemKey(it) === key);
|
|
6347
6680
|
if (item) {
|
|
6348
|
-
if (
|
|
6349
|
-
|
|
6350
|
-
if (
|
|
6351
|
-
if (
|
|
6352
|
-
pin.groups.push(group);
|
|
6353
|
-
touchPinGroups(item);
|
|
6354
|
-
saveHistory();
|
|
6355
|
-
}
|
|
6681
|
+
if (registerTypedTags(names)) saveSettingsFile();
|
|
6682
|
+
let changed = false;
|
|
6683
|
+
for (const name of names) if (setItemTag(item, name, true)) changed = true;
|
|
6684
|
+
if (changed) { touchPinGroups(item); saveHistory(); }
|
|
6356
6685
|
}
|
|
6357
6686
|
}
|
|
6358
6687
|
return true;
|
|
@@ -6446,30 +6775,46 @@ function mcpUnregisterAll() {
|
|
|
6446
6775
|
|
|
6447
6776
|
// ---- Allowlist / AI group ----
|
|
6448
6777
|
function ensureAiGroupShared() {
|
|
6449
|
-
let changed =
|
|
6450
|
-
if (!settings.groups) settings.groups = [];
|
|
6451
|
-
if (!settings.groups.includes(mcpCore.AI_GROUP_NAME)) {
|
|
6452
|
-
applyGroupCreate(mcpCore.AI_GROUP_NAME);
|
|
6453
|
-
}
|
|
6778
|
+
let changed = registerTags([mcpCore.AI_GROUP_NAME]);
|
|
6454
6779
|
if (!Array.isArray(settings.groups_shared_with_ai)) settings.groups_shared_with_ai = [];
|
|
6455
|
-
if (!settings.groups_shared_with_ai.
|
|
6780
|
+
if (!settings.groups_shared_with_ai.some(g => clipSearch.sameTag(g, mcpCore.AI_GROUP_NAME))) {
|
|
6456
6781
|
settings.groups_shared_with_ai.push(mcpCore.AI_GROUP_NAME);
|
|
6457
6782
|
changed = true;
|
|
6458
6783
|
}
|
|
6459
6784
|
if (changed) saveSettingsFile();
|
|
6460
6785
|
}
|
|
6461
6786
|
|
|
6462
|
-
|
|
6463
|
-
|
|
6787
|
+
// One flip of a tag's AI share switch: the list here, plus a stamp that carries the
|
|
6788
|
+
// flip to the other devices (newest flip per tag wins there).
|
|
6789
|
+
function stampShareSwitch(name, shared, at = Date.now()) {
|
|
6790
|
+
const key = clipSearch.tagKey(name);
|
|
6791
|
+
if (!key) return false;
|
|
6464
6792
|
if (!Array.isArray(settings.groups_shared_with_ai)) settings.groups_shared_with_ai = [];
|
|
6465
|
-
const has = settings.groups_shared_with_ai.
|
|
6466
|
-
if (shared && !has) settings.groups_shared_with_ai.push(name);
|
|
6467
|
-
else if (!shared && has) settings.groups_shared_with_ai = settings.groups_shared_with_ai.filter(g => g !==
|
|
6468
|
-
|
|
6793
|
+
const has = settings.groups_shared_with_ai.some(g => clipSearch.tagKey(g) === key);
|
|
6794
|
+
if (shared && !has) settings.groups_shared_with_ai.push(clipSearch.cleanTagName(name));
|
|
6795
|
+
else if (!shared && has) settings.groups_shared_with_ai = settings.groups_shared_with_ai.filter(g => clipSearch.tagKey(g) !== key);
|
|
6796
|
+
settings.ai_share_stamps = clipboardModel.mergeShareStamps(settings.ai_share_stamps, { [key]: { name: clipSearch.cleanTagName(name), shared: !!shared, at } });
|
|
6797
|
+
return shared !== has;
|
|
6798
|
+
}
|
|
6799
|
+
|
|
6800
|
+
// A share switch takes one existing tag, never typed input: splitting "a, b" here
|
|
6801
|
+
// would share two tags at once.
|
|
6802
|
+
function setGroupSharedWithAi(name, shared) {
|
|
6803
|
+
const key = clipSearch.tagKey(name);
|
|
6804
|
+
const stored = (settings.groups || []).find(g => clipSearch.tagKey(g) === key) || (clipSearch.sameTag(name, mcpCore.AI_GROUP_NAME) ? mcpCore.AI_GROUP_NAME : '');
|
|
6805
|
+
if (!stored) return;
|
|
6806
|
+
stampShareSwitch(stored, shared);
|
|
6469
6807
|
saveSettingsFile();
|
|
6470
6808
|
notifyAiAccessChanged();
|
|
6471
6809
|
}
|
|
6472
6810
|
|
|
6811
|
+
let aiAccessNotifyTimer = null;
|
|
6812
|
+
function notifyAiAccessChangedSoon() {
|
|
6813
|
+
if (aiAccessNotifyTimer) return;
|
|
6814
|
+
aiAccessNotifyTimer = setTimeout(() => { aiAccessNotifyTimer = null; notifyAiAccessChanged(); }, 50);
|
|
6815
|
+
if (aiAccessNotifyTimer.unref) aiAccessNotifyTimer.unref();
|
|
6816
|
+
}
|
|
6817
|
+
|
|
6473
6818
|
function isItemSharedWithAi(item) {
|
|
6474
6819
|
return item ? mcpCore.isShared(item, mcpCore.sharedGroupSet(settings)) : false;
|
|
6475
6820
|
}
|
|
@@ -6601,6 +6946,36 @@ function requestApproval(request, { signal } = {}) {
|
|
|
6601
6946
|
});
|
|
6602
6947
|
}
|
|
6603
6948
|
|
|
6949
|
+
// ---- AI tag tools: the planned change, read by both the gate and the prompt ----
|
|
6950
|
+
// The clip an add_clip lands on when its text is already in history: the add moves
|
|
6951
|
+
// that clip to the top and tags it (applyAddText), it makes no second one.
|
|
6952
|
+
function mcpAddTarget(text) {
|
|
6953
|
+
const value = String(text || '');
|
|
6954
|
+
if (!value) return null;
|
|
6955
|
+
return history.find(it => it.type !== 'image' && it.text === value)
|
|
6956
|
+
|| findHistoryItem(clipboardModel.itemKey({ type: 'text', text: value }));
|
|
6957
|
+
}
|
|
6958
|
+
// What an AI tag change does to a clip: assign_group's toggle (planTagToggle) or
|
|
6959
|
+
// add_clip's tags (added only). `after` = the clip's tags once it ran.
|
|
6960
|
+
function plannedTagChange(item, tool, input) {
|
|
6961
|
+
const before = groupsOf(item);
|
|
6962
|
+
if (tool === 'add_clip') {
|
|
6963
|
+
const add = input ? tagInputNames(input).filter(name => !itemHasTag(item, name)) : [];
|
|
6964
|
+
return { add, remove: [], after: [...before, ...add] };
|
|
6965
|
+
}
|
|
6966
|
+
const plan = planTagToggle(item, input);
|
|
6967
|
+
const after = plan.add.length ? [...before, ...plan.add] : before.filter(g => !plan.remove.some(name => clipSearch.sameTag(name, g)));
|
|
6968
|
+
return { ...plan, after };
|
|
6969
|
+
}
|
|
6970
|
+
// Would a clip with these tags be readable by AI?
|
|
6971
|
+
function tagsVisibleToAi(names) {
|
|
6972
|
+
return mcpCore.isShared({ pin: { groups: names } }, mcpCore.sharedGroupSet(settings));
|
|
6973
|
+
}
|
|
6974
|
+
// The tag a name refers to (any case), as delete_group and the prompt see it.
|
|
6975
|
+
function existingTag(name) {
|
|
6976
|
+
return (settings.groups || []).find(g => clipSearch.sameTag(g, name)) || null;
|
|
6977
|
+
}
|
|
6978
|
+
|
|
6604
6979
|
function buildApprovalRequest(tool, args, targetItem, client) {
|
|
6605
6980
|
const dangerTools = new Set(['delete_clip', 'edit_clip', 'copy_to_clipboard', 'paste_clip']);
|
|
6606
6981
|
// The prompt must say, in plain words, WHAT will happen, to WHICH clip, what
|
|
@@ -6614,7 +6989,41 @@ function buildApprovalRequest(tool, args, targetItem, client) {
|
|
|
6614
6989
|
const shared = item ? isItemSharedWithAi(item) : false;
|
|
6615
6990
|
const quote = s => `\u201c${String(s || '').replace(/\s+/g, ' ').trim().slice(0, 60)}\u201d`;
|
|
6616
6991
|
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()) || ''))) : '';
|
|
6992
|
+
// Group names in a sentence: “a”, “b” and “c”.
|
|
6993
|
+
const nameList = names => {
|
|
6994
|
+
const q = names.map(quote);
|
|
6995
|
+
return q.length < 2 ? (q[0] || '') : `${q.slice(0, -1).join(', ')} and ${q[q.length - 1]}`;
|
|
6996
|
+
};
|
|
6997
|
+
const tagWord = names => (names.length === 1 ? 'group' : 'groups');
|
|
6998
|
+
const tagsText = names => (names.length ? names.join(', ') : 'none');
|
|
6999
|
+
// A retired nested name in the input says what it now means.
|
|
7000
|
+
const splits = clipboardModel.splitMapOf(settings);
|
|
7001
|
+
const retiredNote = input => clipSearch.splitTagInput(input)
|
|
7002
|
+
.filter(name => !existingTag(name) && splits.has(clipSearch.tagKey(name)))
|
|
7003
|
+
.map(name => {
|
|
7004
|
+
const parts = splits.get(clipSearch.tagKey(name));
|
|
7005
|
+
return `${quote(name)} is an old nested name for the ${tagWord(parts)} ${nameList(parts)}.`;
|
|
7006
|
+
})
|
|
7007
|
+
.join(' ');
|
|
7008
|
+
// What the change does to what AI assistants can read.
|
|
7009
|
+
const sharedSet = mcpCore.sharedGroupSet(settings);
|
|
7010
|
+
const sharingNote = (before, after) => {
|
|
7011
|
+
const was = tagsVisibleToAi(before);
|
|
7012
|
+
const will = tagsVisibleToAi(after);
|
|
7013
|
+
if (!was && will) {
|
|
7014
|
+
const via = after.filter(g => sharedSet.has(g));
|
|
7015
|
+
return `AI assistants will then be able to read this clip, because ${nameList(via)} ${via.length === 1 ? 'is' : 'are'} shared with AI.`;
|
|
7016
|
+
}
|
|
7017
|
+
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.';
|
|
7018
|
+
return '';
|
|
7019
|
+
};
|
|
7020
|
+
const sentences = (...parts) => parts.filter(Boolean).join(' ');
|
|
6617
7021
|
const facts = [];
|
|
7022
|
+
// The Groups fact shows the change: before -> after.
|
|
7023
|
+
const tagFact = (before, after) => {
|
|
7024
|
+
const row = facts.find(f => f[0] === 'Groups');
|
|
7025
|
+
if (row) row[1] = `${tagsText(before)} \u2192 ${tagsText(after)}`;
|
|
7026
|
+
};
|
|
6618
7027
|
let preview = null;
|
|
6619
7028
|
if (item) {
|
|
6620
7029
|
textBlobStore.hydrateTextItem(item, TEXT_DIR);
|
|
@@ -6622,25 +7031,37 @@ function buildApprovalRequest(tool, args, targetItem, client) {
|
|
|
6622
7031
|
const lines = text ? text.split(/\r?\n/).length : 0;
|
|
6623
7032
|
facts.push(['Clip', item.type === 'image' ? `image ${item.width || '?'}x${item.height || '?'}` : `${text.length} chars, ${lines} line${lines === 1 ? '' : 's'}`]);
|
|
6624
7033
|
if (item.ts) facts.push(['Captured', new Date(item.ts * 1000).toLocaleString()]);
|
|
6625
|
-
facts.push(['Groups', groups
|
|
7034
|
+
facts.push(['Groups', tagsText(groups)]);
|
|
6626
7035
|
if (item.pin && typeof item.pin.number === 'number') facts.push(['Numpad', `slot ${item.pin.number}`]);
|
|
6627
7036
|
if (item.type !== 'image') preview = { label: 'Clip text', text };
|
|
6628
7037
|
}
|
|
6629
7038
|
let title = '';
|
|
6630
7039
|
let explain = '';
|
|
6631
7040
|
let why = '';
|
|
6632
|
-
const gatedWhy = 'This action always asks, whatever
|
|
7041
|
+
const gatedWhy = 'This action always asks, whatever groups the clip is in.';
|
|
7042
|
+
const privateWhy = 'Asking because this clip is in no group shared with AI.';
|
|
6633
7043
|
const sharedWhy = shared
|
|
6634
7044
|
? 'The clip is in a group shared with AI; asking because you have not allowed this action for the session.'
|
|
6635
|
-
:
|
|
7045
|
+
: privateWhy;
|
|
6636
7046
|
switch (tool) {
|
|
6637
7047
|
case 'assign_group': {
|
|
6638
|
-
|
|
6639
|
-
|
|
6640
|
-
|
|
6641
|
-
|
|
6642
|
-
|
|
6643
|
-
|
|
7048
|
+
// main's own toggle rule (planTagToggle): the named tags it lacks are
|
|
7049
|
+
// added; if it has them all, they are all removed.
|
|
7050
|
+
const plan = item ? plannedTagChange(item, tool, a.group) : { add: [], remove: [], after: groups };
|
|
7051
|
+
const already = plan.add.length ? tagInputNames(a.group).filter(name => itemHasTag(item, name)) : [];
|
|
7052
|
+
const unchanged = 'Its text is not changed and nothing is deleted.';
|
|
7053
|
+
if (plan.add.length) {
|
|
7054
|
+
title = `Add clip to ${tagWord(plan.add)} ${nameList(plan.add)}${already.length ? ` (it is already in ${nameList(already)})` : ''}`;
|
|
7055
|
+
explain = sentences(`The clip ${clipLabel} will be added to ${tagWord(plan.add)} ${nameList(plan.add)}.`, unchanged);
|
|
7056
|
+
} else if (plan.remove.length) {
|
|
7057
|
+
title = `Remove clip from ${tagWord(plan.remove)} ${nameList(plan.remove)}`;
|
|
7058
|
+
explain = sentences(`The clip ${clipLabel} will leave the ${tagWord(plan.remove)} ${nameList(plan.remove)}.`, unchanged);
|
|
7059
|
+
} else {
|
|
7060
|
+
title = "Change a clip's groups";
|
|
7061
|
+
explain = 'No group was named, so nothing will change.';
|
|
7062
|
+
}
|
|
7063
|
+
explain = sentences(explain, retiredNote(a.group), sharingNote(groups, plan.after));
|
|
7064
|
+
tagFact(groups, plan.after);
|
|
6644
7065
|
why = sharedWhy;
|
|
6645
7066
|
break;
|
|
6646
7067
|
}
|
|
@@ -6679,22 +7100,62 @@ function buildApprovalRequest(tool, args, targetItem, client) {
|
|
|
6679
7100
|
why = gatedWhy;
|
|
6680
7101
|
break;
|
|
6681
7102
|
}
|
|
6682
|
-
case 'add_clip':
|
|
6683
|
-
|
|
6684
|
-
|
|
6685
|
-
|
|
6686
|
-
|
|
7103
|
+
case 'add_clip': {
|
|
7104
|
+
const sessionWhy = 'Asking because you have not allowed clip creation for the session.';
|
|
7105
|
+
if (item) {
|
|
7106
|
+
// The text is already a clip: the add moves and tags that clip.
|
|
7107
|
+
const plan = plannedTagChange(item, tool, a.group);
|
|
7108
|
+
title = plan.add.length ? `Add an existing clip to ${tagWord(plan.add)} ${nameList(plan.add)}` : 'Move an existing clip to the top';
|
|
7109
|
+
explain = sentences(
|
|
7110
|
+
`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)}` : ''}.`,
|
|
7111
|
+
retiredNote(a.group),
|
|
7112
|
+
sharingNote(groups, plan.after),
|
|
7113
|
+
);
|
|
7114
|
+
tagFact(groups, plan.after);
|
|
7115
|
+
if (!tagsVisibleToAi(groups) && tagsVisibleToAi(plan.after)) why = 'Asking because this would let the AI read a clip it cannot read now.';
|
|
7116
|
+
else if (!shared) why = 'Asking because this text is already a clip the AI cannot read.';
|
|
7117
|
+
else why = sessionWhy;
|
|
7118
|
+
} else {
|
|
7119
|
+
const names = a.group ? tagInputNames(a.group) : [];
|
|
7120
|
+
title = 'Add a new clip';
|
|
7121
|
+
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));
|
|
7122
|
+
why = sessionWhy;
|
|
7123
|
+
preview = { label: 'New clip text', text: String(a.text || '') };
|
|
7124
|
+
}
|
|
6687
7125
|
break;
|
|
6688
|
-
|
|
6689
|
-
|
|
6690
|
-
|
|
6691
|
-
|
|
7126
|
+
}
|
|
7127
|
+
case 'create_group': {
|
|
7128
|
+
const names = tagInputNames(a.name);
|
|
7129
|
+
const fresh = names.filter(name => !existingTag(name));
|
|
7130
|
+
const have = names.filter(name => existingTag(name));
|
|
7131
|
+
title = names.length ? `Create ${tagWord(names)} ${nameList(names)}` : 'Create a group';
|
|
7132
|
+
explain = sentences(
|
|
7133
|
+
fresh.length ? `${fresh.length === 1 ? 'A new, empty group' : 'New, empty groups'} ${nameList(fresh)} will appear in your filters.` : '',
|
|
7134
|
+
have.length ? `${nameList(have)} already ${have.length === 1 ? 'exists' : 'exist'}, so nothing changes for ${have.length === 1 ? 'it' : 'them'}.` : '',
|
|
7135
|
+
names.length ? retiredNote(a.name) : 'No group was named, so nothing will change.',
|
|
7136
|
+
);
|
|
7137
|
+
why = 'Asking because you have not allowed creating groups for the session.';
|
|
6692
7138
|
break;
|
|
6693
|
-
|
|
6694
|
-
|
|
6695
|
-
|
|
6696
|
-
|
|
7139
|
+
}
|
|
7140
|
+
case 'delete_group': {
|
|
7141
|
+
// One group by its full name, any case; never split (this is destructive).
|
|
7142
|
+
const tag = existingTag(a.name);
|
|
7143
|
+
const tagged = tag ? history.filter(h => itemHasTag(h, tag)) : [];
|
|
7144
|
+
const hidden = tag && sharedSet.has(tag)
|
|
7145
|
+
? tagged.filter(h => !tagsVisibleToAi(groupsOf(h).filter(g => !clipSearch.sameTag(g, tag)))).length
|
|
7146
|
+
: 0;
|
|
7147
|
+
title = `Delete group ${quote(tag || a.name)}`;
|
|
7148
|
+
explain = tag
|
|
7149
|
+
? sentences(
|
|
7150
|
+
tagged.length
|
|
7151
|
+
? `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.`
|
|
7152
|
+
: `The group ${quote(tag)} will be removed from your filters. No clip is in it.`,
|
|
7153
|
+
hidden ? `AI assistants will no longer be able to read ${hidden === tagged.length ? (hidden === 1 ? 'it' : 'them') : `${hidden} of them`}.` : '',
|
|
7154
|
+
)
|
|
7155
|
+
: `There is no group named ${quote(a.name)}, so nothing will change.`;
|
|
7156
|
+
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.';
|
|
6697
7157
|
break;
|
|
7158
|
+
}
|
|
6698
7159
|
case 'copy_to_clipboard':
|
|
6699
7160
|
title = 'Put text on your clipboard';
|
|
6700
7161
|
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.`;
|
|
@@ -6709,11 +7170,11 @@ function buildApprovalRequest(tool, args, targetItem, client) {
|
|
|
6709
7170
|
case 'read_clip':
|
|
6710
7171
|
title = 'Let the AI read a private clip';
|
|
6711
7172
|
explain = `The full text of the clip ${clipLabel} will be sent to the AI. Nothing in your history changes.`;
|
|
6712
|
-
why =
|
|
7173
|
+
why = privateWhy;
|
|
6713
7174
|
break;
|
|
6714
7175
|
case 'search_all':
|
|
6715
7176
|
title = 'Search all clips, private ones included';
|
|
6716
|
-
explain = `Matching clips (title and a short preview) from your ENTIRE history, including
|
|
7177
|
+
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.`;
|
|
6717
7178
|
why = gatedWhy;
|
|
6718
7179
|
preview = { label: 'Query', text: String(a.query || '') };
|
|
6719
7180
|
break;
|
|
@@ -6756,32 +7217,97 @@ function previewForItem(item) {
|
|
|
6756
7217
|
// like delete_clip rather than being free-on-shared. Users can still grant a
|
|
6757
7218
|
// per-tool "always allow" from the modal to make repeated edits frictionless.
|
|
6758
7219
|
const MCP_ALWAYS_GATED = new Set(['delete_clip', 'edit_clip', 'copy_to_clipboard', 'paste_clip', 'read_clip', 'search_all', 'image_path']);
|
|
7220
|
+
// Free on what the AI can already read, asked on anything else.
|
|
6759
7221
|
const MCP_MANAGE_FREE_ON_SHARED = new Set(['pin_clip', 'set_numpad', 'assign_group', 'create_group', 'delete_group', 'add_clip']);
|
|
6760
7222
|
|
|
6761
|
-
function mcpNeedsApproval(tool, targetItem) {
|
|
7223
|
+
function mcpNeedsApproval(tool, targetItem, args = {}) {
|
|
6762
7224
|
if (mcpSessionAllow.has(tool)) return false;
|
|
6763
7225
|
if (Array.isArray(settings.ai_always_allow) && settings.ai_always_allow.includes(tool)) return false;
|
|
6764
7226
|
if (MCP_ALWAYS_GATED.has(tool)) return true;
|
|
6765
7227
|
if (MCP_MANAGE_FREE_ON_SHARED.has(tool)) {
|
|
6766
|
-
|
|
7228
|
+
// Naming a group not shared with AI stays free (agents file into private groups,
|
|
7229
|
+
// e.g. "lawyer notes"): the answer echoes such a name as typed, the same for an
|
|
7230
|
+
// existing private group as for a brand-new one (mcpCore.groupToolView), so a free
|
|
7231
|
+
// answer tells nothing, while a prompt only for existing ones would.
|
|
7232
|
+
// add_clip's target is the clip that already holds its text (mcpAddTarget): the
|
|
7233
|
+
// add moves (and may tag) that clip, so it is free only on a clip the AI can
|
|
7234
|
+
// read, as every other change is. A free add onto a private clip would also
|
|
7235
|
+
// tell the AI whether a guessed text is in the history (no new clip is made).
|
|
7236
|
+
if (tool === 'add_clip') return !!targetItem && !isItemSharedWithAi(targetItem);
|
|
7237
|
+
// A group not shared with AI holds clips the AI cannot read: deleting it asks.
|
|
7238
|
+
if (tool === 'delete_group') {
|
|
7239
|
+
const tag = existingTag(args.name);
|
|
7240
|
+
return !!tag && !mcpCore.sharedGroupSet(settings).has(tag);
|
|
7241
|
+
}
|
|
7242
|
+
if (!targetItem) return false; // create_group
|
|
6767
7243
|
return !isItemSharedWithAi(targetItem); // free on shared clips, gated on non-shared
|
|
6768
7244
|
}
|
|
6769
7245
|
return true;
|
|
6770
7246
|
}
|
|
6771
7247
|
|
|
7248
|
+
// ---- The AI's ids and revs (the lib/mcp-core boundary) ----
|
|
7249
|
+
// An AI names a clip it can read by its real id and any other by the opaque handle it
|
|
7250
|
+
// was given, and every rev it holds is keyed. mcpResolveArgs turns them into the
|
|
7251
|
+
// app's own before anything runs (or any prompt opens), refusing in the AI's own
|
|
7252
|
+
// terms an id it may not address or a rev that moved; the apply* primitives then
|
|
7253
|
+
// check the real rev again after a (possibly long) approval.
|
|
7254
|
+
const MCP_ID_TOOLS = new Set([...MCP_REV_GUARDED_TOOLS, 'read_clip', 'image_path', 'copy_to_clipboard', 'paste_clip']);
|
|
7255
|
+
function mcpConflict(code, details) {
|
|
7256
|
+
return new clipRevisionCheck.RevisionConflict(code, details);
|
|
7257
|
+
}
|
|
7258
|
+
function mcpResolveArgs(tool, args) {
|
|
7259
|
+
if (!MCP_ID_TOOLS.has(tool) || (tool === 'copy_to_clipboard' && args.text != null)) return { args, item: null };
|
|
7260
|
+
const id = String(args.id == null ? '' : args.id);
|
|
7261
|
+
const found = mcpCore.resolvePublicId(history, settings, id);
|
|
7262
|
+
if (MCP_REV_GUARDED_TOOLS.has(tool)) {
|
|
7263
|
+
const rev = typeof args.expected_rev === 'string' ? args.expected_rev.trim() : '';
|
|
7264
|
+
if (!rev) throw mcpConflict('rev_required', { id });
|
|
7265
|
+
if (!found.item) throw found.movedTo ? mcpConflict('superseded', { id, currentId: found.movedTo }) : mcpConflict('not_found', { id });
|
|
7266
|
+
const currentRev = mcpCore.publicRev(found.item, settings);
|
|
7267
|
+
if (rev !== currentRev) throw mcpConflict('stale_revision', { id, expectedRev: rev, currentRev });
|
|
7268
|
+
return { args: { ...args, id: found.realId, expected_rev: clipboardModel.clipRevision(found.item) }, item: found.item };
|
|
7269
|
+
}
|
|
7270
|
+
if (!found.item) throw new Error(found.movedTo ? `Clip ${id} was edited and is now clip ${found.movedTo}.` : `No clip with id "${id}".`);
|
|
7271
|
+
return { args: { ...args, id: found.realId }, item: found.item };
|
|
7272
|
+
}
|
|
7273
|
+
// A conflict the apply* primitives raised after an approval, in the AI's terms.
|
|
7274
|
+
function mcpPublicError(err, publicArgs) {
|
|
7275
|
+
if (!clipRevisionCheck.isRevisionConflict(err)) return err;
|
|
7276
|
+
const d = err.details || {};
|
|
7277
|
+
const id = String(publicArgs.id == null ? '' : publicArgs.id);
|
|
7278
|
+
if (err.code === 'stale_revision') {
|
|
7279
|
+
const item = findHistoryItem(d.id);
|
|
7280
|
+
return mcpConflict('stale_revision', { id, expectedRev: publicArgs.expected_rev, currentRev: item ? mcpCore.publicRev(item, settings) : null });
|
|
7281
|
+
}
|
|
7282
|
+
if (err.code === 'superseded') {
|
|
7283
|
+
const current = findHistoryItem(d.currentId);
|
|
7284
|
+
return current ? mcpConflict('superseded', { id, currentId: mcpCore.publicId(current, settings) }) : mcpConflict('not_found', { id });
|
|
7285
|
+
}
|
|
7286
|
+
return mcpConflict(err.code, { id });
|
|
7287
|
+
}
|
|
7288
|
+
|
|
6772
7289
|
async function mcpHandleRequest(reqPath, payload, ctx = {}) {
|
|
6773
|
-
const { tool, args = {}, client } = payload || {};
|
|
7290
|
+
const { tool, args: publicArgs = {}, client } = payload || {};
|
|
6774
7291
|
if (!tool) throw new Error('missing_tool');
|
|
6775
7292
|
if (!settings.ai_access_enabled) throw new Error('ai_access_disabled');
|
|
6776
|
-
|
|
6777
|
-
|
|
6778
|
-
|
|
6779
|
-
|
|
6780
|
-
|
|
6781
|
-
|
|
7293
|
+
// The AI group is the one every assistant can always read: never deleted (the
|
|
7294
|
+
// app's own UI cannot delete it either), and no prompt asks about it.
|
|
7295
|
+
if (tool === 'delete_group' && clipSearch.sameTag(publicArgs.name, mcpCore.AI_GROUP_NAME)) {
|
|
7296
|
+
diagnostics.record('mcp.tool', { tool, client, decision: 'refused' }, { forceFile: true });
|
|
7297
|
+
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.` };
|
|
7298
|
+
}
|
|
7299
|
+
// Refuse a stale/missing rev (or an id the AI may not name) BEFORE asking the
|
|
7300
|
+
// human to approve a change that would be refused anyway.
|
|
7301
|
+
const { args, item: resolvedItem } = mcpResolveArgs(tool, publicArgs);
|
|
7302
|
+
// The app's own check on the real id and rev, as every caller's (mcpExecute's
|
|
7303
|
+
// primitives repeat it after the approval, since the clip can change meanwhile).
|
|
7304
|
+
if (MCP_REV_GUARDED_TOOLS.has(tool)) {
|
|
7305
|
+
try { assertClipRevision(args.id, args.expected_rev); } catch (err) { throw mcpPublicError(err, publicArgs); }
|
|
7306
|
+
}
|
|
7307
|
+
const targetItem = tool === 'add_clip' ? mcpAddTarget(args.text) : resolvedItem;
|
|
6782
7308
|
|
|
6783
7309
|
let decision = 'auto';
|
|
6784
|
-
if (mcpNeedsApproval(tool, targetItem)) {
|
|
7310
|
+
if (mcpNeedsApproval(tool, targetItem, args)) {
|
|
6785
7311
|
decision = await requestApproval(buildApprovalRequest(tool, args, targetItem, client), { signal: ctx.signal });
|
|
6786
7312
|
if (decision === 'deny') throw new Error('denied');
|
|
6787
7313
|
if (decision === 'timeout') throw new Error('timed_out');
|
|
@@ -6797,18 +7323,23 @@ async function mcpHandleRequest(reqPath, payload, ctx = {}) {
|
|
|
6797
7323
|
// Re-check after the (possibly long) approval await: if AI access was toggled
|
|
6798
7324
|
// off while the modal was open, refuse before performing the action.
|
|
6799
7325
|
if (!settings.ai_access_enabled) throw new Error('ai_access_disabled');
|
|
6800
|
-
|
|
7326
|
+
try {
|
|
7327
|
+
return await mcpExecute(tool, args, ctx, publicArgs);
|
|
7328
|
+
} catch (err) {
|
|
7329
|
+
throw mcpPublicError(err, publicArgs);
|
|
7330
|
+
}
|
|
6801
7331
|
}
|
|
6802
7332
|
|
|
6803
|
-
|
|
7333
|
+
// `args` are the app's own (real ids, real revs, mcpResolveArgs); `publicArgs` what
|
|
7334
|
+
// the AI sent. Every result goes back in the AI's terms (lib/mcp-core boundary).
|
|
7335
|
+
function mcpExecute(tool, args, ctx = {}, publicArgs = args) {
|
|
6804
7336
|
switch (tool) {
|
|
6805
7337
|
case 'read_clip': {
|
|
6806
7338
|
const item = findHistoryItem(args.id);
|
|
6807
7339
|
if (!item) throw new Error('not_found');
|
|
6808
|
-
|
|
6809
|
-
if (item.type === 'image') return mcpCore.clipView(item, { sharedSet });
|
|
7340
|
+
if (item.type === 'image') return mcpCore.clipView(item, { settings });
|
|
6810
7341
|
textBlobStore.hydrateTextItem(item, TEXT_DIR);
|
|
6811
|
-
return { ...mcpCore.fullTextResult(item,
|
|
7342
|
+
return { ...mcpCore.fullTextResult(item, { settings }), viaApproval: true };
|
|
6812
7343
|
}
|
|
6813
7344
|
case 'search_all':
|
|
6814
7345
|
// A /regex/ runs on a worker thread: it must never hang this process.
|
|
@@ -6832,20 +7363,46 @@ function mcpExecute(tool, args, ctx = {}) {
|
|
|
6832
7363
|
newTitle: args.title != null ? String(args.title) : undefined,
|
|
6833
7364
|
});
|
|
6834
7365
|
const saved = result && result.item ? result.item : item;
|
|
6835
|
-
return { ok: !!(result && result.changed), reason: result && result.reason, id:
|
|
7366
|
+
return { ok: !!(result && result.changed), reason: result && result.reason, id: mcpCore.publicId(saved, settings), rev: mcpCore.publicRev(saved, settings) };
|
|
6836
7367
|
}
|
|
6837
7368
|
case 'pin_clip':
|
|
6838
7369
|
return mcpMutationResult(args.id, applyPinToggle(args.id, args.expected_rev));
|
|
6839
7370
|
case 'set_numpad':
|
|
6840
7371
|
return mcpMutationResult(args.id, applyNumpadAssign(args.id, args.slot, args.expected_rev));
|
|
6841
|
-
case 'assign_group':
|
|
6842
|
-
|
|
6843
|
-
|
|
6844
|
-
|
|
6845
|
-
|
|
6846
|
-
|
|
7372
|
+
case 'assign_group': {
|
|
7373
|
+
// What the AI typed, read before the change (the names it means may be created).
|
|
7374
|
+
// The answer names only groups shared with AI (mcpCore.groupToolView): whether a
|
|
7375
|
+
// private group existed, or was on the clip, is never in it.
|
|
7376
|
+
const tokens = mcpCore.namedGroupTokens(args.group, settings);
|
|
7377
|
+
const result = applyGroupAssign(args.id, args.group, args.expected_rev);
|
|
7378
|
+
const item = findHistoryItem(args.id);
|
|
7379
|
+
const view = mcpCore.groupToolView(tokens, settings, { added: result ? result.added : [], removed: result ? result.removed : [] });
|
|
7380
|
+
const out = {
|
|
7381
|
+
...mcpMutationResult(args.id, result),
|
|
7382
|
+
added: view.added,
|
|
7383
|
+
removed: view.removed,
|
|
7384
|
+
groups: item ? mcpCore.visibleGroups(item, mcpCore.sharedGroupSet(settings)) : [],
|
|
7385
|
+
...(view.notShared ? { notShared: view.notShared } : {}),
|
|
7386
|
+
};
|
|
7387
|
+
if (!result) out.reason = 'No group was named, so nothing changed.';
|
|
7388
|
+
return out;
|
|
7389
|
+
}
|
|
7390
|
+
case 'create_group': {
|
|
7391
|
+
const tokens = mcpCore.namedGroupTokens(args.name, settings);
|
|
7392
|
+
const created = applyGroupCreate(args.name);
|
|
7393
|
+
if (!created) return { ok: false, reason: 'No group was named, so nothing changed.' };
|
|
7394
|
+
return { ok: true, ...mcpCore.groupToolView(tokens, settings, { groups: created }) };
|
|
7395
|
+
}
|
|
7396
|
+
case 'delete_group': {
|
|
7397
|
+
// One group by its full name (any case), never split: a comma or a '/' is part of it.
|
|
7398
|
+
const shared = mcpCore.sharedGroupSet(settings).has(args.name);
|
|
7399
|
+
const deleted = applyGroupDelete(args.name);
|
|
7400
|
+
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.` };
|
|
7401
|
+
// A group not shared with AI: only the name as the AI typed it, nothing about its clips.
|
|
7402
|
+
return shared ? { ok: true, name: deleted.name, clips: deleted.ids.length } : { ok: true, name: clipSearch.cleanTagName(args.name) };
|
|
7403
|
+
}
|
|
6847
7404
|
case 'delete_clip':
|
|
6848
|
-
return { ok: applyDeleteItem(args.id, args.expected_rev), id:
|
|
7405
|
+
return { ok: applyDeleteItem(args.id, args.expected_rev), id: String(publicArgs.id) };
|
|
6849
7406
|
case 'copy_to_clipboard': {
|
|
6850
7407
|
if (args.text != null) { clipboard.writeText(String(args.text)); return { ok: true }; }
|
|
6851
7408
|
const item = findHistoryItem(args.id);
|
|
@@ -6913,6 +7470,9 @@ function setupIPC() {
|
|
|
6913
7470
|
quickPaste: effectiveQuickPasteShortcut(),
|
|
6914
7471
|
quickPasteCustom: !!settings.quick_paste_shortcut,
|
|
6915
7472
|
quickPasteDefault: defaultQuickPasteShortcut(),
|
|
7473
|
+
newNote: effectiveNewNoteShortcut(),
|
|
7474
|
+
newNoteCustom: !!settings.new_note_shortcut,
|
|
7475
|
+
newNoteDefault: defaultNewNoteShortcut(),
|
|
6916
7476
|
windows_hook: process.platform === 'win32',
|
|
6917
7477
|
},
|
|
6918
7478
|
}));
|
|
@@ -7039,6 +7599,7 @@ function setupIPC() {
|
|
|
7039
7599
|
|
|
7040
7600
|
ipcMain.handle('set-show-shortcut', (_, shortcut) => setShowShortcut(shortcut));
|
|
7041
7601
|
ipcMain.handle('set-quick-paste-shortcut', (_, shortcut) => setQuickPasteShortcut(shortcut));
|
|
7602
|
+
ipcMain.handle('set-new-note-shortcut', (_, shortcut) => setNewNoteShortcut(shortcut));
|
|
7042
7603
|
ipcMain.handle('suspend-shortcuts', () => suspendShortcutsForRecording());
|
|
7043
7604
|
ipcMain.handle('resume-shortcuts', () => resumeShortcutsAfterRecording());
|
|
7044
7605
|
ipcMain.handle('resolve-show-shortcut', (_, shortcut) => {
|
|
@@ -7049,6 +7610,7 @@ function setupIPC() {
|
|
|
7049
7610
|
ipcMain.handle('group-create', (_, name) => applyGroupCreate(name));
|
|
7050
7611
|
|
|
7051
7612
|
ipcMain.handle('group-delete', (_, name) => applyGroupDelete(name));
|
|
7613
|
+
ipcMain.handle('group-restore', (_, payload) => applyGroupRestore(payload));
|
|
7052
7614
|
|
|
7053
7615
|
ipcMain.handle('group-assign', (_, id, group, rev) => applyGroupAssign(id, group, rev));
|
|
7054
7616
|
|
|
@@ -7372,56 +7934,49 @@ async function handleQuickPaste(slot) {
|
|
|
7372
7934
|
await runNumpadSlotAction(slot, { source: 'shortcut' });
|
|
7373
7935
|
}
|
|
7374
7936
|
|
|
7375
|
-
|
|
7376
|
-
|
|
7377
|
-
|
|
7378
|
-
|
|
7379
|
-
|
|
7937
|
+
// The global new-note shortcut (Win+N through the hook, or a recorded chord):
|
|
7938
|
+
// the popup's New note action, as a hand-off.
|
|
7939
|
+
function handleNewNoteShortcut() {
|
|
7940
|
+
diagnostics.record('shortcut.new_note', { popup_open: isPopupOpen() });
|
|
7941
|
+
if (process.platform === 'darwin') app.focus({ steal: true }); // an accessory app is not active
|
|
7942
|
+
openEditor(null, {});
|
|
7943
|
+
}
|
|
7944
|
+
|
|
7945
|
+
// Why a recorded chord cannot be saved ('' = it can). Only the popup
|
|
7946
|
+
// shortcut takes Globe (macOS's own hotkey path); `key` names what follows
|
|
7947
|
+
// the modifiers in the hint.
|
|
7948
|
+
function recordedShortcutError(shortcut, { globe = false, key = 'a key' } = {}) {
|
|
7949
|
+
if (shortcutUsesFn(shortcut)) {
|
|
7950
|
+
if (!globe) return 'Globe shortcuts are only supported for the popup shortcut.';
|
|
7951
|
+
if (process.platform !== 'darwin') return 'Globe shortcuts are only supported on macOS.';
|
|
7380
7952
|
}
|
|
7381
|
-
if (
|
|
7953
|
+
if (!shortcutHasKeyAndModifier(shortcut)) {
|
|
7382
7954
|
const modifiers = process.platform === 'darwin'
|
|
7383
|
-
? 'Command, Control, Option, or Globe'
|
|
7955
|
+
? (globe ? 'Command, Control, Option, or Globe' : 'Command, Control, or Option')
|
|
7384
7956
|
: 'Command, Control, Alt, or Win';
|
|
7385
|
-
return
|
|
7386
|
-
}
|
|
7387
|
-
|
|
7388
|
-
settings.show_shortcut = next;
|
|
7389
|
-
const result = registerShortcuts();
|
|
7390
|
-
if (!result.showShortcutRegistered) {
|
|
7391
|
-
settings.show_shortcut = previous;
|
|
7392
|
-
registerShortcuts();
|
|
7393
|
-
return { ok: false, error: 'Shortcut is already in use or not supported.' };
|
|
7957
|
+
return `Use ${modifiers} with ${key}.`;
|
|
7394
7958
|
}
|
|
7959
|
+
return '';
|
|
7960
|
+
}
|
|
7395
7961
|
|
|
7396
|
-
|
|
7962
|
+
function setShowShortcut(shortcut) {
|
|
7963
|
+
const next = normalizeShowShortcut(shortcut);
|
|
7964
|
+
const invalid = next && recordedShortcutError(next, { globe: true });
|
|
7965
|
+
if (invalid) return { ok: false, error: invalid };
|
|
7966
|
+
const saved = commitShortcutSetting('show_shortcut', next, 'showShortcutRegistered', 'Shortcut is already in use or not supported.');
|
|
7967
|
+
if (!saved.ok) return saved;
|
|
7397
7968
|
return { ok: true, shortcut: effectiveShowShortcut(), custom: !!settings.show_shortcut };
|
|
7398
7969
|
}
|
|
7399
7970
|
|
|
7400
7971
|
function setQuickPasteShortcut(shortcut) {
|
|
7401
|
-
const previous = settings.quick_paste_shortcut || '';
|
|
7402
7972
|
const next = normalizeQuickPasteShortcut(shortcut);
|
|
7403
|
-
|
|
7404
|
-
|
|
7405
|
-
}
|
|
7406
|
-
if (next && !shortcutHasKeyAndModifier(next)) {
|
|
7407
|
-
const modifiers = process.platform === 'darwin'
|
|
7408
|
-
? 'Command, Control, or Option'
|
|
7409
|
-
: 'Command, Control, Alt, or Win';
|
|
7410
|
-
return { ok: false, error: `Use ${modifiers} with a number.` };
|
|
7411
|
-
}
|
|
7973
|
+
const invalid = next && recordedShortcutError(next, { key: 'a number' });
|
|
7974
|
+
if (invalid) return { ok: false, error: invalid };
|
|
7412
7975
|
if (next && quickPasteSlotFromShortcut(next) == null) {
|
|
7413
7976
|
return { ok: false, error: 'Press a shortcut ending in 1-9.' };
|
|
7414
7977
|
}
|
|
7415
|
-
|
|
7416
|
-
|
|
7417
|
-
const result = registerShortcuts();
|
|
7418
|
-
if (!result.quickPasteRegistered) {
|
|
7419
|
-
settings.quick_paste_shortcut = previous;
|
|
7420
|
-
registerShortcuts();
|
|
7421
|
-
return { ok: false, error: 'Quick paste shortcut is already in use or not supported.' };
|
|
7422
|
-
}
|
|
7423
|
-
|
|
7424
|
-
saveSettingsFile();
|
|
7978
|
+
const saved = commitShortcutSetting('quick_paste_shortcut', next, 'quickPasteRegistered', 'Quick paste shortcut is already in use or not supported.');
|
|
7979
|
+
if (!saved.ok) return saved;
|
|
7425
7980
|
return {
|
|
7426
7981
|
ok: true,
|
|
7427
7982
|
shortcut: effectiveQuickPasteShortcut(),
|
|
@@ -7429,20 +7984,92 @@ function setQuickPasteShortcut(shortcut) {
|
|
|
7429
7984
|
};
|
|
7430
7985
|
}
|
|
7431
7986
|
|
|
7987
|
+
// '' = the default (Windows: Win+N through the hook), SHORTCUT_OFF = none
|
|
7988
|
+
// (the row's Turn off), else a recorded chord.
|
|
7989
|
+
function setNewNoteShortcut(shortcut) {
|
|
7990
|
+
const next = normalizeNewNoteShortcut(shortcut);
|
|
7991
|
+
const invalid = next && next !== SHORTCUT_OFF && recordedShortcutError(next);
|
|
7992
|
+
if (invalid) return { ok: false, error: invalid };
|
|
7993
|
+
const saved = commitShortcutSetting('new_note_shortcut', next, 'newNoteRegistered', 'New note shortcut is already in use or not supported.');
|
|
7994
|
+
if (!saved.ok) return saved;
|
|
7995
|
+
return { ok: true, shortcut: effectiveNewNoteShortcut(), custom: !!settings.new_note_shortcut };
|
|
7996
|
+
}
|
|
7997
|
+
|
|
7998
|
+
// Save one recorded shortcut: register it, or put the previous one back.
|
|
7999
|
+
// Saving ends the recording, so the registration really happens (while
|
|
8000
|
+
// suspended, registerShortcuts registers nothing and reports success: a
|
|
8001
|
+
// recorded shortcut stayed dead, every other one with it, until a restart).
|
|
8002
|
+
function commitShortcutSetting(key, next, registeredFlag, error) {
|
|
8003
|
+
const previous = settings[key] || '';
|
|
8004
|
+
shortcutsSuspended = false;
|
|
8005
|
+
settings[key] = next;
|
|
8006
|
+
if (!registerShortcuts()[registeredFlag]) {
|
|
8007
|
+
settings[key] = previous;
|
|
8008
|
+
registerShortcuts();
|
|
8009
|
+
return { ok: false, error };
|
|
8010
|
+
}
|
|
8011
|
+
saveSettingsFile();
|
|
8012
|
+
return { ok: true };
|
|
8013
|
+
}
|
|
8014
|
+
|
|
7432
8015
|
function registerShortcuts() {
|
|
8016
|
+
// Where each shortcut lives: macOS's popup hotkey (Carbon, Globe-capable),
|
|
8017
|
+
// the Windows keyboard hook, a globalShortcut, or nowhere (off). On Windows
|
|
8018
|
+
// EVERY Win chord is the hook's (hookPolicy.isWinChord: the shell holds most
|
|
8019
|
+
// of them, Win+V / Win+N / Win+1-9 included, where a globalShortcut silently
|
|
8020
|
+
// loses); the defaults Win+V and Win+N are Win chords like a recorded one.
|
|
8021
|
+
const viaHook = (shortcut) => process.platform === 'win32' && hookPolicy.isWinChord(shortcut);
|
|
8022
|
+
const showShortcut = effectiveShowShortcut();
|
|
8023
|
+
const macosShowKey = process.platform === 'darwin' ? showShortcut : '';
|
|
8024
|
+
const showHook = viaHook(showShortcut);
|
|
8025
|
+
const showKey = macosShowKey || showHook ? '' : showShortcut;
|
|
8026
|
+
const quickPasteShortcut = globalQuickPasteShortcut();
|
|
8027
|
+
const quickPasteHook = viaHook(quickPasteShortcut);
|
|
8028
|
+
const quickPasteKey = quickPasteHook ? '' : quickPasteShortcut;
|
|
8029
|
+
const newNoteShortcut = shortcutUsesFn(effectiveNewNoteShortcut()) ? '' : effectiveNewNoteShortcut();
|
|
8030
|
+
const newNoteHook = viaHook(newNoteShortcut);
|
|
8031
|
+
const newNoteKey = newNoteHook ? '' : newNoteShortcut;
|
|
8032
|
+
|
|
8033
|
+
// The hook's chord table, first come first served like globalShortcut: a
|
|
8034
|
+
// chord two shortcuts share, or a key the hook has no code for, fails the
|
|
8035
|
+
// later one the way an already-registered globalShortcut does.
|
|
8036
|
+
const hookChords = [];
|
|
8037
|
+
const addHookChord = (shortcut, type, slot) => {
|
|
8038
|
+
const chord = hookPolicy.chordFromAccelerator(shortcut);
|
|
8039
|
+
if (!chord || hookChords.some(c => c.vk === chord.vk && c.mods === chord.mods)) return false;
|
|
8040
|
+
hookChords.push({ ...chord, type, slot });
|
|
8041
|
+
return true;
|
|
8042
|
+
};
|
|
8043
|
+
const showHookOk = !showHook || addHookChord(showShortcut, 'show');
|
|
8044
|
+
const quickPasteRegistrations = [];
|
|
8045
|
+
let quickPasteHookOk = true;
|
|
8046
|
+
if (quickPasteHook) {
|
|
8047
|
+
for (let n = 1; n <= 9; n++) {
|
|
8048
|
+
const key = quickPasteShortcutForSlot(quickPasteShortcut, n);
|
|
8049
|
+
const registered = !!key && addHookChord(key, 'quick-paste', n);
|
|
8050
|
+
quickPasteRegistrations.push({ slot: n, key, registered, backend: 'hook' });
|
|
8051
|
+
if (!registered) quickPasteHookOk = false;
|
|
8052
|
+
}
|
|
8053
|
+
}
|
|
8054
|
+
const newNoteHookOk = !newNoteHook || addHookChord(newNoteShortcut, 'new-note');
|
|
8055
|
+
|
|
7433
8056
|
// A throwaway QA instance (BOARDCLIP_ISOLATED=1) never takes the keyboard: its
|
|
7434
8057
|
// low-level hook would sit in front of the real app's, so Win+V would open the
|
|
7435
8058
|
// sandbox and an assigned numpad key would paste sandbox text into the user's
|
|
7436
8059
|
// app through the real clipboard. Windows hook, macOS hotkey and globalShortcut
|
|
7437
|
-
// alike.
|
|
8060
|
+
// alike. A shortcut that needs no OS registration (the hook's, or none) reports
|
|
8061
|
+
// live, as it does for real (it cannot fail), so its row can be saved in QA.
|
|
7438
8062
|
if (process.env.BOARDCLIP_ISOLATED === '1') {
|
|
7439
|
-
|
|
7440
|
-
|
|
7441
|
-
showShortcutRegistered: false,
|
|
8063
|
+
const isolated = {
|
|
8064
|
+
showShortcutRegistered: showHookOk && !(macosShowKey || showKey),
|
|
7442
8065
|
showShortcut: effectiveShowShortcut(),
|
|
7443
|
-
quickPasteRegistered:
|
|
8066
|
+
quickPasteRegistered: quickPasteHookOk && !quickPasteKey,
|
|
7444
8067
|
quickPasteShortcut: effectiveQuickPasteShortcut(),
|
|
8068
|
+
newNoteRegistered: newNoteHookOk && !newNoteKey,
|
|
8069
|
+
newNoteShortcut: effectiveNewNoteShortcut(),
|
|
7445
8070
|
};
|
|
8071
|
+
diagnostics.record('shortcut.register', { platform: process.platform, isolated: true, show_registered: isolated.showShortcutRegistered, quick_paste_registered: isolated.quickPasteRegistered, new_note_registered: isolated.newNoteRegistered });
|
|
8072
|
+
return isolated;
|
|
7446
8073
|
}
|
|
7447
8074
|
if (shortcutsSuspended) {
|
|
7448
8075
|
return {
|
|
@@ -7450,26 +8077,32 @@ function registerShortcuts() {
|
|
|
7450
8077
|
showShortcut: effectiveShowShortcut(),
|
|
7451
8078
|
quickPasteRegistered: true,
|
|
7452
8079
|
quickPasteShortcut: effectiveQuickPasteShortcut(),
|
|
8080
|
+
newNoteRegistered: true,
|
|
8081
|
+
newNoteShortcut: effectiveNewNoteShortcut(),
|
|
7453
8082
|
};
|
|
7454
8083
|
}
|
|
7455
8084
|
globalShortcut.unregisterAll();
|
|
7456
8085
|
|
|
7457
|
-
let showShortcutRegistered =
|
|
7458
|
-
let quickPasteRegistered =
|
|
7459
|
-
|
|
7460
|
-
const showKey = process.platform === 'darwin' ? '' : globalShowShortcut();
|
|
7461
|
-
const quickPasteKey = globalQuickPasteShortcut();
|
|
8086
|
+
let showShortcutRegistered = showHookOk;
|
|
8087
|
+
let quickPasteRegistered = quickPasteHookOk;
|
|
8088
|
+
let newNoteRegistered = newNoteHookOk;
|
|
7462
8089
|
|
|
7463
8090
|
if (process.platform === 'win32') {
|
|
7464
|
-
//
|
|
7465
|
-
//
|
|
7466
|
-
//
|
|
7467
|
-
// intercepts these keys *before* Windows Clipboard History sees them.
|
|
8091
|
+
// The WH_KEYBOARD_LL hook on its own worker thread (lib/windows-hook.js):
|
|
8092
|
+
// it takes the chord table, the numpad slot keys and a recording before
|
|
8093
|
+
// Windows' own handlers see them.
|
|
7468
8094
|
const { install } = require('./lib/windows-hook');
|
|
7469
8095
|
windowsHook = install({
|
|
7470
8096
|
onShowPopup: showPopup,
|
|
7471
8097
|
onNumpadPaste: handleNumpad,
|
|
8098
|
+
onQuickPaste: (slot) => handleQuickPaste(slot).catch(error => {
|
|
8099
|
+
diagnostics.record('shortcut.quick_paste_error', { slot, source: 'hook', error: error && error.message }, { forceFile: true });
|
|
8100
|
+
}),
|
|
8101
|
+
onNewNote: handleNewNoteShortcut,
|
|
8102
|
+
onRecordKey: handleHookRecordedKey,
|
|
7472
8103
|
});
|
|
8104
|
+
windowsHook.setRecording(false);
|
|
8105
|
+
windowsHook.setChords(hookChords);
|
|
7473
8106
|
// Seed the shared state with current history so plain numpad keys
|
|
7474
8107
|
// immediately intercept for already-assigned slots.
|
|
7475
8108
|
syncHookState();
|
|
@@ -7491,7 +8124,6 @@ function registerShortcuts() {
|
|
|
7491
8124
|
if (!showShortcutRegistered) logSafe(`Warning: Could not register popup shortcut ${showKey}`);
|
|
7492
8125
|
}
|
|
7493
8126
|
|
|
7494
|
-
const quickPasteRegistrations = [];
|
|
7495
8127
|
if (quickPasteKey) {
|
|
7496
8128
|
if (process.platform === 'darwin') {
|
|
7497
8129
|
const hook = getMacosHotkey();
|
|
@@ -7534,14 +8166,21 @@ function registerShortcuts() {
|
|
|
7534
8166
|
}
|
|
7535
8167
|
}
|
|
7536
8168
|
}
|
|
8169
|
+
if (newNoteKey) {
|
|
8170
|
+
try { newNoteRegistered = globalShortcut.register(newNoteKey, handleNewNoteShortcut); } catch { newNoteRegistered = false; }
|
|
8171
|
+
if (!newNoteRegistered) logSafe(`Warning: Could not register new note shortcut ${newNoteKey}`);
|
|
8172
|
+
}
|
|
7537
8173
|
diagnostics.record('shortcut.register', {
|
|
7538
8174
|
platform: process.platform,
|
|
7539
|
-
show_key:
|
|
8175
|
+
show_key: showShortcut,
|
|
7540
8176
|
show_registered: showShortcutRegistered,
|
|
7541
|
-
show_backend: macosShowKey ? 'carbon' : (
|
|
7542
|
-
quick_paste_key:
|
|
8177
|
+
show_backend: macosShowKey ? 'carbon' : (showHook ? 'hook' : 'electron'),
|
|
8178
|
+
quick_paste_key: quickPasteShortcut,
|
|
7543
8179
|
quick_paste_registered: quickPasteRegistered,
|
|
7544
8180
|
quick_paste_registrations: quickPasteRegistrations,
|
|
8181
|
+
new_note_key: newNoteShortcut,
|
|
8182
|
+
new_note_registered: newNoteRegistered,
|
|
8183
|
+
new_note_backend: newNoteHook ? 'hook' : (newNoteKey ? 'electron' : ''),
|
|
7545
8184
|
}, { forceFile: true });
|
|
7546
8185
|
|
|
7547
8186
|
return {
|
|
@@ -7549,12 +8188,22 @@ function registerShortcuts() {
|
|
|
7549
8188
|
showShortcut: effectiveShowShortcut(),
|
|
7550
8189
|
quickPasteRegistered,
|
|
7551
8190
|
quickPasteShortcut: effectiveQuickPasteShortcut(),
|
|
8191
|
+
newNoteRegistered,
|
|
8192
|
+
newNoteShortcut: effectiveNewNoteShortcut(),
|
|
7552
8193
|
};
|
|
7553
8194
|
}
|
|
7554
8195
|
|
|
7555
8196
|
function suspendShortcutsForRecording() {
|
|
7556
8197
|
shortcutsSuspended = true;
|
|
7557
8198
|
globalShortcut.unregisterAll();
|
|
8199
|
+
// The hook's chords too, and every Win/Ctrl/Alt press now goes to the
|
|
8200
|
+
// recorder instead of its app (resume re-arms them). Without this Windows
|
|
8201
|
+
// acted on a Win chord first: Win+1 switched to a taskbar app, the popup
|
|
8202
|
+
// lost focus and hid mid-recording, nothing was saved.
|
|
8203
|
+
if (windowsHook) {
|
|
8204
|
+
windowsHook.setChords([]);
|
|
8205
|
+
windowsHook.setRecording(true);
|
|
8206
|
+
}
|
|
7558
8207
|
if (macosHotkey) {
|
|
7559
8208
|
macosHotkey.clearRuntimeShortcut();
|
|
7560
8209
|
macosHotkey.clearQuickPasteShortcuts();
|
|
@@ -7563,6 +8212,15 @@ function suspendShortcutsForRecording() {
|
|
|
7563
8212
|
return { ok: true };
|
|
7564
8213
|
}
|
|
7565
8214
|
|
|
8215
|
+
// A chord the hook took while a Settings row records (Windows): handed to the
|
|
8216
|
+
// popup's recorder as the chord its own keydown would have been.
|
|
8217
|
+
function handleHookRecordedKey(vk, mods) {
|
|
8218
|
+
if (!shortcutsSuspended || !win || win.isDestroyed()) return;
|
|
8219
|
+
const chord = hookPolicy.recordedChord(vk, mods);
|
|
8220
|
+
diagnostics.record('shortcut.record_key', { vk, mods, shortcut: chord.shortcut }, { forceFile: diagnostics.isEnabled() });
|
|
8221
|
+
win.webContents.send('shortcut-recorded', chord);
|
|
8222
|
+
}
|
|
8223
|
+
|
|
7566
8224
|
function resumeShortcutsAfterRecording() {
|
|
7567
8225
|
shortcutsSuspended = false;
|
|
7568
8226
|
const result = registerShortcuts();
|
|
@@ -7646,6 +8304,10 @@ app.whenReady().then(() => {
|
|
|
7646
8304
|
|
|
7647
8305
|
if (process.platform === 'darwin') app.dock.hide();
|
|
7648
8306
|
|
|
8307
|
+
tagMigration = migrateTagsAtLoad();
|
|
8308
|
+
persistTagMigration();
|
|
8309
|
+
const unsplit = unsplitLiteralTags(history, 'load');
|
|
8310
|
+
if (unsplit) history.splice(0, history.length, ...unsplit.history);
|
|
7649
8311
|
migrateNumpad();
|
|
7650
8312
|
recoverOrphanedEdits(); // restore any external edit orphaned by a prior crash/restart
|
|
7651
8313
|
writeHistoryStorageFile();
|
|
@@ -7687,6 +8349,7 @@ app.whenReady().then(() => {
|
|
|
7687
8349
|
// heartbeat is therefore an external kill or a native crash, never a quit.
|
|
7688
8350
|
app.on('before-quit', () => {
|
|
7689
8351
|
app.isQuitting = true;
|
|
8352
|
+
try { flushDeleteStamps(); } catch {}
|
|
7690
8353
|
if (!quitReason) quitReason = 'quit';
|
|
7691
8354
|
diagnostics.record('app.quit', { reason: quitReason, build: BUILD_INFO.label, uptime_s: Math.round(process.uptime()) }, { forceFile: true });
|
|
7692
8355
|
});
|