@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.
@@ -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
- const currentIndex = id ? history.findIndex(item => itemKey(item) === id) : -1;
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
+ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;',
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. editWindowsByClip: originalId -> sessionId
5083
- // (enforces one window per clip). A session re-anchors baseText/currentId after
5084
- // each commit so the chain stays contiguous (in-place, not a fork).
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
- try {
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 the conflict-fork outcome so a stale-based commit is never mistaken
5146
- // for a clobber: the user's edit was kept as a new clip, untouched.
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 || (result.reason !== 'conflict_created' && result.reason !== 'conflict_merged')) return;
5149
- showEditNotification(
5150
- 'BoardClip - saved as a separate clip',
5151
- 'This clip changed while you were editing, so your version was kept as a new clip instead of overwriting. Nothing was lost.'
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 openSessionId = editWindowsByClip.get(id);
5466
- if (openSessionId) {
5467
- const s = editSessions.get(openSessionId);
5468
- if (s && s.win && !s.win.isDestroyed()) {
5469
- try {
5470
- presentSecondaryWindow(s.win, options);
5471
- if (options && options.find) s.win.webContents.send('editor-find', { query: options.find, findRegex: !!options.findRegex });
5472
- if (options && options.focusTitle) s.win.webContents.send('editor-find', { focusTitle: true });
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 target = findHistoryItem(targetId);
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) => openConflictWindow(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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tobq/boardclip",
3
3
  "productName": "BoardClip",
4
- "version": "2.1.109",
4
+ "version": "2.1.110",
5
5
  "description": "Cross-platform clipboard history tray app with numpad quick-paste and native cloud sync",
6
6
  "keywords": [
7
7
  "clipboard",