-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathor-struktur.el
More file actions
1388 lines (1209 loc) · 50.4 KB
/
Copy pathor-struktur.el
File metadata and controls
1388 lines (1209 loc) · 50.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
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
;;; or-struktur.el --- Structure Notes for Org Roam -*- lexical-binding: t -*-
;;
;; Copyright (C) 2026 Taro Sato
;;
;; Author: Taro Sato <okomestudio@gmail.com>
;; URL: https://github.com/okomestudio/or-struktur
;; Version: 0.25.2
;; Keywords: org-roam, convenience
;; Package-Requires: ((emacs "30.1"))
;;
;;; License:
;;
;; This program is free software; you can redistribute it and/or modify it under
;; the terms of the GNU General Public License as published by the Free Software
;; Foundation, either version 3 of the License, or (at your option) any later
;; version.
;;
;; This program is distributed in the hope that it will be useful, but WITHOUT
;; ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
;; FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
;; details.
;;
;; You should have received a copy of the GNU General Public License along with
;; this program. If not, see <https://www.gnu.org/licenses/>.
;;
;;; Commentary:
;;
;; This Org Roam plugin provides support for structure notes (strukturzettel in
;; German).
;;
;;; Code:
(require 'org)
(require 'org-roam)
(defgroup or-struktur nil
"Settings for `or-struktur'."
:group 'extensions
:link '(url-link "https://github.com/okomestudio/or-struktur"))
(defcustom or-struktur-mode-prefix "C-c s"
"Prefix key sequence for `or-struktur-mode' commands."
:type 'string
:group 'or-struktur)
(defcustom or-struktur-sz-tag "sz"
"Tag for `org-roam' nodes indicating strukturzettels."
:type 'string
:group 'or-struktur)
(defcustom or-struktur-sid-text-placement 'before-string
"Specify side of overlay to show rendered SID."
:type '(choice before-string after-string)
:group 'or-struktur)
(defcustom or-struktur-sid-text-wrapper "[%s]"
"String wrapper used for text representation of SID.
When set to nil, text wrapping is disabled for all SIDs, regardless of
per-strukturzettel settings."
:type 'string
:group 'or-struktur)
(defcustom or-struktur-sid-text-format '("%d" ".%d")
"String format for SID representation.
Allowed formatters are '%d' (numeric) and '%s' (alphabetic). When extended,
alphanumeric components are alternated.
Examples:
- (\"%d\" \"%s\") will render like '2d3a1' (numeric then alphabet, with the
rest alternating).
- (\"%d\" \"-%s\") will render like '2-d3a1' (second digit preceded by a
hyphen, with the rest alternating)."
:type '(list string)
:group 'or-struktur)
(defcustom or-struktur-view-layout 'top
"Initial side window layout."
:type '(choice (const :tag "Top" top)
(const :tag "Right" right)
(const :tag "Left" left)
(const :tag "Bottom" bottom))
:group 'or-struktur)
(defcustom or-struktur-view-layout-sizes '((0.167 0.33 0.5) . (0.2 0.4 0.6))
"Preset sizes for left/right and top/bottom layout.
Use `or-struktur-view--window-expand' to cycle through these options."
:type '(cons (repeat (choice number))
(repeat (choice number)))
:group 'or-struktur)
(defcustom or-struktur-view-tags-exclude nil
"Tags to exclude from being added to headlines in strukturzettels."
:type '(repeat string)
:group 'or-struktur)
(defcustom or-struktur-view-show-title 'minibuffer
"Target to show node title at point.
Either nil or `minibuffer' is allowed."
:type '(choice (const :tag "Minibuffer" minibuffer)
(const :tag "Do not show" nil))
:group 'or-struktur)
(defcustom or-struktur-view-show-title-delay 0.2
"Delay before showing title in view mode."
:type 'number
:group 'or-struktur)
(defface or-struktur-overlay
`((t :inherit fixed-pitch
:height 0.85
:underline nil
:foreground ,(face-attribute 'shadow :foreground)
:background ,(face-attribute 'shadow :background)))
"Face used for SID overlays.")
(defconst or-struktur-view--buffer-name " strukturzettel buffer"
"Name of indirect buffer visiting strukturzettel file.")
(defvar or-struktur--db (make-hash-table :test #'equal)
"Mapping storage.")
(defvar or-struktur--ov-faces nil)
(defvar or-struktur-sid-text-wrapper--alist nil)
;;; Utilities
(defmacro or-struktur--debounce (delay &rest body)
"Run BODY after DELAY seconds of idle time, debouncing repeated invocations."
(declare (indent 1) (debug t))
(let ((timer-var (make-symbol (format "debounce-timer-%d" (sxhash body)))))
`(progn
(when (bound-and-true-p ,timer-var)
(cancel-timer ,timer-var))
(set (make-local-variable ',timer-var)
(run-with-idle-timer
,delay nil
(lambda ()
,@body))))))
(defmacro or-struktur--disable-command (mode command)
"Disable COMMAND in major MODE."
(let* ((command-s
(replace-regexp-in-string "[\\#']" "" (format "%s" command)))
(mode-s (replace-regexp-in-string "'" "" (format "%s" mode)))
(fun (make-symbol (format "%s--disable-in-%s" command-s mode-s))))
`(progn
(defun ,fun (&rest _)
(when (derived-mode-p ',mode)
(user-error "%s is disabled in %s" ,command-s ,mode-s)))
(advice-add ,command :before #',fun))))
(defmacro or-struktur--narrow-and-eval (beg end buffer &rest body)
"Narrow to region from BEG to END of BUFFER and evaluate BODY."
(declare (indent 3))
`(with-current-buffer (or buffer (current-buffer))
(let ((beg (or ,beg (point-min)))
(end (or ,end (point-max))))
(when (> (- end beg) 1)
(save-excursion
(save-restriction
(narrow-to-region beg end)
,@body))))))
(defun or-struktur--message (&rest _rest)
"Display via `message', but a little more quietly."
(let ((inhibit-message t))
(apply #'message _rest)))
(defun or-struktur--alist-binary-search-floor (alist key)
"Return ALIST element whose key is floor of KEY."
(let* ((left 0)
(right (1- (length alist)))
result)
(while (<= left right)
(let* ((mid (+ left (/ (- right left) 2)))
(entry (nth mid alist))
(val (car entry)))
(cond
((= val key) (setq result entry) (setq left (1+ mid)))
((< val key) (setq result entry) (setq left (1+ mid)))
(t (setq right (1- mid))))))
result))
(defun or-struktur--list-binary-search-ceil (lis key)
"Return list, LIS, index of element whose value is ceil of KEY."
(let* ((left 0)
(right (1- (length lis)))
result)
(while (<= left right)
(let* ((mid (+ left (/ (- right left) 2)))
(elmt (nth mid lis)))
(cond
((= elmt key) (setq result mid) (setq right (1- mid)))
((> elmt key) (setq result mid) (setq right (1- mid)))
(t (setq left (1+ mid))))))
result))
;;;
;;; Minor Mode (or-struktur-mode)
;;;
(defvar-keymap or-struktur-mode-prefix-map
:doc "Keymap for `or-struktur-mode' under prefix."
"a" #'or-struktur-node-find
"c" #'or-struktur-node-find-children
"p" #'or-struktur-node-find-parents
"s" #'or-struktur-node-find-siblings
"i c" #'or-struktur-node-insert-child
"i p" #'or-struktur-node-insert-parent
"i s" #'or-struktur-node-insert-sibling)
(defvar-keymap or-struktur-mode-map
:doc "Keymap for `or-struktur-mode'.")
(keymap-set or-struktur-mode-map or-struktur-mode-prefix or-struktur-mode-prefix-map)
;;;###autoload
(define-minor-mode or-struktur-mode
"Minor mode for strukturzettels support to Org Roam."
:lighter " struct"
:group 'org-roam
:keymap 'or-struktur-mode-map
(pcase or-struktur-mode
('t (or-struktur-mode--on))
(_ (or-struktur-mode--off))))
(defun or-struktur-mode--on ()
"Activate `or-struktur-mode'."
(add-hook 'or-struktur-mode-hook #'or-struktur--db-init-maybe)
(when (or-struktur-sz-p)
(add-hook 'after-save-hook #'or-struktur-mode--on-after-save 99 t))
(add-hook 'window-scroll-functions #'or-struktur-mode--on-window-scroll 99 t)
(add-hook 'before-change-functions #'or-struktur-mode--on-before-change 99 t)
(add-hook 'after-change-functions #'or-struktur-mode--on-after-change 99 t))
(defun or-struktur-mode--off ()
"Deactivate `or-struktur-mode'."
(remove-hook 'after-change-functions #'or-struktur-mode--on-after-change t)
(remove-hook 'before-change-functions #'or-struktur-mode--on-before-change t)
(remove-hook 'window-scroll-functions #'or-struktur-mode--on-window-scroll t)
(when (or-struktur-sz-p)
(remove-hook 'after-save-hook #'or-struktur-mode--on-after-save t))
(remove-hook 'or-struktur-mode-hook #'or-struktur--db-init-maybe))
(defun or-struktur-mode--on-before-change (beg end)
(or-struktur--ov-remove beg end))
(defun or-struktur-mode--on-after-change (beg end len)
(or-struktur--ov-render beg end))
(defun or-struktur-mode--on-window-scroll (win beg)
(or-struktur--ov-refresh beg (window-end win t) (window-buffer win)))
(defun or-struktur-mode--on-after-save ()
;; NOTE: Even when the save is performed in an indirect buffer, the hook runs
;; on their base buffers.
(or-struktur--db-from-strukturzettel)
(when-let* ((buf (current-buffer))
(win (get-buffer-window buf))
(beg (window-start win))
(end (window-end win t)))
(or-struktur--ov-refresh beg end buf))
;; Update struktur view window if visible.
(when-let* ((win (or-struktur-view--window))
(beg (window-start win))
(end (window-end win t)))
(with-selected-window win
(let ((buf (current-buffer)))
(or-struktur--ov-refresh beg end buf)
(or-struktur-view--font-lock-sync beg end buf)))))
;;; Mapping Storage for ID-SID Relations
;; NOTE: The storage implementation uses a hash table. For scalability, perhaps
;; consider using a relational database, e.g., SQLite.
(defun or-struktur--db-empty-p ()
"Return non-nil if mapping storage is empty."
(= (hash-table-count or-struktur--db) 0))
(defun or-struktur--db-clear ()
"Empty mapping storage."
(clrhash or-struktur--db))
(defun or-struktur--db-sid2id-get (sid)
"Get ID for SID from mapping storage."
(when-let* ((v (gethash `(sid ,sid) or-struktur--db)))
(car v)))
(defun or-struktur--db-id2sid-get (id &optional extra)
"Get all SIDs associated with ID from mapping storage.
When EXTRA is non-nil, return also strukturzettel ID and the position of SID
entry."
(apply #'append
(seq-keep
(lambda (sz-id)
(when-let*
((db (gethash `(sz ,sz-id)
or-struktur--db)))
(mapcar
(lambda (item)
(pcase-let* ((`(,sid . ,line) item))
(if extra `(,sid ,sz-id ,line) sid)))
(gethash id db))))
(gethash `(id ,id) or-struktur--db))))
(defun or-struktur--db-init ()
"Fill mapping storage from all known strukturzettels."
(or-struktur--db-clear)
(dolist (node (or-struktur-sz-list))
(let* ((file (org-roam-node-file node))
(buf-open (get-file-buffer file))
(buf (or buf-open (find-file-noselect file))))
(with-current-buffer buf
(or-struktur--db-from-strukturzettel)
(unless buf-open
(kill-current-buffer))))))
(defun or-struktur--db-init-maybe ()
"If mapping storage is empty, initialize."
(when (or-struktur--db-empty-p)
(or-struktur--db-init)))
(defun or-struktur--prop-get (props key &optional default)
"Get value for KEY in node PROPS.
If value is nil, returns DEFAULT."
(let ((key (concat "STRUKTUR_" key)))
(or (cdr (assoc key props)) default)))
(defun or-struktur--conf-from-props (props)
"Read configurations from node properties PROPS."
(let ((start (string-to-number (or-struktur--prop-get props "START" "1"))))
(unless (and (integerp start) (> start 0))
(error "STRUKTUR_START must be a positive integer"))
(when-let* ((v (or-struktur--prop-get props "TEXT_WRAPPER")))
(setf (alist-get start or-struktur-sid-text-wrapper--alist) v)
(setq or-struktur-sid-text-wrapper--alist (sort or-struktur-sid-text-wrapper--alist)))
(let (plist box)
(when-let* ((v (or-struktur--prop-get props "FACE_FOREGROUND"))
(v (if (string= v "nil") nil v)))
(setq plist (append plist `(:foreground ,v))))
(when-let* ((v (or-struktur--prop-get props "FACE_BACKGROUND"))
(v (if (string= v "nil") nil v)))
(setq plist (append plist `(:background ,v))))
(when-let* ((v (or-struktur--prop-get props "FACE_BOX_LINE_WIDTH")))
(setq box (append box `(:line-width ,v))))
(when-let* ((v (or-struktur--prop-get props "FACE_BOX_COLOR")))
(setq box (append box `(:color
,(pcase v
("nil" (face-background 'default nil t))
(_ v))))))
(when box
(setq plist (append plist `(:box ,box))))
(when plist
(setf (alist-get start or-struktur--ov-faces) plist)
(setq or-struktur--ov-faces (sort or-struktur--ov-faces))))
start))
;; TODO(2026-02-05): Improve by performing update on affected tree.
(defun or-struktur--db-from-strukturzettel ()
"Update storage mapping from current strukturzettel."
(let* ((node (org-roam-node-at-point))
(sz-id (org-roam-node-id node))
(start (1- (or-struktur--conf-from-props
(org-roam-node-properties node))))
(sid `(,start))
(fdb (make-hash-table :test #'equal)))
(org-element-map (org-element-parse-buffer) 'headline
(lambda (elmt)
(let ((level (org-element-property :level elmt))
(line (line-number-at-pos (org-element-property :begin elmt) t))
vs)
(setq sid (or-struktur-sid--resize sid level))
(or-struktur-sid--lsd-inc sid)
(when-let*
((lnk (org-element-map (org-element-property :title elmt) 'link
#'identity nil 'first-match))
(id (and (equal (org-element-property :type lnk) "id")
(org-element-property :path lnk))))
(setq vs (gethash id fdb))
(push (cons (copy-sequence sid) line) vs)
(puthash id vs fdb)
(let* ((key `(id ,id))
(sz-ids (gethash key or-struktur--db)))
(cl-pushnew sz-id sz-ids :test #'equal)
(puthash key sz-ids or-struktur--db))
(puthash `(sid ,(copy-sequence sid)) (cons id sz-id)
or-struktur--db)))))
(puthash `(sz ,sz-id) fdb or-struktur--db)))
;;; Overlay Management
(defun or-struktur--ov-put (beg end text)
"Add TEXT overlay for content from BEG to END."
(let* ((ov (make-overlay beg end))
(space (propertize " "
'face '( :inherit or-struktur-overlay
:height 0.25 )))
(text (pcase or-struktur-sid-text-placement
('before-string (concat text space))
('after-string (concat space text)))))
(overlay-put ov or-struktur-sid-text-placement text)
(overlay-put ov 'category 'or-struktur)
(overlay-put ov 'evaporate t)))
(defun or-struktur--ov-format (id)
"Render SIDs for ID as string for overlay."
(when-let* ((sids (or-struktur--db-id2sid-get id)))
(string-join
(mapcar
(lambda (sid)
(let ((text-wrapper
(if or-struktur-sid-text-wrapper
(or (cdr (or-struktur--alist-binary-search-floor
or-struktur-sid-text-wrapper--alist (car sid)))
or-struktur-sid-text-wrapper)
"%s"))
(face (append '(:inherit or-struktur-overlay)
(cdr (or-struktur--alist-binary-search-floor
or-struktur--ov-faces (car sid))))))
(propertize (format text-wrapper (or-struktur-sid--render sid))
'face face)))
sids)
(string ?\u200B))))
(defun or-struktur--ov-render-in-title ()
"Render SID overlays in document title.
The title is obtained from `#+title:'."
(goto-char (point-min)) ; this respects narrowing
(when-let* ((node (org-roam-node-at-point))
(s (or-struktur--ov-format (org-roam-node-id node))))
(when (re-search-forward "^#\\+TITLE:[ \t]*\\(.*\\)$" (point-max) t)
(or-struktur--ov-put (match-beginning 1) (match-end 1) s))))
(defun or-struktur--ov-render-in-headlines ()
"Render SID overlays in headlines.
IDs are extracted from headline properties."
(org-element-map (org-element-parse-buffer) 'headline
(lambda (elmt)
(when-let* ((id (org-element-property :ID elmt))
(s (or-struktur--ov-format id))
(beg (org-element-property :title-begin elmt))
(end (org-element-property :title-end elmt)))
(or-struktur--ov-put beg end s)))))
(defun or-struktur--ov-render-link (elmt)
"Render SID overlay for link ELMT."
(when-let* ((id (and (string= (org-element-property :type elmt) "id")
(org-element-property :path elmt)))
(s (or-struktur--ov-format id))
(beg (org-element-property :begin elmt))
(end (org-element-property :end elmt)))
(or-struktur--ov-put beg end s)))
(defun or-struktur--ov-remove (&optional beg end buffer)
"Remove SID overlays in BUFFER region from BEG to END."
(or-struktur--narrow-and-eval beg end buffer
(remove-overlays beg end 'category 'or-struktur)))
(defun or-struktur--ov-render (&optional beg end buffer)
"Render SID overlays in BUFFER region from BEG to END."
(or-struktur--narrow-and-eval beg end buffer
(or-struktur--ov-render-in-title)
(or-struktur--ov-render-in-headlines)
(org-element-map (org-element-parse-buffer) 'link
#'or-struktur--ov-render-link)))
(defun or-struktur--ov-refresh (&optional beg end buffer)
"Refresh SID overlays in BUFFER region from BEG to END."
(or-struktur--ov-remove beg end buffer)
(or-struktur--ov-render beg end buffer))
;;; Struktur ID (SID)
;; An SID is an ID of an org-roam node assigned by its placement in a
;; strukturzettel. The underlying data for SID is a list of integers, e.g., (2 3
;; 5).
(defun or-struktur-sid--number-to-alpha (n)
"Convert positive N to alphabet representation.
The return value is a string or nil if N is not a positive integer."
(unless (and (integerp n) (> n 0))
(error "N must be a positive integer"))
(let ((result "")
(num n))
(while (> num 0)
(let* ((adjusted (1- num))
(remainder (mod adjusted 26))
(letter (char-to-string (+ ?a remainder))))
(setq result (concat letter result))
(setq num (/ adjusted 26))))
result))
(defun or-struktur-sid--render (sid)
"Render SID using preset format.
The preset format is set with `or-struktur-sid-text-format'."
(string-join
(cl-loop with (converter) = '(nil)
for i below (length sid)
collect
(let ((fmt (or (nth i or-struktur-sid-text-format)
(if (eq converter #'identity) "%s" "%d"))))
(setq converter (or (and (string-search "%d" fmt) #'identity)
#'or-struktur-sid--number-to-alpha))
(format fmt (funcall converter (nth i sid)))))))
(defun or-struktur-sid--resize (sid n &optional initval)
"Resize SID to N digits.
If given, fill new digit(s) with INITVAL (defaults to zero)."
(let ((initval (or initval 0))
(m (length sid)))
(cond ((< n m) (seq-take sid n))
((< m n) (append sid (make-list (- n m) initval)))
(t sid))))
(defun or-struktur-sid--lsd-inc (sid)
"Increment least-significant digit of SID."
(let ((lsd (1- (length sid))))
(setcar (nthcdr lsd sid) (1+ (nth lsd sid)))))
(defun or-struktur-sid--from-id (&optional id extra)
"Get SIDs for node ID.
If not given, ID defaults to that of current node.
See `or-struktur--mapping-id2fz-get' for EXTRA."
(or-struktur--db-id2sid-get (or id (org-roam-id-at-point)) extra))
(defun or-struktur-sid--to-id (sid)
"Get node ID for SID."
(or-struktur--db-sid2id-get sid))
(defun or-struktur-sid--get-children (sid)
"Get SIDs of existing child nodes for SID."
(let (result)
(if sid
(let* ((sid (copy-sequence sid))
(fz-child (or-struktur-sid--resize sid (1+ (length sid)) 1)))
(while (or-struktur--db-sid2id-get fz-child)
(push (copy-sequence fz-child) result)
(or-struktur-sid--lsd-inc fz-child)))
;; Top-level folgezettels could be non-contiguous.
(maphash (lambda (k _)
(pcase-let ((`(,type ,sid) k))
(when (and (eq type 'sid) (= (length sid) 1))
(push (copy-sequence sid) result))))
or-struktur--db))
result))
(defun or-struktur-sid--get-parents (sid)
"Get parent SID of SID."
(list (butlast sid)))
(defun or-struktur-sid--get-siblings (sid)
"Get SIDs of existing siblings for SID."
(apply #'append
(mapcar (lambda (sid)
(or-struktur-sid--get-children sid))
(or-struktur-sid--get-parents sid))))
;;; Strukturzettel Nodes
(defun or-struktur-sz-p (&optional node)
"Return non-nil if NODE is strukturzettel node.
A strukturzettel is defined as an Org document with one of its file tags being
`or-struktur-sz-tag'."
(when-let* ((node (or node (org-roam-node-at-point)))
(tags (org-roam-node-tags node)))
(member or-struktur-sz-tag tags)))
(defun or-struktur-sz-list (&optional fun)
"Get strukturzettel nodes.
FUN (default: `identity') is a function that takes a strukturzettel node as an
argument and returns transformation."
(let ((fun (or fun #'identity)))
(seq-keep (lambda (node)
(when (or-struktur-sz-p node)
(funcall fun node)))
(org-roam-node-list))))
(defun or-struktur-sz-select ()
"Select strukturzettel node.
The function calls `completing-read' to prompt for a node interactively."
(when-let*
((options (or-struktur-sz-list
(lambda (node)
(cons (org-roam-node-title node) node)))))
(if (> (length options) 1)
(alist-get (completing-read "Strukturzettel: " options nil t)
options nil nil #'equal)
(cdar options))))
;;; Nodes
(defun or-struktur-node-has-sid-p (&optional node)
"Return non-nil if NODE has SID.
If not given, NODE will be node at point."
(and (or-struktur-sid--from-id (and node (org-roam-node-id node))) t))
(defun or-struktur-node-find ()
"Find and open node found in any strukturzettel."
(interactive)
(org-roam-node-find nil nil (lambda (node)
(or-struktur-node-has-sid-p node))))
(defun or-struktur-node--op (type filter-fn)
"Perform operation of TYPE on nodes related to node at point.
TYPE is either `find' or `insert'.
The function FILTER-FN takes an SID and returns related nodes."
(if-let* ((node (and (derived-mode-p 'org-mode) (org-roam-node-at-point)))
(id (and (or-struktur-node-has-sid-p node)
(org-roam-node-id node))))
(if-let* ((ids (seq-keep
(lambda (sid) (or-struktur-sid--to-id sid))
(apply #'append
(seq-keep
(lambda (sid) (funcall filter-fn sid))
(or-struktur-sid--from-id id)))))
(fun (lambda (node) (member (org-roam-node-id node) ids))))
(pcase type
('find (org-roam-node-find nil nil fun))
('insert (org-roam-node-insert fun)))
(message "No nodes found"))
(warn "Not in a relevant org-roam node")))
(defun or-struktur-node-find-children ()
"Find and open children of node at point."
(interactive)
(or-struktur-node--op 'find #'or-struktur-sid--get-children))
(defun or-struktur-node-find-parents ()
"Find and open parents of node at point."
(interactive)
(or-struktur-node--op 'find #'or-struktur-sid--get-parents))
(defun or-struktur-node-find-siblings ()
"Find and open siblings of node at point."
(interactive)
(or-struktur-node--op 'find #'or-struktur-sid--get-siblings))
(defun or-struktur-node-insert-child ()
"Insert Org link to child of node at point."
(interactive)
(or-struktur-node--op 'insert #'or-struktur-sid--get-children))
(defun or-struktur-node-insert-parent ()
"Insert Org link to parent of node at point."
(interactive)
(or-struktur-node--op 'insert #'or-struktur-sid--get-parents))
(defun or-struktur-node-insert-sibling ()
"Insert Org link to sibling of node at point."
(interactive)
(or-struktur-node--op 'insert #'or-struktur-sid--get-siblings))
;;;
;;; Major Mode (or-struktur-view-mode)
;;;
(defvar-keymap or-struktur-view-mode-map
:doc "Keymap for `or-struktur-mode' under prefix."
;; Similar to org-speed-command:
"n" #'or-struktur-view-next-headline
"p" #'or-struktur-view-previous-headline
"f" #'or-struktur-view-next-sibling-headline
"b" #'or-struktur-view-previous-sibling-headline
"u" #'or-struktur-view-parent-headline
"o" #'or-struktur-view-open-zettel
"v" #'or-struktur-view-visit-zettel
"<return>" #'or-struktur-view-visit-zettel
"i" #'or-struktur-view-imenu
"<backtab>" #'or-struktur-view-cycle-global-visibility
"<tab>" #'or-struktur-view-cycle-visibility
"C c" #'or-struktur-view-insert-child
"C s" #'or-struktur-view-insert-sibling
"M-<left>" #'or-struktur-view-do-headline-promote
"M-<right>" #'or-struktur-view-do-headline-demote
"M-S-<left>" #'or-struktur-view-do-subtree-promote
"M-S-<right>" #'or-struktur-view-do-subtree-demote
"M-<down>" #'or-struktur-view-do-subtree-move-down
"M-<up>" #'or-struktur-view-do-subtree-move-up
"S k" #'or-struktur-view-do-subtree-cut
"S y c" #'or-struktur-view-do-subtree-paste-as-child
"S y s" #'or-struktur-view-do-subtree-paste-as-sibling
"T" #'or-struktur-view-edit-link-desc
"E" #'or-struktur-view-edit
"O" #'or-struktur-view-switch-strukturzettel
"L t" #'or-struktur-view-top
"L r" #'or-struktur-view-right
"L b" #'or-struktur-view-bottom
"L l" #'or-struktur-view-left
"W" #'or-struktur-view-expand
"V" #'or-struktur-view-preview-toggle
"R" #'font-lock-fontify-buffer
"q" #'delete-window)
(set-keymap-parent or-struktur-view-mode-map text-mode-map)
;;;###autoload
(define-derived-mode or-struktur-view-mode org-mode "struktur"
"Major mode for strukturzettels."
:group 'org-roam
;; Load directory local variables, as indirect buffers do not load them by
;; default.
(when-let*
((base (buffer-base-buffer))
(default-directory (buffer-local-value 'default-directory base)))
(hack-dir-local-variables-non-file-buffer))
(read-only-mode 1)
;; Disable input method
(make-local-variable 'current-input-method)
(setq current-input-method nil)
(make-local-variable 'default-input-method)
(setq default-input-method nil)
(or-struktur--disable-command 'or-struktur-view-mode
#'toggle-input-method))
(defun or-struktur-view--on-after-change-major-mode ()
(when (derived-mode-p 'or-struktur-view-mode)
(unless (buffer-narrowed-p)
;; Narrow to content, hiding file properties, etc.
(widen)
(goto-char (point-min))
(when (re-search-forward org-outline-regexp-bol nil t)
(narrow-to-region (point-at-bol) (point-max))))))
(defun or-struktur-view--on-capture-before-finalize ()
(when-let*
((buff (and (bound-and-true-p org-capture-plist)
(eq (plist-get org-capture-plist :finalize) 'insert-link)
(plist-get org-capture-plist :original-buffer))))
(with-current-buffer buff
(when (derived-mode-p 'or-struktur-view-mode)
(read-only-mode -1)))))
(defun or-struktur-view--on-capture-after-finalize ()
(when-let* ((buf (buffer-base-buffer)))
(with-current-buffer buf
(if org-note-abort
(revert-buffer nil t)
(when (buffer-modified-p)
(save-buffer)))
(when (derived-mode-p 'or-struktur-view-mode)
(read-only-mode +1)))))
(defun or-struktur-view--on-after-change (beg end len)
(when (> (- end beg) 1)
(or-struktur-view--font-lock-sync beg end (current-buffer))))
(defun or-struktur-view--on-org-cycle (state)
(let ((beg (window-start))
(end (window-end nil t)))
(or-struktur-view--font-lock-sync beg end (current-buffer))))
(defun or-struktur-view--on-post-node-insert (id desc)
(or-struktur-view--modify
(or-struktur-view-tags-refresh))
(move-beginning-of-line 1))
(defun or-struktur-view--on-window-scroll (win beg)
(let ((end (window-end win t)))
(or-struktur-view--font-lock-sync beg end (window-buffer win))))
(defun or-struktur-view--on-window-state-change (win)
(let ((beg (window-start win))
(end (window-end win t)))
(or-struktur-view--font-lock-sync beg end (window-buffer win))))
(defun or-struktur-view--on-window-buffer-change (win)
(let ((beg (window-start win))
(end (window-end win t)))
(or-struktur-view--font-lock-sync beg end (window-buffer win))))
(defun or-struktur-view--on-setup ()
"Set up hooks for `or-struktur-view-mode'."
(add-hook 'after-change-major-mode-hook
#'or-struktur-view--on-after-change-major-mode)
(add-hook 'org-capture-before-finalize-hook
#'or-struktur-view--on-capture-before-finalize)
(add-hook 'org-capture-after-finalize-hook
#'or-struktur-view--on-capture-after-finalize)
(add-hook 'after-change-functions
#'or-struktur-view--on-after-change nil t)
(add-hook 'window-scroll-functions
#'or-struktur-view--on-window-scroll nil t)
(add-hook 'window-state-change-functions
#'or-struktur-view--on-window-state-change nil t)
(add-hook 'org-cycle-hook
#'or-struktur-view--on-org-cycle nil t)
(add-hook 'org-roam-post-node-insert-hook
#'or-struktur-view--on-post-node-insert 99 t)
(add-hook 'post-command-hook
#'or-struktur-view-show-title nil t)
(add-hook 'window-buffer-change-functions
#'or-struktur-view--on-window-buffer-change 99 t))
(add-hook 'or-struktur-view-mode-hook #'or-struktur-view--on-setup)
(defun or-struktur-view--link ()
"Return first Org link element on current line or nil if it does not exist."
(save-excursion
(beginning-of-line)
(let ((end (line-end-position))
lnk)
(while (and (not lnk)
(< (point) end)
(re-search-forward org-link-any-re end t))
(goto-char (match-beginning 0))
(when-let ((el (org-element-context)))
(when (eq (org-element-type el) 'link)
(setq lnk el))))
lnk)))
(defun or-struktur-view--headline-link ()
"Get link on headline at point if exists."
(when-let*
((raw (org-get-heading t t t t))
(parsed (org-element-parse-secondary-string raw '(link))))
(org-element-map parsed 'link #'identity nil 'first-match)))
(defun or-struktur-view--headline-linked-node ()
"Get node linked on headline at point if exists."
(when-let*
((lnk (or-struktur-view--headline-link))
(id (and (equal (org-element-property :type lnk) "id")
(org-element-property :path lnk)))
(node (org-roam-node-from-id id)))
node))
(defun or-struktur-view-tags-refresh ()
"Refresh tags in headline at point in view mode.
Use `or-struktur-view-tags-exclude' to exclude tags from being added."
(interactive)
(when (not (org-at-heading-p))
(warn "Point not on Org headline"))
(when-let*
((lnk (or-struktur-view--headline-link))
(lnk-type (and lnk (org-element-property :type lnk)))
(lnk-path (and lnk (org-element-property :path lnk)))
(id (and (equal lnk-type "id") lnk-path))
(node (org-roam-node-from-id id))
(tags (cl-set-difference (org-roam-node-tags node)
or-struktur-view-tags-exclude
:test #'equal)))
(org-set-tags tags)))
(defun or-struktur-view-tags-refresh-all ()
"Refresh tags in all headlines in view mode.
On each headline, refresh is performed by `or-struktur-view-tags-refresh'."
(interactive)
(save-excursion
(goto-char (point-min))
(org-map-entries #'or-struktur-view-tags-refresh)))
(defun or-struktur-view-preview-async ()
"Async-open node linked in current headline."
(when (org-at-heading-p)
(or-struktur--debounce 0.2
(when-let* ((node (or-struktur-view--headline-linked-node)))
(display-buffer (find-file-noselect (org-roam-node-file node))
'((display-buffer-use-some-window)
(inhibit-same-window . t)))))))
(defun or-struktur-view-preview-toggle ()
"Toggle preview in view."
(interactive)
(when (or-struktur-sz-p)
(if (member #'or-struktur-view-preview-async post-command-hook)
(remove-hook 'post-command-hook #'or-struktur-view-preview-async t)
(add-hook 'post-command-hook #'or-struktur-view-preview-async nil t))))
;; Show node title in minibuffer
(defun or-struktur-view-show-title ()
"Show note title in target specified in `or-struktur-view-show-title'."
(or-struktur--debounce or-struktur-view-show-title-delay
(when-let* ((lnk (or-struktur-view--headline-link))
(contents (org-element-contents lnk))
(desc (org-element-interpret-data contents)))
(when (eq or-struktur-view-show-title 'minibuffer)
(minibuffer-message "Note: %s" desc)))))
(defun or-struktur-view-next-headline ()
(interactive)
(call-interactively #'org-next-visible-heading))
(defun or-struktur-view-previous-headline ()
(interactive)
(call-interactively #'org-previous-visible-heading))
(defun or-struktur-view-next-sibling-headline ()
(interactive)
(call-interactively #'org-forward-heading-same-level))
(defun or-struktur-view-previous-sibling-headline ()
(interactive)
(call-interactively #'org-backward-heading-same-level))
(defun or-struktur-view-parent-headline ()
(interactive)
(call-interactively #'outline-up-heading))
(defun or-struktur-view-open-zettel ()
"Open zettel node at line in another window."
(interactive)
(when-let*
((node (or-struktur-view--headline-linked-node))
(file (org-roam-node-file node))
(buf (find-file-noselect file))
(win (get-mru-window nil t t)))
(display-buffer buf
`((;; display-buffer-in-previous-window
display-buffer-reuse-window
display-buffer-use-some-window)
(inhibit-same-window . t)
(window . ,win)))))
(defun or-struktur-view-visit-zettel (&rest _rest)
"Override `org-return' for faster navigation.
This command changes default behavior to find a link on current header and visit
if such a link exists."
(interactive)
(if-let* ((node (or-struktur-view--headline-linked-node)))
(org-roam-node-visit node)
(apply #'org-return _rest)))
(defun or-struktur-view-imenu ()
"Run `imenu' on buffer."
(interactive)
(imenu))
(defun or-struktur-view-cycle-global-visibility ()
"Run `org-cycle-global'."
(interactive)
(org-cycle-global))
(defun or-struktur-view-cycle-visibility ()
"Run `org-cycle'."
(interactive)
(org-cycle))
(defun or-struktur-view--capture-active-p ()
"Return non-nil if at least one `org-capture' buffer is live (pre-finalize)."
;; TODO(2026-03-09): For accurate identification, try to detect the live
;; capture session is initiated within `or-struktur-view--modify'?
(seq-some (lambda (buf)
(with-current-buffer buf
(and (derived-mode-p 'org-mode)
(bound-and-true-p org-capture-mode))))
(buffer-list)))
(defmacro or-struktur-view--modify (&rest body)
"Temporarily toggle `read-only-mode' while running BODY."
`(let* ((buf (current-buffer))
(base-buf (buffer-base-buffer))
(inhibit-read-only t))
(read-only-mode -1)
(atomic-change-group
,@body)
;; Do not save buffer if a capture is in session, in which case
;; save-or-revert is handled in the capture's after-finalize hook.
(unless (or-struktur-view--capture-active-p)
(with-current-buffer base-buf
(when (buffer-modified-p)
(save-buffer))))
(with-current-buffer buf
(read-only-mode +1))))
(defmacro or-struktur-view--refresh-subtree (&rest body)
`(let ((buf (current-buffer))
reg-beg reg-end)
(with-current-buffer buf
(save-excursion
(org-mark-subtree 1)
(setq reg-beg (region-beginning) reg-end (region-end))
(deactivate-mark))
(or-struktur--ov-refresh reg-beg reg-end))
,@body
(with-current-buffer buf
(or-struktur--ov-refresh reg-beg reg-end))))
(defun or-struktur-view-insert-child ()
"Insert child of current headline."