-
Notifications
You must be signed in to change notification settings - Fork 15
Expand file tree
/
Copy pathorg-node-backlink.el
More file actions
791 lines (705 loc) · 35.4 KB
/
org-node-backlink.el
File metadata and controls
791 lines (705 loc) · 35.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
;;; org-node-backlink.el --- Extension for managing :BACKLINKS: properties or drawers -*- lexical-binding: t; -*-
;; Copyright (C) 2024-2026 Martin Edström
;; SPDX-License-Identifier: GPL-3.0-or-later
;;; Commentary:
;; Strictly an extension (core does not depend on this file).
;; A mode for ensuring that the Org nodes that should have
;; :BACKLINKS: properties (or :BACKLINKS: drawers) have them,
;; and are up to date.
;;; Code:
(declare-function org-entry-end-position "org")
(declare-function org-entry-get "org")
(declare-function org-entry-get-with-inheritance "org")
(declare-function org-entry-put "org")
(declare-function org-find-property "org")
(declare-function org-get-property-block "org")
(declare-function org-get-title "org")
(declare-function org-link-make-string "ol")
(defvar org-entry-property-inherited-from)
(require 'cl-lib)
(require 'fileloop)
(require 'llama)
(require 'cond-let)
(require 'org-mem)
(require 'org-mem-updater)
(require 'org-node)
(eval-when-compile
(require 'org)
(require 'org-element)
(require 'ol))
(defgroup org-node-backlink nil "In-file backlinks."
:group 'org-node)
(defcustom org-node-backlink-do-drawers t
"Manage :BACKLINKS: drawers instead of properties.
A :BACKLINKS: property is more compact, but can run off the edge of the
visible window without `visual-line-mode' or similar."
:type 'boolean
:package-version '(org-node . "2.0.0"))
(defcustom org-node-backlink-protect-org-super-links t
"Do not try to manage drawers if user has org-super-links installed.
Print a message instead, ensuring the user knows what they are doing
and can invert this setting."
:type 'boolean
:package-version '(org-node . "2.0.0"))
(defun org-node-backlink--check-osl-user-p ()
"Maybe warn users of org-super-links, not to clobber their stuff.
If a warning was not needed, return nil."
(and org-node-backlink-do-drawers
org-node-backlink-protect-org-super-links
(fboundp 'org-super-links-convert-link-to-super)
(display-warning 'org-node-backlink "A notice to users of org-super-links:
To protect your pre-existing drawers,
`org-node-backlink-mode' will do nothing.
If you're OK with how it would reformat your backlinks drawers,
set `org-node-backlink-protect-org-super-links' to nil.")))
(defun org-node-backlink--check-v2-misaligned-setting-p ()
"Warn if `org-node-backlink-do-drawers' is t but properties exist.
If a warning was not needed, return nil."
(and org-node-backlink-do-drawers
(cl-some (##org-mem-entry-property "BACKLINKS" %) (org-node-all-filtered-nodes))
(display-warning 'org-node-backlink "User option `org-node-backlink-do-drawers' is t,
but found :BACKLINKS: lines in some property drawers, so doing nothing.
This is a new default in v2, you probably just need to toggle it.
Or run this command once: `org-node-backlink-mass-delete-props'.")))
;;; Drawer config
(defcustom org-node-backlink-drawer-positioner #'org-node-goto-new-drawer-site
"Function for moving point before placing a new drawer.
Called in a buffer narrowed to one Org entry, excluding any subtrees.
The function may return anything, but if it returns an integer or
marker, point will move to that position."
:type '(radio (function-item org-node-goto-new-drawer-site)
(function-item org-entry-end-position)
(function-item org-node-full-end-of-meta-data)
(function :tag "Custom function" :value (lambda ())))
:package-version '(org-node . "3.3.2"))
(defcustom org-node-backlink-drawer-sort-in-reverse nil
"Whether to reverse how lines are sorted in the backlinks drawer."
:type 'boolean
:package-version '(org-node . "2.0.0"))
(defcustom org-node-backlink-drawer-sorter
#'org-node-backlink-id-blind-string-collate-lessp
"Function for sorting lines in the backlinks drawer."
:type '(radio
(function-item org-node-backlink-timestamp-lessp)
(function-item org-node-backlink-link-description-lessp)
(function-item org-node-backlink-link-description-collate-lessp)
(function-item org-node-backlink-id-lessp)
(function-item org-node-backlink-reversed-id-lessp)
(function-item org-node-backlink-id-blind-string-lessp)
(function-item org-node-backlink-id-blind-string-collate-lessp)
(function-item string-lessp)
(function-item string-collate-lessp)
(function :tag "Custom function" :value (lambda (s1 s2))))
:package-version '(org-node . "3.3.10"))
(defun org-node-backlink-timestamp-lessp (s1 s2)
"Sort on first Org timestamp in the line.
S1 before S2 if timestamp in S1 is earlier in time."
(let ((ts-1 (org-node-backlink--extract-timestamp s1))
(ts-2 (org-node-backlink--extract-timestamp s2)))
(or (and ts-1 (not ts-2))
(and ts-1 ts-2 (org-time< ts-1 ts-2)))))
(defun org-node-backlink-id-lessp (s1 s2)
"Sort on content of [[id:...]].
S1 before S2 if the IDs inside satisfy `string<'.
May be useful with a non-default `org-id-method'."
(string< (org-node-backlink--extract-id s1)
(org-node-backlink--extract-id s2)))
(defun org-node-backlink-reversed-id-lessp (s1 s2)
"Sort on content of [[id:...]] after reversing.
S1 before S2 if the mirror images of IDs inside satisfy `string<'.
May be useful when `org-id-method' is set to `org', because that is a
timestamp with the digits reversed. Then, the result is a
chronological order of when those IDs were originally created
\(not when the backlinks were created)."
(string< (org-node-backlink--extract-id (reverse s1))
(org-node-backlink--extract-id (reverse s2))))
(defun org-node-backlink-link-description-lessp (s1 s2)
"Sort on first link description in the line.
S1 before S2 if link descriptions inside satisfy `string<'."
(string< (org-node-backlink--extract-link-desc s1)
(org-node-backlink--extract-link-desc s2)))
(defun org-node-backlink-link-description-collate-lessp (s1 s2)
"Sort on first link description in the line.
S1 before S2 if link descriptions inside satisfy `string-collate-lessp'."
(string-collate-lessp
(org-node-backlink--extract-link-desc s1)
(org-node-backlink--extract-link-desc s2)))
(defun org-node-backlink-id-blind-string-lessp (s1 s2)
"Sort lexicographically, but ignoring nonsense inside [[id:...]].
S1 before S2 if the strings sans org-ids satisfy `string<'."
(string< (replace-regexp-in-string "\\[\\[id:.*?]" "" s1)
(replace-regexp-in-string "\\[\\[id:.*?]" "" s2)))
(defun org-node-backlink-id-blind-string-collate-lessp (s1 s2)
"Sort lexicographically, but ignoring nonsense inside [[id:...]].
S1 before S2 if the strings sans org-ids satisfy `string-collate-lessp'."
(string-collate-lessp
(replace-regexp-in-string "\\[\\[id:.*?]" "" s1)
(replace-regexp-in-string "\\[\\[id:.*?]" "" s2)))
(defcustom org-node-backlink-drawer-formatter
#'org-node-backlink-format-like-org-super-links-default
"Function to format a new line for the backlinks drawer.
It takes three arguments ID, TITLE and TIME. The first two
are strings, while the third is a Lisp time value.
It should return a string, with constraints:
- No initial whitespace.
- No newlines.
- Not more than one [[id:...]] construct."
:type '(radio
(function-item org-node-backlink-format-like-org-super-links-default)
(function-item org-node-backlink-format-as-bullet-with-time)
(function-item org-node-backlink-format-as-bullet-no-time)
(function :tag "Custom function" :value (lambda (id title time))))
:package-version '(org-node . "2.0.0"))
(defun org-node-backlink-format-like-org-super-links-default
(id desc &optional time)
"Example: \"[2025-02-21 Fri 14:39] <- [[id:ID][Node title]]\".
ID and DESC are link id and description, TIME a Lisp time value."
(concat (org-node-time-stamp t t time)
" <- "
(org-link-make-string (concat "id:" id) desc)))
(defun org-node-backlink-format-as-bullet-with-time (id desc &optional time)
"Example: \"- [2025-02-21 Fri 14:39] [[id:ID][Node title]]\".
ID and DESC are link id and description, TIME a Lisp time value."
(concat "- "
(org-node-time-stamp t t time)
" "
(org-link-make-string (concat "id:" id) desc)))
(defun org-node-backlink-format-as-bullet-no-time (id desc &optional _time)
"Example: \"- [[id:ID][Node title]]\".
ID and DESC are link id and description, TIME a Lisp time value."
(concat "- " (org-link-make-string (concat "id:" id) desc)))
(defun org-node-backlink--reformat-line (line)
"Pass LINE back through `org-node-backlink-drawer-formatter'."
(let ((time (org-node-backlink--extract-timestamp line)))
(funcall org-node-backlink-drawer-formatter
(org-node-backlink--extract-id line)
(org-node-backlink--extract-link-desc line)
(and time (encode-time (parse-time-string time))))))
(defun org-node-backlink--extract-timestamp (text)
"Get Org timestamp out of TEXT."
(let ((link-beg (and (string-match org-link-bracket-re text)
(match-beginning 0))))
(when (string-match (org-re-timestamp 'all) (substring text 0 link-beg))
(match-string 0 text))))
(defun org-node-backlink--extract-id (text)
"Get first link description out of TEXT.
That means the first part of a [[id][description]]."
(with-temp-buffer
(insert text)
(goto-char (point-min))
(when (search-forward "[[id:" nil t)
(buffer-substring-no-properties (point)
(- (re-search-forward "].\\|::")
2)))))
(defun org-node-backlink--extract-link-desc (text)
"Get first link description out of TEXT.
That means the second part of a [[id][description]]."
(with-temp-buffer
(insert text)
(goto-char (point-min))
(when (and (search-forward "[[id:" nil t)
(search-forward "][" nil t))
(buffer-substring-no-properties (point)
(- (search-forward "]]")
2)))))
;;; Commands
;;;###autoload
(defun org-node-backlink-mass-update-drawers ()
"Add or update backlinks drawers in all files."
(interactive)
(unless org-node-backlink-do-drawers
(user-error "Asked to update :BACKLINKS: drawers, but `org-node-backlink-do-drawers' is nil"))
(org-node-backlink--fix-all-files 'update-drawers))
;;;###autoload
(defun org-node-backlink-mass-update-props ()
"Add or update backlinks properties in all files."
(interactive)
(when org-node-backlink-do-drawers
(user-error "Asked to update :BACKLINKS: properties, but `org-node-backlink-do-drawers' is t"))
(org-node-backlink--fix-all-files 'update-props))
;;;###autoload
(defun org-node-backlink-mass-delete-drawers ()
"Delete all backlinks drawers in all files."
(interactive)
(org-node-backlink--fix-all-files 'del-drawers))
;;;###autoload
(defun org-node-backlink-mass-delete-props ()
"Delete all backlinks properties in all files."
(interactive)
(org-node-backlink--fix-all-files 'del-props))
;; NOTE: For a smaller example of basically the same algorithm, see
;; `org-node-lint-all-files'.
(defvar org-node-backlink--work-remaining nil)
(defvar org-node-backlink--work-kind nil)
(defun org-node-backlink--fix-all-files (kind)
"Update :BACKLINKS: in all known nodes.
Argument KIND controls how to update them."
(require 'org)
(unless (boundp 'fileloop--operate-function)
(error "Probably org-node-backlink.el is not up to date with fileloop.el"))
(when (or (and (memq kind '(update-drawers update-props))
(org-node-backlink--check-v2-misaligned-setting-p))
(and (eq kind 'update-drawers)
(org-node-backlink--check-osl-user-p)))
(user-error "Not proceeding"))
(let ((proceed (and org-node-backlink--work-remaining
(eq org-node-backlink--work-kind kind)
(equal fileloop--operate-function
#'org-node-backlink--loop-operator))))
(unless proceed
(org-mem-reset nil "org-node: Waiting for org-mem...")
(unless (org-mem-await "org-node: Waiting for org-mem..." 30)
(error "org-node: Waited weirdly long for org-mem"))
(let* ((files (org-node-all-filtered-files))
(dirs (org-node--root-dirs files))
(problematic (seq-filter (##and (boundp %) (symbol-value %))
'(org-node-backlink-mode
auto-save-visited-mode
git-auto-commit-mode
auto-revert-mode))))
(and
(y-or-n-p
(format "Edit %d Org files in these %d directories?\n%S"
(length files) (length dirs) dirs))
(or (not problematic)
(and (y-or-n-p
(concat "Disable "
(string-join (mapcar #'symbol-name problematic)
", ")
"? "))
(dolist (mode problematic t)
(funcall mode 0))))
(progn
(setq org-node-backlink--work-remaining files)
(setq org-node-backlink--work-kind kind)
(fileloop-initialize files
(lambda ()
(let ((file (pop org-node-backlink--work-remaining)))
(string-prefix-p "org" (file-name-extension file))))
#'org-node-backlink--loop-operator)
(setq proceed t)))))
(when proceed
(let ((find-file-hook nil)
(buffer-list-update-hook nil)
(enable-local-variables :safe)
(org-agenda-files nil) ;; Stop `org-mode' calling `org-agenda-files'
(org-inhibit-startup t) ;; Don't apply startup #+options
(delay-mode-hooks t))
(fileloop-continue)))))
(defun org-node-backlink--loop-operator ()
"An OPERATE-FUNCTION for `fileloop-initialize'.
Do `org-node-backlink-fix-buffer', then maybe save, maybe kill buffer."
(let ((buffer-seems-new (and (not (buffer-modified-p))
(not buffer-undo-list)))
(kill-buffer-hook nil)) ;; Inhibit save-place etc
(unless (derived-mode-p 'org-mode)
(org-mode))
(org-node-backlink-fix-buffer org-node-backlink--work-kind)
(when buffer-seems-new
(when (buffer-modified-p)
(let ((write-file-functions nil) ;; recentf-track-opened-file
(before-save-hook nil)
(after-save-hook nil))
(save-buffer)))
(kill-buffer))
t))
(defvar org-node-backlink--checked nil)
;;;###autoload
(defun org-node-backlink-fix-buffer (&optional kind)
"Update :BACKLINKS: properties or drawers in all nodes in buffer.
Let user option `org-node-backlink-do-drawers' determine which.
Or if KIND is symbol `update-drawers', `del-drawers', `update-props', or
`del-props', do the corresponding thing."
(interactive)
(save-excursion
(unless (or (and (memq kind '(update-drawers update-props))
(org-node-backlink--check-v2-misaligned-setting-p))
(and (eq kind 'update-drawers)
(org-node-backlink--check-osl-user-p)))
;; (message "Fixing file %s" buffer-file-name)
(goto-char (point-min))
(org-node--assert-transclusion-safe)
(let ((case-fold-search t))
;; NOTE: If there is an entry that has :BACKLINKS:, but that has lost its
;; :ID:, it will never be touched again, but that's on the user.
(while (re-search-forward "^[ \t]*:id:[ \t]*[[:graph:]]" nil t)
(org-node-backlink--fix-nearby kind)
(outline-next-heading))))))
;;; Proactive updating
(defcustom org-node-backlink-lazy t
"Inhibit cleaning up backlinks until user edits affected entry.
Background: Regardless of this value, links inserted via most commands
will insert a backlink in real time, so long as
`org-node-backlink-mode' is enabled.
If in the future the user deletes that link, the corresponding backlink
becomes stale. This value controls what to do upon noticing that.
When t, they are not cleaned until you carry out some edits under the
heading that holds the stale backlink, and save that buffer.
That can be desirable for e.g. quieter git diffs.
When nil, all affected nodes are silently visited after a save if needed
to ensure that their :BACKLINKS: properties or drawers reflect reality.
To clarify, this is solely about the textual contents of :BACKLINKS:
properties or drawers; the underlying link tables are up to date anyway.
Minor side effect: `org-element-cache-reset' is called in the buffers
where backlinks are fixed.
To force an update at any time, use one of these commands:
- \\[org-node-backlink-fix-buffer]
- \\[org-node-backlink-mass-update-drawers]
- \\[org-node-backlink-mass-update-props]"
:type 'boolean
:package-version '(org-node . "2.0.0"))
(defun org-node-backlink--maybe-fix-proactively (_)
"Designed for `org-mem-post-targeted-scan-functions'."
(unless org-node-backlink-lazy
(let (affected-targets)
(dolist (target (cl-union (hash-table-keys org-mem--target<>old-links)
(hash-table-keys org-mem--target<>links)))
(let ((entry (or (org-mem-entry-by-id target)
(org-mem-entry-by-roam-ref target))))
;; Entry being targeted must have an ID, or we can't use
;; `org-find-property' later.
(when (and entry (org-mem-entry-id entry))
(let ((links (gethash target org-mem--target<>links))
(old-links (gethash target org-mem--target<>old-links)))
(when (or (null links) ;; Gone: no longer any links with this target
(null old-links) ;; New: this target never been linked before
(not
;; The set of "source entries" where the links were
;; found, do they have same IDs they did before?
;; If not, then something changed.
;; (Either there's a new source or a source stopped
;; linking to this target.)
(seq-set-equal-p
(seq-keep #'org-mem-link-nearby-id old-links)
(seq-keep #'org-mem-link-nearby-id links))))
;; Something changed. So we'll visit the targeted entry to
;; re-print backlinks.
;; Alist `affected-targets' looks like:
;; ((file1 . (id1 id2 id3 ...))
;; (file2 . (...))
;; (file3 . (...)))
(push (org-mem-entry-id entry)
(alist-get (org-mem-entry-file-truename entry)
affected-targets
nil
nil
#'equal)))))))
(cl-loop
for (file . ids) in affected-targets
do (cond
((not (file-readable-p file))
(message "Cannot edit backlinks in unreadable file: %s" file))
((not (file-writable-p file))
(message "Cannot edit backlinks in unwritable file: %s" file))
((org-node--with-quick-file-buffer file
:about-to-do "About to fix backlinks"
(org-node--assert-transclusion-safe)
(let ((user-is-editing (buffer-modified-p)))
(dolist (id (delete-dups ids))
(if-let ((pos (and id (org-find-property "ID" id))))
(progn (goto-char pos)
(org-node-backlink--fix-nearby))
(error "Could not find ID %s in file %s" id file)))
(unless user-is-editing
;; Normally, `org-node--with-quick-file-buffer' only saves buffers
;; it had to open anew. Let's save even if it was open previously.
(let ((before-save-hook nil)
(after-save-hook nil)
(save-silently t)
(inhibit-message t))
(save-buffer)))))))))))
;;; Subroutine: "Fix nearby"
(defun org-node-backlink--fix-nearby (&optional kind)
"In current entry, fix the backlinks drawer or property.
Let user option `org-node-backlink-do-drawers' determine which.
Or if KIND is symbol `update-drawers', `del-drawers', `update-props', or
`del-props', do the corresponding thing."
(if kind
(pcase kind
('del-props (org-node-backlink--fix-nearby-property t))
('del-drawers (org-node-backlink--fix-nearby-drawer t))
('update-props (org-node-backlink--fix-nearby-property))
('update-drawers (org-node-backlink--fix-nearby-drawer)))
(if org-node-backlink-do-drawers
(org-node-backlink--fix-nearby-drawer)
(org-node-backlink--fix-nearby-property))))
(defun org-node-backlink--fix-nearby-property (&optional remove)
"Update the :BACKLINKS: property in the current entry.
If REMOVE is non-nil, remove it instead."
(when-let ((prop-pos (car (org-get-property-block))))
(when (get-text-property prop-pos 'read-only)
;; Because `org-entry-put' is so unsafe that it inhibits read-only
(error "org-node-backlink: Area seems to be read-only at %d in %s"
prop-pos (buffer-name))))
(if remove
(org-entry-delete nil "BACKLINKS")
(let* ((id (org-entry-get nil "ID"))
(entry (org-mem-entry-by-id id)))
(if (not (and id entry))
(org-entry-delete nil "BACKLINKS")
(let* ((origins (thread-last
(append (org-mem-id-links-to-entry entry)
(org-mem-roam-reflinks-to-entry entry))
(mapcar #'org-mem-link-nearby-id)
(delete id)
(delete-dups)
;; Sort deterministically for less noisy diffs.
(seq-sort #'string<)
(seq-keep #'org-mem-entry-by-id)))
(backlinks (cl-loop
for ogn in origins
collect (org-link-make-string
(concat "id:" (org-mem-entry-id ogn))
(org-mem-entry-title ogn))))
(new-value (string-join backlinks " ")))
(if backlinks
(unless (equal new-value (org-entry-get nil "BACKLINKS"))
(org-entry-put nil "BACKLINKS" new-value))
(org-entry-delete nil "BACKLINKS")))))))
(defun org-node-backlink--fix-nearby-drawer (&optional remove)
"Update nearby backlinks drawer so it reflects current reality.
If REMOVE non-nil, remove it instead."
(if remove
(org-node--delete-drawer "BACKLINKS")
(let* ((id (org-entry-get nil "ID"))
(entry (org-mem-entry-by-id id))
(origin-ids (when entry
(thread-last
(append (org-mem-id-links-to-entry entry)
(org-mem-roam-reflinks-to-entry entry))
(seq-keep #'org-mem-link-nearby-id)
(delete id)
(delete-dups)
;; Shouldn't be necessary if tables are correct, but
;; don't assume `org-mem-updater-mode' is flawless
(seq-filter #'org-mem-entry-by-id))))
(org-node--inhibit-flagging t))
(if (null origin-ids)
(org-node--delete-drawer "BACKLINKS")
(save-excursion
(save-restriction
(org-node-narrow-to-drawer-create
"BACKLINKS" org-node-backlink-drawer-positioner)
(let* ((col (current-indentation))
(lines (split-string (buffer-string) "\n" t))
(already-present-ids
(seq-keep #'org-node-backlink--extract-id lines))
(to-add (seq-difference origin-ids already-present-ids))
(to-remove (seq-difference already-present-ids origin-ids))
(to-reformat (seq-intersection already-present-ids origin-ids)))
;; Add new, remove stale, reformat the rest
(dolist (id to-remove)
(save-excursion
(search-forward id)
(delete-line)))
(dolist (id to-reformat)
(save-excursion
(search-forward id)
(back-to-indentation)
(let* ((line (buffer-substring-no-properties (point) (pos-eol)))
(reformatted (org-node-backlink--reformat-line line)))
(unless (equal line reformatted)
(atomic-change-group
(delete-region (point) (pos-eol))
(insert reformatted))))))
(dolist (id to-add)
(let ((title (org-mem-title-maybe (org-mem-entry-by-id id))))
(insert (funcall org-node-backlink-drawer-formatter id title))
(newline)
(indent-to col)))
;; Membership is correct, now re-sort
(let ((sorted-lines (sort (split-string (buffer-string) "\n" t)
org-node-backlink-drawer-sorter)))
(when org-node-backlink-drawer-sort-in-reverse
(setq sorted-lines (nreverse sorted-lines)))
(unless (equal lines sorted-lines)
(atomic-change-group
(delete-region (point-min) (point-max))
(insert (string-join sorted-lines "\n"))))))))))))
;;; Subroutine: "Add in target" (to advise real-time link insertion)
;; This logic is independent from the per-buffer validation, because that
;; operates on the file being saved -- in other words, making the file
;; navel-gaze its own content to see if it looks correct according to current
;; links tables. Technically, that would be enough to result in correct
;; backlinks everywhere if you just run it on all files, and that's
;; more-or-less how `org-node-backlink--fix-all-files' works, but we don't want
;; to do that on every save.
;; By contrast, the below code does not look up tables, just reacts to the
;; exact link being inserted, which has two benefits:
;; 1. You can observe backlinks appearing in realtime before a buffer is saved
;; 2. It's actually necessary, because a link being inserted does not mean we
;; should check the current file but rather visit and edit the target file.
;; If we didn't have the below code, we'd have to save the current buffer
;; (in order to update tables) and then open the target file and run
;; `org-node-backlink-fix-buffer', which can easily take a while for a big
;; target file.
;; REVIEW: In theory, it is possible to drop these advices, letting user insert
;; links with zero Emacs lag, if we instead use something like
;; `org-node-backlink--maybe-fix-proactively' after some idle...
(defun org-node-backlink--add-in-target (&rest _)
"For known link at point, leave a backlink in the target node."
(unless (derived-mode-p 'org-mode)
(error "Backlink function called in non-Org buffer"))
(org-node-cache-ensure)
(let* ((elm (org-element-context))
(path (org-element-property :path elm))
(type (org-element-property :type elm))
target-id target-file)
;; In a link such as [[id:abc1234]], TYPE is "id" and PATH is "abc1234".
(when (and type path)
(if (equal "id" type)
;; A classic backlink
(progn
(setq target-id path)
;; `org-id-find-id-file' has terrible fallback behavior
(setq target-file (ignore-errors
(org-mem-entry-file
(org-mem-entry-by-id target-id)))))
;; A "reflink"
(setq target-id (gethash path org-mem--roam-ref<>id))
(setq target-file (ignore-errors
(org-mem-entry-file
(org-mem-entry-by-id target-id)))))
;; (when (null target-file)
;; (message "`org-node-backlink--add-in-target' could not resolve ID: %s" target-id))
(when (and target-id target-file)
(org-node--assert-transclusion-safe)
(let ((origin-id (org-entry-get-with-inheritance "ID")))
(when (and origin-id (not (equal origin-id target-id)))
(when-let ((origin-title
(save-excursion
(without-restriction
(goto-char org-entry-property-inherited-from)
(or (org-get-heading t t t t)
(org-get-title))))))
(org-node--with-quick-file-buffer target-file
:about-to-do "Org-node going to add backlink in target of link you just inserted"
(org-node--assert-transclusion-safe)
(if-let ((pos (org-find-property "ID" target-id)))
(progn (goto-char pos)
(org-node-backlink--add-nearby origin-id origin-title))
(message "`org-node-backlink--add-in-target' could not find ID %s in file %s"
target-id target-file))))))))))
(defun org-node-backlink--add-nearby (id title)
"Add link with ID and TITLE into local backlink drawer or property."
(if org-node-backlink-do-drawers
(org-node-backlink--add-to-drawer id title)
(org-node-backlink--add-to-property id title)))
(defun org-node-backlink--add-to-property (id title)
"Insert a link with ID and TITLE into nearby :BACKLINKS: property."
(when-let ((prop-pos (car (org-get-property-block))))
(when (get-text-property prop-pos 'read-only)
;; Because `org-entry-put' is so unsafe that it inhibits read-only
(error "org-node-backlink: Area seems to be read-only at %d in %s"
prop-pos (buffer-name))))
(let ((current-backlinks-value (org-entry-get nil "BACKLINKS"))
(new-link (org-link-make-string (concat "id:" id) title))
new-value
(org-node--inhibit-flagging t))
(when (and current-backlinks-value
(string-search "\f" current-backlinks-value))
(error "Form-feed character in BACKLINKS property near %d in %s"
(point) (buffer-name)))
(if current-backlinks-value
;; Build a temp list to check we don't add the same link twice.
;; There is an Org builtin `org-entry-add-to-multivalued-property',
;; but we cannot use it since the link descriptions may contain
;; spaces. Further, they may contain quotes(!), so we cannot use
;; `split-string-and-unquote' even if we had wrapped the links in
;; quotes.
(let ((links (split-string (replace-regexp-in-string
(rx "]]" (+ space) "[[")
"]]\f[["
(string-trim current-backlinks-value))
"\f")))
(cl-loop for link in links
when (string-search id link)
do (setq links (delete link links)))
(push new-link links)
(when (seq-some #'null links)
(error "org-node: nils in %S" links))
;; Enforce deterministic order to prevent unnecessary reordering
;; every time a node is linked that already has the backlink
(sort links #'string-lessp)
(setq new-value (string-join links " ")))
;; Only one link
(setq new-value new-link))
(unless (equal new-value current-backlinks-value)
(let ((user-is-editing (buffer-modified-p)))
(org-entry-put nil "BACKLINKS" new-value)
(unless user-is-editing
(let ((before-save-hook nil)
(after-save-hook nil))
(save-buffer)))))))
(defun org-node-backlink--add-to-drawer (id title)
"Add new backlink with ID and TITLE to nearby drawer.
Designed for use by `org-node-backlink--add-in-target'."
(cl-assert id) ;; Don't want to insert empty [[id:]] ever
(if (or (org-node-backlink--check-v2-misaligned-setting-p)
(org-node-backlink--check-osl-user-p))
(org-node-backlink-mode 0)
(save-excursion
(save-restriction
(let ((org-node--inhibit-flagging t))
(org-node-narrow-to-drawer-create
"BACKLINKS" org-node-backlink-drawer-positioner)
(unless (search-forward (concat "[[id:" id) nil t) ;; Already has it
(let ((col (current-indentation)))
(insert (funcall org-node-backlink-drawer-formatter id title))
(unless (eolp)
;; NOTE: Don't use `newline-and-indent' because it may deepen
;; indentation of a list bullet.
(newline)
(indent-to col)))
;; Re-sort so just-inserted link is placed correct among them
(let ((lines (sort (split-string (buffer-string) "\n" t)
org-node-backlink-drawer-sorter)))
(when org-node-backlink-drawer-sort-in-reverse
(setq lines (nreverse lines)))
(atomic-change-group
(delete-region (point-min) (point-max))
(insert (string-join lines "\n"))))))))))
;;; Global minor mode
;;;###autoload
(define-minor-mode org-node-backlink-mode
"Keep :BACKLINKS: properties or drawers updated.
See Info node `(org-node)'."
:global t
(if org-node-backlink-mode
(progn
(add-hook 'org-mem-post-targeted-scan-functions #'org-node-backlink--maybe-fix-proactively)
(add-hook 'org-node-relocation-hook #'org-node-backlink--fix-nearby)
(add-hook 'org-node-modification-hook #'org-node-backlink--fix-nearby)
(add-hook 'org-roam-post-node-insert-hook #'org-node-backlink--add-in-target)
(add-hook 'org-node-insert-link-hook #'org-node-backlink--add-in-target)
(advice-add 'org-insert-link :after #'org-node-backlink--add-in-target))
(remove-hook 'org-mem-post-targeted-scan-functions #'org-node-backlink--maybe-fix-proactively)
(remove-hook 'org-node-relocation-hook #'org-node-backlink--fix-nearby)
(remove-hook 'org-node-modification-hook #'org-node-backlink--fix-nearby)
(remove-hook 'org-roam-post-node-insert-hook #'org-node-backlink--add-in-target)
(remove-hook 'org-node-insert-link-hook #'org-node-backlink--add-in-target)
(advice-remove 'org-insert-link #'org-node-backlink--add-in-target)))
(let (warned-once)
(defun org-node-backlinks-mode (&rest args)
(unless warned-once
(setq warned-once t)
(run-with-timer
.1 nil #'display-warning 'org-node
"Your initfiles may have misspelled `org-node-backlink-mode' as `org-node-backlinks-mode'"))
(apply #'org-node-backlink-mode args)))
(define-obsolete-function-alias 'org-node-backlink-id-blind-simple-lessp
#'org-node-backlink-id-blind-string-lessp "2025-05-24")
(provide 'org-node-backlink)
;;; org-node-backlink.el ends here
;; Local Variables:
;; checkdoc-spellcheck-documentation-flag: nil
;; checkdoc-verb-check-experimental-flag: nil
;; emacs-lisp-docstring-fill-column: 72
;; read-symbol-shorthands: (("and$" . "cond-let--and$")
;; ("and>" . "cond-let--and>")
;; ("and-let" . "cond-let--and-let")
;; ("if-let" . "cond-let--if-let")
;; ("when$" . "cond-let--when$")
;; ("when-let" . "cond-let--when-let")
;; ("while-let" . "cond-let--while-let"))
;; End: