@tobq/boardclip 2.1.109 → 2.1.110
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/lib/clipboard-model.js +18 -3
- package/lib/notifications.js +83 -0
- package/main.js +119 -34
- package/package.json +1 -1
package/lib/clipboard-model.js
CHANGED
|
@@ -274,6 +274,13 @@ function conflictSnapshot(item, fallback = {}) {
|
|
|
274
274
|
};
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
// Two sides with the same text and title leave nothing to merge.
|
|
278
|
+
function withoutIdenticalSides(conflict) {
|
|
279
|
+
const { left, right } = conflict || {};
|
|
280
|
+
if (left && right && left.text === right.text && left.title === right.title) return null;
|
|
281
|
+
return conflict;
|
|
282
|
+
}
|
|
283
|
+
|
|
277
284
|
function titleConflict(localItem, remoteItem) {
|
|
278
285
|
if (!localItem || !remoteItem || localItem.type === 'image' || remoteItem.type === 'image') return false;
|
|
279
286
|
if (itemKey(localItem) !== itemKey(remoteItem)) return false;
|
|
@@ -890,7 +897,15 @@ function applyTextEdit(items, options = {}) {
|
|
|
890
897
|
ensureItemId(item);
|
|
891
898
|
}
|
|
892
899
|
|
|
893
|
-
|
|
900
|
+
let currentIndex = id ? history.findIndex(item => itemKey(item) === id) : -1;
|
|
901
|
+
// A base edited since this edit began (in another window or on another
|
|
902
|
+
// device) is gone under its old id. Its newest version is found through the
|
|
903
|
+
// edit lineage, so the fork below carries a conflict record against it and
|
|
904
|
+
// the two can be merged, instead of a second copy with nothing to merge.
|
|
905
|
+
if (currentIndex < 0 && id && options.supersedes) {
|
|
906
|
+
const headId = supersedeMap(options.supersedes).get(id);
|
|
907
|
+
if (headId) currentIndex = history.findIndex(item => item.type !== 'image' && itemKey(item) === headId);
|
|
908
|
+
}
|
|
894
909
|
const current = currentIndex >= 0 ? history[currentIndex] : null;
|
|
895
910
|
const originalTitle = titleEditRequested
|
|
896
911
|
? (options.originalTitle !== undefined ? cleanTitle(options.originalTitle) : titleOf(current))
|
|
@@ -949,13 +964,13 @@ function applyTextEdit(items, options = {}) {
|
|
|
949
964
|
dedupeNumpadSlots(history);
|
|
950
965
|
const titleMergeConflict = titleConflict(existingBefore, edited);
|
|
951
966
|
const conflict = current && (!canUpdateCurrent || titleDiverged || titleMergeConflict)
|
|
952
|
-
? {
|
|
967
|
+
? withoutIdenticalSides({
|
|
953
968
|
kind: titleDiverged || titleMergeConflict ? 'title' : 'editor',
|
|
954
969
|
base: { id, type: 'text', text: originalText, title: originalTitle },
|
|
955
970
|
left: titleMergeConflict ? existingBefore : (currentBefore || existingBefore),
|
|
956
971
|
right: conflictSnapshot(edited, { text: newText, title: newTitle }),
|
|
957
972
|
targetId: itemKey(existing),
|
|
958
|
-
}
|
|
973
|
+
})
|
|
959
974
|
: null;
|
|
960
975
|
return {
|
|
961
976
|
changed: true,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// OS notifications with an optional action button, on Windows and macOS.
|
|
4
|
+
// - Windows: Electron's Notification takes a button only through custom toast
|
|
5
|
+
// XML. A foreground-activated button reaches the app as the notification's
|
|
6
|
+
// 'click' (Electron maps every activation to it), so the body and the button
|
|
7
|
+
// run the same action.
|
|
8
|
+
// - macOS: Electron's `actions` ('action' event). They show only when the app's
|
|
9
|
+
// notification style is Alerts; otherwise the body click is the way in.
|
|
10
|
+
// Clicking the notification runs the action everywhere: that is the generic
|
|
11
|
+
// path, and a button is the extra a platform adds on top.
|
|
12
|
+
|
|
13
|
+
function escapeXml(value) {
|
|
14
|
+
return String(value == null ? '' : value).replace(/[<>&"']/g, (ch) => ({
|
|
15
|
+
'<': '<', '>': '>', '&': '&', '"': '"', "'": ''',
|
|
16
|
+
})[ch]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function toastXml({ title, body, action, silent = true }) {
|
|
20
|
+
return '<toast activationType="foreground" launch="open">'
|
|
21
|
+
+ '<visual><binding template="ToastGeneric">'
|
|
22
|
+
+ `<text>${escapeXml(title)}</text>`
|
|
23
|
+
+ (body ? `<text>${escapeXml(body)}</text>` : '')
|
|
24
|
+
+ '</binding></visual>'
|
|
25
|
+
+ (action ? `<actions><action content="${escapeXml(action)}" arguments="action" activationType="foreground"/></actions>` : '')
|
|
26
|
+
+ (silent ? '<audio silent="true"/>' : '')
|
|
27
|
+
+ '</toast>';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function notificationOptions({ title, body, action, silent = true, platform = process.platform } = {}) {
|
|
31
|
+
const options = { title: String(title || ''), body: String(body || ''), silent: !!silent };
|
|
32
|
+
if (action && platform === 'win32') options.toastXml = toastXml({ title, body, action, silent });
|
|
33
|
+
if (action && platform === 'darwin') options.actions = [{ type: 'button', text: String(action) }];
|
|
34
|
+
return options;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// show() keeps each notification referenced until it is acted on or closed:
|
|
38
|
+
// an unreferenced Notification can be garbage-collected before its click.
|
|
39
|
+
// A toast Windows rejects ('failed') is shown again without the button, so the
|
|
40
|
+
// message still arrives and its click still runs the action; onError hears of
|
|
41
|
+
// every failure (each one is a bug to fix, not a path to rely on).
|
|
42
|
+
function createNotifier({ Notification, platform = process.platform, onError = () => {} } = {}) {
|
|
43
|
+
const live = new Set();
|
|
44
|
+
const present = (options, act) => {
|
|
45
|
+
const notification = new Notification(options);
|
|
46
|
+
live.add(notification);
|
|
47
|
+
const done = () => { live.delete(notification); };
|
|
48
|
+
notification.on('click', () => { done(); act(); });
|
|
49
|
+
notification.on('action', () => { done(); act(); });
|
|
50
|
+
notification.on('close', done);
|
|
51
|
+
notification.on('failed', (_event, error) => {
|
|
52
|
+
done();
|
|
53
|
+
onError(new Error(String(error || 'notification failed')));
|
|
54
|
+
if (options.toastXml) {
|
|
55
|
+
const { toastXml, ...plain } = options;
|
|
56
|
+
try { present(plain, act); } catch (retryError) { onError(retryError); }
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
notification.show();
|
|
60
|
+
return notification;
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
show({ title, body, action, onAction, silent = true } = {}) {
|
|
64
|
+
try {
|
|
65
|
+
if (!Notification || typeof Notification.isSupported !== 'function' || !Notification.isSupported()) return null;
|
|
66
|
+
const withAction = typeof onAction === 'function';
|
|
67
|
+
let acted = false;
|
|
68
|
+
const act = () => {
|
|
69
|
+
if (!withAction || acted) return;
|
|
70
|
+
acted = true;
|
|
71
|
+
try { onAction(); } catch (error) { onError(error); }
|
|
72
|
+
};
|
|
73
|
+
return present(notificationOptions({ title, body, action: withAction ? action : '', silent, platform }), act);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
onError(error);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
liveCount: () => live.size,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { escapeXml, toastXml, notificationOptions, createNotifier };
|
package/main.js
CHANGED
|
@@ -49,6 +49,7 @@ const { ControlServer } = require('./lib/control-server');
|
|
|
49
49
|
const windowsConsole = require('./lib/windows-console');
|
|
50
50
|
const windowsDwm = require('./lib/windows-dwm');
|
|
51
51
|
const appearance = require('./lib/appearance');
|
|
52
|
+
const { createNotifier } = require('./lib/notifications');
|
|
52
53
|
|
|
53
54
|
function guardBrokenPipe(stream) {
|
|
54
55
|
try {
|
|
@@ -931,6 +932,7 @@ function applyExternalTextEdit({ id, originalText, originalTitle, sourceGroups,
|
|
|
931
932
|
newTitle,
|
|
932
933
|
sourceGroups,
|
|
933
934
|
groupTombstones: settings.group_tombstones,
|
|
935
|
+
supersedes: settings.supersedes,
|
|
934
936
|
now: Date.now(),
|
|
935
937
|
ignoreBlank: true,
|
|
936
938
|
});
|
|
@@ -5079,11 +5081,10 @@ function refreshTray() {
|
|
|
5079
5081
|
// prefix so they're retained but not resurrected).
|
|
5080
5082
|
// 5. Disk retention - finished drafts are kept (LRU-pruned) as a last resort.
|
|
5081
5083
|
//
|
|
5082
|
-
// editSessions: sessionId -> session
|
|
5083
|
-
//
|
|
5084
|
-
//
|
|
5084
|
+
// editSessions: sessionId -> session (one window per note: findEditSessionForClip).
|
|
5085
|
+
// A session re-anchors baseText/currentId after each commit so the chain stays
|
|
5086
|
+
// contiguous (in-place, not a fork).
|
|
5085
5087
|
const editSessions = new Map();
|
|
5086
|
-
const editWindowsByClip = new Map();
|
|
5087
5088
|
const conflictWindows = new Map();
|
|
5088
5089
|
const EDIT_DRAFT_RE = /^boardclip-edit-[0-9a-f]{12}-\d+(?:-\d+)?\.txt$/; // in-flight drafts only (legacy `-<ts>` and current `-<ts>-<seq>` names)
|
|
5089
5090
|
let editSessionSeq = 0;
|
|
@@ -5134,22 +5135,34 @@ function readDraftFile(sessionOrPath, fallback = {}) {
|
|
|
5134
5135
|
return editorPayloadFrom(raw, fallback);
|
|
5135
5136
|
}
|
|
5136
5137
|
|
|
5138
|
+
const notifier = createNotifier({
|
|
5139
|
+
Notification,
|
|
5140
|
+
onError: (error) => diagnostics.record('notification.error', { error: error && error.message }, { forceFile: true }),
|
|
5141
|
+
});
|
|
5142
|
+
|
|
5137
5143
|
function showEditNotification(title, body) {
|
|
5138
|
-
|
|
5139
|
-
if (Notification && Notification.isSupported && Notification.isSupported()) {
|
|
5140
|
-
new Notification({ title, body, silent: true }).show();
|
|
5141
|
-
}
|
|
5142
|
-
} catch {}
|
|
5144
|
+
notifier.show({ title, body });
|
|
5143
5145
|
}
|
|
5144
5146
|
|
|
5145
|
-
// Surface
|
|
5146
|
-
//
|
|
5147
|
+
// Surface a stale-based save so it is never mistaken for a clobber: the edit
|
|
5148
|
+
// was kept beside the note, and Merge (or a click on the notification) opens
|
|
5149
|
+
// the merge view that folds the two back into one note.
|
|
5147
5150
|
function notifyEditOutcome(result) {
|
|
5148
|
-
if (!result
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
|
|
5151
|
+
if (!result) return;
|
|
5152
|
+
const record = result.conflictRecord;
|
|
5153
|
+
if (record && record.kind === 'editor') {
|
|
5154
|
+
notifier.show({
|
|
5155
|
+
title: 'BoardClip - this note changed while you were editing',
|
|
5156
|
+
body: 'Your version is kept as a separate clip, so nothing is lost.',
|
|
5157
|
+
action: 'Merge',
|
|
5158
|
+
onAction: () => showConflict(record.id),
|
|
5159
|
+
});
|
|
5160
|
+
} else if (!record && result.reason === 'conflict_created') {
|
|
5161
|
+
showEditNotification(
|
|
5162
|
+
'BoardClip - saved as a new clip',
|
|
5163
|
+
'The clip you were editing was deleted, so your version is kept as a new clip.'
|
|
5164
|
+
);
|
|
5165
|
+
}
|
|
5153
5166
|
}
|
|
5154
5167
|
|
|
5155
5168
|
// --- Editor <-> clipboard follow (lib/clipboard-follow.js) ---
|
|
@@ -5218,6 +5231,7 @@ function commitEditSession(session, payload) {
|
|
|
5218
5231
|
notifyEditOutcome(result);
|
|
5219
5232
|
if (result.conflictRecord && session.win && !session.win.isDestroyed()) {
|
|
5220
5233
|
session.inConflict = true;
|
|
5234
|
+
session.conflictId = result.conflictRecord.id;
|
|
5221
5235
|
try { session.win.webContents.send('editor-conflict', conflictModel.normalizeConflictRecord(result.conflictRecord)); } catch {}
|
|
5222
5236
|
}
|
|
5223
5237
|
}
|
|
@@ -5415,9 +5429,6 @@ function createEditorWindow(session, presentOptions = {}) {
|
|
|
5415
5429
|
trackWindowBounds(editorWin, 'editor_bounds');
|
|
5416
5430
|
editorWin.on('closed', () => {
|
|
5417
5431
|
editSessions.delete(session.id);
|
|
5418
|
-
if (session.originalId != null && editWindowsByClip.get(session.originalId) === session.id) {
|
|
5419
|
-
editWindowsByClip.delete(session.originalId);
|
|
5420
|
-
}
|
|
5421
5432
|
// Safety net: commit the latest on-disk draft (the IPC commit may have raced
|
|
5422
5433
|
// the window teardown), then mark the draft finished so it's retained but not
|
|
5423
5434
|
// re-recovered, and prune. suppressCommit = the clip was deliberately deleted
|
|
@@ -5460,19 +5471,47 @@ function presentSecondaryWindow(targetWin, options = {}) {
|
|
|
5460
5471
|
hidePopup();
|
|
5461
5472
|
}
|
|
5462
5473
|
|
|
5474
|
+
// A note's id changes on every save (content-addressed), so its open editor is
|
|
5475
|
+
// found by the note's edit lineage, never by the id it opened with. Matching
|
|
5476
|
+
// only that id let a reopen start a second editor on the same note; the
|
|
5477
|
+
// first one's next save then left the second stale, and typing in it forked
|
|
5478
|
+
// the note.
|
|
5479
|
+
function findEditSessionForClip(id) {
|
|
5480
|
+
const key = String(id || '');
|
|
5481
|
+
if (!key) return null;
|
|
5482
|
+
const lineage = clipboardModel.supersedeMap(settings.supersedes);
|
|
5483
|
+
const head = (clipId) => lineage.get(clipId) || clipId;
|
|
5484
|
+
const target = head(key);
|
|
5485
|
+
for (const session of editSessions.values()) {
|
|
5486
|
+
if (!session.currentId || !session.win || session.win.isDestroyed()) continue;
|
|
5487
|
+
if (session.currentId === key || head(session.currentId) === target) return session;
|
|
5488
|
+
}
|
|
5489
|
+
return null;
|
|
5490
|
+
}
|
|
5491
|
+
|
|
5492
|
+
// The merge view of a conflict: the editor already showing it (an editor turns
|
|
5493
|
+
// into the merge view when its save conflicts), else a conflict window.
|
|
5494
|
+
function showConflict(conflictId) {
|
|
5495
|
+
const id = String(conflictId || '');
|
|
5496
|
+
for (const session of editSessions.values()) {
|
|
5497
|
+
if (session.conflictId === id && session.win && !session.win.isDestroyed()) {
|
|
5498
|
+
presentSecondaryWindow(session.win);
|
|
5499
|
+
return { ok: true };
|
|
5500
|
+
}
|
|
5501
|
+
}
|
|
5502
|
+
return openConflictWindow(id);
|
|
5503
|
+
}
|
|
5504
|
+
|
|
5463
5505
|
function openEditor(id, options = {}) {
|
|
5464
5506
|
if (id != null) {
|
|
5465
|
-
const
|
|
5466
|
-
if (
|
|
5467
|
-
|
|
5468
|
-
|
|
5469
|
-
|
|
5470
|
-
|
|
5471
|
-
|
|
5472
|
-
|
|
5473
|
-
} catch {}
|
|
5474
|
-
return;
|
|
5475
|
-
}
|
|
5507
|
+
const s = findEditSessionForClip(id);
|
|
5508
|
+
if (s) {
|
|
5509
|
+
try {
|
|
5510
|
+
presentSecondaryWindow(s.win, options);
|
|
5511
|
+
if (options && options.find) s.win.webContents.send('editor-find', { query: options.find, findRegex: !!options.findRegex });
|
|
5512
|
+
if (options && options.focusTitle) s.win.webContents.send('editor-find', { focusTitle: true });
|
|
5513
|
+
} catch {}
|
|
5514
|
+
return;
|
|
5476
5515
|
}
|
|
5477
5516
|
}
|
|
5478
5517
|
let baseText = '';
|
|
@@ -5522,7 +5561,6 @@ function openEditor(id, options = {}) {
|
|
|
5522
5561
|
session.follow.observe(editorNoteTexts(session));
|
|
5523
5562
|
writeDraftFile(session);
|
|
5524
5563
|
editSessions.set(session.id, session);
|
|
5525
|
-
if (originalId != null) editWindowsByClip.set(originalId, session.id);
|
|
5526
5564
|
createEditorWindow(session, options);
|
|
5527
5565
|
}
|
|
5528
5566
|
|
|
@@ -5649,6 +5687,51 @@ function clipWindowState(id) {
|
|
|
5649
5687
|
return { item, items, groups: [...(settings.groups || [])], aiGroup: mcpCore.AI_GROUP_NAME };
|
|
5650
5688
|
}
|
|
5651
5689
|
|
|
5690
|
+
// A clip by an id a conflict record holds, followed to its newest version
|
|
5691
|
+
// when it was edited since.
|
|
5692
|
+
function liveTextClip(id) {
|
|
5693
|
+
const key = String(id || '');
|
|
5694
|
+
if (!key) return null;
|
|
5695
|
+
const item = findHistoryItem(key) || findHistoryItem(clipboardModel.supersedeMap(settings.supersedes).get(key));
|
|
5696
|
+
return item && item.type !== 'image' ? item : null;
|
|
5697
|
+
}
|
|
5698
|
+
|
|
5699
|
+
// An editor conflict joins two clips: the note (left) and the edit kept beside
|
|
5700
|
+
// it (targetId). Resolving it makes them one note again.
|
|
5701
|
+
function conflictForkPair(record) {
|
|
5702
|
+
if (!record || record.kind !== 'editor') return null;
|
|
5703
|
+
const note = liveTextClip(record.left && record.left.id);
|
|
5704
|
+
const fork = liveTextClip(record.targetId || (record.right && record.right.id));
|
|
5705
|
+
return note && fork && note !== fork ? { note, fork } : null;
|
|
5706
|
+
}
|
|
5707
|
+
|
|
5708
|
+
// The resolved text went into the note, which keeps its pin, numpad key and
|
|
5709
|
+
// lineage. The fork folds into it like an edit: its groups (and a numpad key
|
|
5710
|
+
// the note lacks) join the note, and a supersede link sends a stale copy or
|
|
5711
|
+
// an open editor of it to the note.
|
|
5712
|
+
function foldForkInto(fork, note) {
|
|
5713
|
+
if (!fork || !note || fork === note) return;
|
|
5714
|
+
const forkIndex = history.indexOf(fork);
|
|
5715
|
+
if (forkIndex < 0) return; // the write already merged the two (same text)
|
|
5716
|
+
const forkId = itemKey(fork);
|
|
5717
|
+
const noteId = itemKey(note);
|
|
5718
|
+
const forkSlot = numpadSlotOf(fork);
|
|
5719
|
+
const noteGroups = groupsOf(note);
|
|
5720
|
+
const newGroups = groupsOf(fork).filter(group => !noteGroups.includes(group));
|
|
5721
|
+
if (newGroups.length) {
|
|
5722
|
+
const pin = ensurePin(note);
|
|
5723
|
+
pin.groups = [...(pin.groups || []), ...newGroups];
|
|
5724
|
+
touchPinGroups(note);
|
|
5725
|
+
}
|
|
5726
|
+
if (isPinned(fork) && !note.pin) ensurePin(note);
|
|
5727
|
+
deleteHistoryIndex(forkIndex, { tombstone: false });
|
|
5728
|
+
addTombstone(forkId, null);
|
|
5729
|
+
addSupersede({ from: forkId, to: noteId, updatedAt: Date.now() });
|
|
5730
|
+
saveSettingsFile();
|
|
5731
|
+
saveHistory();
|
|
5732
|
+
if (forkSlot != null && numpadSlotOf(note) == null) applyNumpadAssign(noteId, forkSlot, clipboardModel.clipRevision(note));
|
|
5733
|
+
}
|
|
5734
|
+
|
|
5652
5735
|
function applyConflictResolution(resolution) {
|
|
5653
5736
|
const payload = resolution && typeof resolution === 'object' ? resolution : {};
|
|
5654
5737
|
const conflictId = String(payload.id || '');
|
|
@@ -5661,9 +5744,11 @@ function applyConflictResolution(resolution) {
|
|
|
5661
5744
|
const text = write.text;
|
|
5662
5745
|
const title = snapshot ? titleOf(snapshot) : clipboardModel.titleOf({ title: write.title });
|
|
5663
5746
|
const targetId = record.targetId || snapshot && snapshot.id || record.right && record.right.id || record.left && record.left.id || '';
|
|
5664
|
-
const
|
|
5747
|
+
const fork = conflictForkPair(record);
|
|
5748
|
+
const target = fork ? fork.note : findHistoryItem(targetId);
|
|
5665
5749
|
if (target && target.type !== 'image') {
|
|
5666
|
-
applyTextEditToItem(target, { newText: text, newTitle: title });
|
|
5750
|
+
const written = applyTextEditToItem(target, { newText: text, newTitle: title });
|
|
5751
|
+
if (fork) foldForkInto(fork.fork, (written && written.item) || fork.note);
|
|
5667
5752
|
} else if (text.trim()) {
|
|
5668
5753
|
applyExternalTextEdit({
|
|
5669
5754
|
id: '',
|
|
@@ -6973,7 +7058,7 @@ function setupIPC() {
|
|
|
6973
7058
|
});
|
|
6974
7059
|
ipcMain.handle('resolve-conflict', (_, payload) => applyConflictResolution(payload));
|
|
6975
7060
|
ipcMain.handle('get-conflicts', () => conflictListForRenderer());
|
|
6976
|
-
ipcMain.handle('open-conflict', (_, id) =>
|
|
7061
|
+
ipcMain.handle('open-conflict', (_, id) => showConflict(id));
|
|
6977
7062
|
|
|
6978
7063
|
// Multi-select bulk operations.
|
|
6979
7064
|
ipcMain.handle('delete-items', (_, targets) => applyDeleteItems(targets));
|
package/package.json
CHANGED