-
Notifications
You must be signed in to change notification settings - Fork 15
Expand file tree
/
Copy pathsupertag-ui-commands.el
More file actions
1277 lines (1150 loc) · 56.9 KB
/
Copy pathsupertag-ui-commands.el
File metadata and controls
1277 lines (1150 loc) · 56.9 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
;;; supertag/ui/commands.el --- User command interface for Supertag -*- lexical-binding: t; -*-
;;; Commentary:
;; This file provides the user-facing interactive commands for Supertag.
;; These commands act as the entry points for user interaction, calling the
;; underlying operations and services.
;;; Code:
(require 'cl-lib)
(require 'org)
(require 'org-id)
(require 'supertag-services-ui) ; For UI helper services
(require 'supertag-ops-node) ; For node operations
(require 'supertag-ops-tag) ; For tag operations (e.g., tag completion)
(require 'supertag-ops-field) ; For field operations
(require 'supertag-ops-relation) ; For relation operations
(require 'supertag-services-query) ; For query operations
(require 'supertag-view-kanban) ; For Kanban board view
(require 'supertag-services-sync) ; For sync services
(require 'supertag-services-capture) ; For capture services
(require 'supertag-service-org)
(require 'supertag-core-store)
;; Forward declarations for view-node
(declare-function supertag-view-node--buffer "supertag-view-node" ())
(declare-function supertag-view-node--show-side "supertag-view-node" (&optional node-id))
(declare-function supertag-view-node--focus-view "supertag-view-node" ())
(declare-function supertag-view-node--goto-field "supertag-view-node" (&optional tag-id field-name))
(declare-function supertag-view-node-edit-at-point "supertag-view-node" ())
(declare-function supertag-view-build-node-state "supertag-services-ui" (node-id))
(defvar supertag-view-node--current-node-id) ; For supertag--rebuild-all-indexes
;;; --- Customization ---
(defcustom supertag-batch-tag-insert-position 'end
"Where to insert tags when adding tags in batch mode.
- 'end: Insert tags at the end of the heading (default)
- 'beginning: Insert tags at the beginning of the heading (after the stars and TODO keyword if any)"
:type '(choice (const :tag "End of heading" end)
(const :tag "Beginning of heading" beginning))
:group 'supertag)
(defcustom supertag-capture-tag-position 'end
"Where to place tags when creating a headline via capture.
- 'end: Keep tags after the title (default, preserves current behavior).
- 'beginning: Insert tags immediately after the leading stars/TODO keyword."
:type '(choice (const :tag "End of headline" end)
(const :tag "Beginning of headline" beginning))
:group 'supertag)
(defun supertag-set-tag-parent (parent-tag child-tags)
"Set one or more CHILD-TAGS to extend a PARENT-TAG.
This command modifies the `:extends` property of the child tags.
When invoked interactively, allows selecting multiple child tags."
(interactive
(let* ((tags (supertag-view-api-list-tag-ids)))
(when (null tags)
(user-error "No tags available"))
(let* ((parent (supertag-ui-read-tag "Parent tag: " tags nil nil))
(child-candidates (remove parent (copy-sequence tags)))
(children (supertag-ui-read-tags
(format "Child tags for '%s': " parent)
child-candidates nil)))
(when (null children)
(user-error "You must select at least one child tag"))
(list parent children))))
(let ((children (if (listp child-tags) child-tags (list child-tags))))
(dolist (child children)
(when (and child (not (string-empty-p child)))
(supertag--set-tag-parent child parent-tag)))
(when (called-interactively-p 'interactive)
(message "Tags %s now extend %s"
(mapconcat #'identity children ", ") parent-tag))
children))
(defun supertag-clear-parent (child-tags)
"Clear parent relationships for one or more CHILD-TAGS."
(interactive
(let* ((tags (supertag-view-api-list-tag-ids)))
(when (null tags)
(user-error "No tags available"))
(let ((children (supertag-ui-read-tags
"Clear parent for tag(s): " tags nil)))
(when (null children)
(user-error "You must select at least one tag"))
(list children))))
(let ((children (if (listp child-tags) child-tags (list child-tags))))
(dolist (child children)
(when (and child (not (string-empty-p child)))
(supertag--clear-parent child)))
(when (called-interactively-p 'interactive)
(message "Cleared parent for tag(s): %s"
(mapconcat #'identity children ", ")))
children))
(defun supertag-ui--get-nodes-in-region (beg end)
"Extract all node IDs from Org headings within the region BEG to END.
Returns a list of node IDs. Creates IDs for headings that don't have one.
Only includes headings whose starting position is within [BEG, END)."
(let ((node-ids '()))
(save-excursion
(goto-char beg)
;; Move to the beginning of the first heading in or after BEG
(unless (org-at-heading-p)
(org-next-visible-heading 1))
;; Collect all headings that start within the region
(while (and (not (eobp))
(org-at-heading-p)
(< (point) end)) ; Heading must start before END
(let ((heading-start (point))
(node-id (org-id-get-create)))
;; Only include if heading starts within region
(when (>= heading-start beg)
(push node-id node-ids)))
(org-next-visible-heading 1)))
(nreverse node-ids)))
(defun supertag--get-node-props-at-point ()
"Extract node properties from the current Org heading at point."
(when (org-at-heading-p)
(when (fboundp 'org-element-at-point)
(let ((element (org-element-at-point))
(file (buffer-file-name)))
;; Delegate parsing to the authoritative function in the sync service.
(supertag--convert-element-to-node-plist element file)))))
;;; --- User Commands ---
(defun supertag-ui--get-node-at-point ()
"Check if point is at a heading and return the node ID.
Creates an ID if one does not exist. Errors out if not on a heading."
(unless (org-at-heading-p)
(user-error "Point must be at an Org heading."))
(org-id-get-create))
(defun supertag-ui--get-containing-node-at-point ()
"Get the node ID of the containing node, whether at heading or in content.
Works when point is at a heading, within the content of a node, or at the
file-level before any heading."
(save-excursion
(cond
((org-at-heading-p)
(org-id-get-create))
((org-before-first-heading-p)
(supertag-ui--get-file-node-at-point))
((org-back-to-heading t)
(org-id-get-create))
(t
(supertag-ui--get-file-node-at-point)))))
(defun supertag-ui--ensure-node-synced (node-id)
"Ensure NODE-ID exists in the store by syncing the heading if necessary."
(when node-id
(unless (supertag-node-get node-id)
(when-let ((marker (supertag-ui--find-node-marker node-id)))
(org-with-point-at marker
(when (org-at-heading-p)
(supertag-node-sync-at-point)))))))
(defun supertag-view-kanban ()
"Create an interactive Kanban board view based on a tag's field."
(interactive)
(let* ((available-tags (supertag-view-kanban--get-all-tags))
(tag-name (supertag-ui-read-tag
"Select a tag to build Kanban from: "
available-tags nil nil))
(tag-id (when tag-name (supertag-tag-get-id-by-name tag-name))))
;;
(if (not tag-id)
(message "No valid tag selected.")
(let* ((tag-data (supertag-tag-get tag-id))
(tag-name-from-data (plist-get tag-data :name)) ; Get tag name from tag data
(fields (supertag-tag-get-all-fields tag-id))
(field-names
(let ((seen (make-hash-table :test 'equal))
(names '()))
(dolist (f fields (nreverse names))
(let* ((fid (or (plist-get f :id) (plist-get f :name)))
(slug (and fid (supertag-sanitize-field-id fid)))
(dedupe slug))
(when (and dedupe (not (gethash dedupe seen)))
(puthash dedupe t seen)
(push (plist-get f :name) names)))))))
(if (not field-names)
(message "Tag '%s' has no fields to group by." tag-name-from-data)
(let* ((field-name (completing-read "Group columns by which field: " field-names nil t))
(config (supertag-view-kanban-create-config tag-id field-name)))
(when field-name
(supertag-view-kanban-open config tag-name-from-data)
(message "Kanban board created for tag '%s' grouped by '%s'"
tag-name-from-data field-name))))))))
;;; --- Node Commands: Create, move, find, delete
(defun supertag-create-node ()
"Interactive command to create a new node.
If at an Org heading, it will create a node from that heading.
Otherwise, it will prompt for a title and create a new heading."
(interactive)
(let* ((props nil)
(node-id nil))
(if (org-at-heading-p)
;; Create node from existing heading
(progn
(setq props (supertag--get-node-props-at-point))
(unless (plist-get props :id)
(org-id-get-create) ; Ensure ID exists for the heading
(setq props (supertag--get-node-props-at-point)))
(setq node-id (plist-get props :id))
(supertag-node-create props)
(message "Node created from current heading: %s" (plist-get props :title)))
;; Create new heading and node
(let* ((title (read-string "Node title: "))
(level (if (org-at-heading-p) (org-outline-level) 1)))
(save-excursion
(beginning-of-line)
(insert (make-string level ?*) " " title "\n")
(forward-line -1) ; Move back to the new heading
(org-id-get-create) ; Create ID for the new heading
(setq props (supertag--get-node-props-at-point))
(setq node-id (plist-get props :id))
(supertag-node-create props)
(message "New node '%s' created." title))))
node-id))
(defun supertag-find-node ()
"Find a node by its title/path and jump to it in the current window."
(interactive)
(let ((node-id (supertag-ui-select-node "Find node: " t))) ; Use cache for better performance
(when node-id
(supertag-goto-node node-id))))
(defun supertag-find-node-other-window ()
"Find a node by its title/path, with live preview in another window.
Jumps to the selected node in another window."
(interactive)
(let ((node-id (supertag-ui-select-node "Find node (other window): " t t))) ; Use cache & preview
(when node-id
(supertag-goto-node node-id t))))
(defun supertag-delete-node ()
"Delete the node at point, removing it from the database and the Org file."
(interactive)
(unless (org-at-heading-p)
(user-error "Point must be at a heading to delete a node."))
(let ((node-id (org-id-get)))
(unless node-id
(user-error "Current heading does not have an ID, it is not a node."))
(when (yes-or-no-p (format "Really delete node %s and its headline? " node-id))
;; 1. Call the core operation to delete from the database
(supertag-node-delete node-id)
;; 2. Delete the headline from the Org buffer
(org-back-to-heading t)
(when (fboundp 'org-element-at-point)
(let* ((element (org-element-at-point))
(begin (org-element-property :begin element))
(end (org-element-property :end element)))
(delete-region begin end)
;; Also delete the newline after the heading if it exists
(when (looking-at "\n")
(delete-char 1))))
;; 3. Save the buffer to persist the deletion from the file
(save-buffer)
(message "Node %s deleted." node-id))))
(defun supertag-update-node-at-point ()
"Manually re-synchronize the node at the current headline with the database."
(interactive)
(unless (org-at-heading-p)
(user-error "Point must be at a heading to update a node."))
;; Ensure ID exists before syncing.
(org-id-get-create)
(if (supertag-node-sync-at-point)
(message "Node at point re-synced successfully.")
(user-error "Failed to re-sync node at point.")))
(defun supertag-back-to-heading ()
"Remove the node at point from the supertag system.
This removes the node and all its relations from the database,
but leaves the Org heading and its content intact in the file,
effectively converting it back to a regular heading."
(interactive)
(let ((node-id (org-id-get)))
(unless node-id
(user-error "Current heading does not have an ID, it is not a node."))
(when (yes-or-no-p "Really remove this node from the database? (The heading will be preserved)")
(supertag-node-delete node-id)
(org-entry-delete (point) "ID")
(message "Node %s removed from database. It is now a regular Org heading." node-id))))
(defun supertag-move-node (&optional beg end)
"Interactively move node(s) to another file.
If region is active (BEG and END provided), move all nodes in the region.
Otherwise, move the node at point.
The node's content (the entire subtree) will be cut from the
current file and inserted into the target file at a chosen position."
(interactive
(when (use-region-p)
(list (region-beginning) (region-end))))
(let ((node-ids (if (and beg end)
;; Batch mode: get all nodes in region
(supertag-ui--get-nodes-in-region beg end)
;; Single mode: get node at point
(unless (org-at-heading-p)
(user-error "Point must be at a heading to move a node."))
(let ((node-id (org-id-get)))
(unless node-id
(user-error "Current heading does not have an ID, it is not a node."))
(list node-id)))))
(unless node-ids
(user-error "No nodes found to move."))
;; 1. Prompt for target file and position
(let* ((target-file (read-file-name "Move node(s) to file: "))
(insert-info (supertag-ui-select-insert-position target-file))
(target-pos (plist-get insert-info :position))
(target-level (plist-get insert-info :level)))
(unless (and target-file (file-exists-p target-file))
(user-error "Target file does not exist: %s" target-file))
(unless insert-info
(user-error "No valid insert position selected."))
(when (yes-or-no-p (format "Really move %d node(s) to %s? "
(length node-ids)
(file-name-nondirectory target-file)))
(require 'supertag-ops-batch)
(supertag-with-transaction
(let ((current-target-pos target-pos)
(nodes-to-move '()))
;; 2. First pass: collect all node data before any modifications
;; Read directly from current buffer to avoid database dependency
(dolist (node-id node-ids)
(let ((marker (supertag-ui--find-node-marker node-id)))
(when marker
(with-current-buffer (marker-buffer marker)
(save-restriction
(widen)
(save-excursion
(goto-char (marker-position marker))
(org-back-to-heading t)
(when (org-at-heading-p)
(when (fboundp 'org-element-at-point)
(let* ((element (org-element-at-point))
(begin (org-element-property :begin element))
(end (org-element-property :end element))
(original-level (org-element-property :level element))
(content (buffer-substring-no-properties begin end))
(node-file (buffer-file-name)))
(push (list :id node-id
:file node-file
:begin begin
:end end
:level original-level
:content content)
nodes-to-move)))))))))
(setq nodes-to-move (nreverse nodes-to-move))
;; 3. Second pass: group nodes by file and delete from each file
;; (in reverse position order to preserve positions)
(let ((nodes-by-file (make-hash-table :test 'equal)))
;; Group nodes by file
(dolist (node-info nodes-to-move)
(let ((file (plist-get node-info :file)))
(push node-info (gethash file nodes-by-file))))
;; Delete from each file (nodes in reverse position order)
(maphash
(lambda (file nodes-in-file)
(let ((sorted-nodes (sort nodes-in-file
(lambda (a b)
(> (plist-get a :begin)
(plist-get b :begin))))))
(with-current-buffer (find-file-noselect file)
(save-restriction
(widen)
(dolist (node-info sorted-nodes)
(let ((begin (plist-get node-info :begin))
(end (plist-get node-info :end)))
(delete-region begin end))))
(save-buffer))))
nodes-by-file))
;; 4. Third pass: insert into target file and update database
(dolist (node-info nodes-to-move)
(let* ((node-id (plist-get node-info :id))
(content (plist-get node-info :content))
(original-level (plist-get node-info :level))
(adjusted-content (supertag-ui--adjust-content-level content original-level target-level))
(node-start-pos nil))
(with-current-buffer (find-file-noselect target-file)
(save-restriction
(widen)
(goto-char current-target-pos)
(unless (or (bobp) (looking-back "\n" 1)) (insert "\n"))
;; Record the position where the node starts
(setq node-start-pos (point))
(insert adjusted-content)
;; Update position for next node (after current insertion)
(setq current-target-pos (point)))
(save-buffer))
;; Update the database with the new location (use node start position)
(supertag-node-set-location node-id target-file node-start-pos)))
(message "%d node(s) successfully moved to %s."
(length nodes-to-move)
(file-name-nondirectory target-file)))))))))
(defun supertag-move-node-and-link ()
"Move the node at point to another file, leaving a link behind."
(interactive)
(unless (org-at-heading-p)
(user-error "Point must be at a heading to move a node."))
(let ((node-id (org-id-get)))
(unless node-id
(user-error "Current heading does not have an ID, it is not a node."))
;; 1. Get target file and position (reusing our UI service)
(let* ((target-file (read-file-name "Move node to file: "))
(insert-info (supertag-ui-select-insert-position target-file))
(target-pos (plist-get insert-info :position))
(target-level (plist-get insert-info :level)))
(unless (and target-file (file-exists-p target-file))
(user-error "Target file does not exist: %s" target-file))
(unless insert-info
(user-error "No valid insert position selected."))
(when (yes-or-no-p (format "Really move node %s and leave a link? " node-id))
;; 2. Get node content and original properties
(when (fboundp 'org-element-at-point)
(let* ((element (org-element-at-point))
(begin (org-element-property :begin element))
(end (org-element-property :end element))
(original-level (org-element-property :level element))
(title (org-element-property :raw-value element))
(content (buffer-substring-no-properties begin end)))
;; 3. Insert into target file (same as move-node)
(let ((adjusted-content (supertag-ui--adjust-content-level content original-level target-level)))
(with-current-buffer (find-file-noselect target-file)
(goto-char target-pos)
(unless (or (bobp) (looking-back "\n" 1)) (insert "\n"))
(insert adjusted-content)
(save-buffer))
(message "Pasted node into %s." (file-name-nondirectory target-file)))
;; 4. Update the database with the new location (same as move-node)
(supertag-node-set-location node-id target-file target-pos)
;; 5. KEY DIFFERENCE: Replace original content with a link
(delete-region begin end)
(insert (make-string original-level ?*) " "
(format "[[id:%s][%s]]\n" node-id title))
(save-buffer)
(message "Node %s moved and link created." node-id)))))))
;; --- Node Commands: Add, Remove Reference
(defun supertag-ui--document-link-bounds (node-id)
"Return the direct Org content bounds owned by NODE-ID."
(save-excursion
(org-with-wide-buffer
(if (supertag-ui--file-node-p node-id)
(progn
(goto-char (point-min))
(cons (point-min)
(if (re-search-forward "^\\*+\\s-" nil t)
(match-beginning 0)
(point-max))))
(org-back-to-heading t)
(org-end-of-meta-data t)
(let ((start (point)))
(cons start
(if (re-search-forward org-outline-regexp-bol nil t)
(match-beginning 0)
(point-max))))))))
(defun supertag-ui--reproject-containing-node (node-id)
"Refresh NODE-ID's Document Projection from the current Org buffer."
(if (supertag-ui--file-node-p node-id)
(supertag-ui--ensure-file-node-synced (buffer-file-name))
(save-excursion
(org-back-to-heading t)
(supertag-node-sync-at-point))))
(defun supertag-add-reference ()
"Add one source-owned Org link from the current node to a selected node.
The target Backlink is derived from the relation index, never written to Org.
Works for both heading nodes and file nodes (level 0)."
(interactive)
(let* ((from-id (supertag-ui--get-containing-node-at-point))
(to-id nil))
(unless from-id
(user-error "Point must be inside an Org heading or its content."))
(supertag-ui--ensure-node-synced from-id)
(setq to-id (supertag-ui-select-node "Add reference to: " t))
(when to-id
(let* ((to-node (supertag-node-get to-id))
(to-title (or (plist-get to-node :title) to-id))
(bounds (supertag-ui--document-link-bounds from-id))
(link-pattern (supertag-node-link-pattern to-id))
(link-exists (save-excursion
(goto-char (car bounds))
(re-search-forward link-pattern (cdr bounds) t))))
(unless link-exists
(unless (<= (car bounds) (point) (cdr bounds))
(goto-char (car bounds)))
(insert (supertag-node-format-link to-id to-title))
(save-buffer))
(supertag-ui--reproject-containing-node from-id)
(message "Reference added.")))))
(defun supertag-add-reference-and-create (beg end)
"Create a new node from the selected region and replace it with a link.
Interactively asks for a target location to save the new node."
(interactive "r")
(let* ((title (buffer-substring-no-properties beg end))
;; Get from-id BEFORE creating the new node
(from-id (supertag-ui--get-containing-node-at-point)))
(if (or (null title) (string-empty-p title))
(user-error "Region is empty. Cannot create a node.")
;; 1. Get target location from user
(let* ((target-file (read-file-name "Create node in file: " nil nil t))
(insert-info (when (and target-file (file-exists-p target-file))
(supertag-ui-select-insert-position target-file)))
(insert-pos (plist-get insert-info :position))
(insert-level (plist-get insert-info :level)))
(unless insert-info
(user-error "No valid insert position selected. Aborting."))
(let ((new-node-id (org-id-new)))
;; 2. Create the node in the target file (physical insertion)
(with-current-buffer (find-file-noselect target-file)
(goto-char insert-pos)
;; Ensure we are on a new line before inserting
(unless (or (bobp) (looking-back "\n" 1)) (insert "\n"))
(insert (format "%s %s\n:PROPERTIES:\n:ID: %s\n:END:\n"
(make-string insert-level ?*)
title
new-node-id))
(save-buffer))
;; 3. Create the node in the database (logical creation)
(supertag-node-create
`(:id ,new-node-id
:title ,title
:file ,target-file
:position ,insert-pos
:level ,insert-level))
;; 4. Replace original text with the single forward Document Link.
(delete-region beg end)
(insert (format "[[id:%s][%s]]" new-node-id title))
(save-buffer)
(when from-id
(supertag-ui--reproject-containing-node from-id))
(message "Node '%s' created and linked." title))))))
(defun supertag-remove-reference ()
"Remove a source-owned Document Link from the current node."
(interactive)
(let ((from-id (supertag-ui--get-containing-node-at-point)))
(unless from-id
(user-error "Point must be inside an Org heading or file node."))
(supertag-ui--ensure-node-synced from-id)
(let ((to-id (supertag-ui-select-reference-to-remove from-id)))
(when to-id
;; Remove only the source-owned physical link, then rebuild its projection.
(let ((bounds (supertag-ui--document-link-bounds from-id)))
(save-excursion
(goto-char (car bounds))
(when (re-search-forward (supertag-node-link-pattern to-id)
(cdr bounds) t)
(goto-char (match-beginning 0))
(when-let* ((link (org-element-context)))
(when (and (eq (org-element-type link) 'link)
(string= (org-element-property :path link) to-id))
(delete-region (org-element-property :begin link)
(org-element-property :end link)))))))
(save-buffer)
(supertag-ui--reproject-containing-node from-id)
(message "Reference to node %s removed." to-id)))))
;; --- Embed Commands ---
(defun supertag-insert-embed ()
"Insert an embed block at point by selecting a node.
This command provides a convenient way to embed node content directly
without first creating a link. It will prompt you to select a node
and then insert the embed block at the current position."
(interactive)
(require 'supertag-ui-embed)
(supertag-ui-embed--insert-block))
(defun supertag-convert-link-to-embed ()
"Convert the org id: link at point to an embed block.
This command provides a user-friendly interface to convert an existing
id: link into an embed block that displays the node's content inline."
(interactive)
(require 'supertag-ui-embed)
(supertag-ui-embed--link-to-block))
;; --- Tag Commands: add, remove ----
(defun supertag-add-tag (&optional beg end)
"Interactively add a tag to node(s).
If region is active (BEG and END provided), add tag to all nodes in the region.
Otherwise, add tag to the node at point.
This command handles tag creation, linking, and smart insertion
of the inline #tag text into the buffer. Can be used both at headings
and within node content area.
If you prefix your input with '=' (e.g. '=ref'), it will be treated as a literal
new tag name, bypassing fuzzy completion matching."
(interactive
(when (use-region-p)
(list (region-beginning) (region-end))))
(let* ((batch-mode (and beg end))
(current-marker (copy-marker (point)))
(node-ids (if batch-mode
;; Batch mode: get all nodes in region
(supertag-ui--get-nodes-in-region beg end)
;; Single mode: get node at point
(let ((node-id (supertag-ui--get-containing-node-at-point)))
(unless node-id
(user-error "Point is not inside a Supertag node"))
;; Ensure the node exists in the database before proceeding.
(unless (supertag-node-get node-id)
(supertag-node-sync-at-point))
(list node-id))))
(all-tags (supertag-view-api-list-tag-ids))
(raw-name (or (supertag-ui-read-tag
(format "Add tag to %d node(s) (use =tagname for exact match): "
(length node-ids))
all-tags t t)
""))
(literal-tag (and (> (length raw-name) 0) (eq (aref raw-name 0) ?=))))
(unless node-ids
(user-error "No nodes found to add tag to."))
(when (and raw-name (not (string-empty-p raw-name)))
(let* ((tag-name (if literal-tag
(substring raw-name 1) ; Remove the '=' prefix
raw-name))
(token (supertag-sanitize-tag-name tag-name))
(tag-id (or (and (supertag-tag-get token) token)
(supertag-tag-resolve-occurrence token))))
(when (or tag-id
(yes-or-no-p
(if literal-tag
(format "Create new tag '%s' and add to %d node(s)? "
token (length node-ids))
(format "Tag '%s' does not exist. Create and add it to %d node(s)? "
token (length node-ids)))))
(unless tag-id
(setq tag-id (plist-get (supertag-tag-create `(:name ,token)) :id)))
(dolist (node-id node-ids)
(unless (supertag-node-get node-id)
(when-let* ((marker (supertag-ui--find-node-marker node-id)))
(with-current-buffer (marker-buffer marker)
(goto-char marker)
(supertag-node-sync-at-point))))
(supertag-service-org-add-tag
node-id tag-id
(if batch-mode
supertag-batch-tag-insert-position
current-marker)))
(message "Tag '%s' added to %d node(s)." tag-id (length node-ids)))))))
(defun supertag-remove-tag-from-node ()
"Interactively remove a tag from the current node.
Can be used both at headings and within node content areas."
(interactive)
(let* ((node-id (supertag-ui--get-containing-node-at-point))
(tag-id (supertag-ui-select-tag-on-node node-id)))
(when tag-id
(supertag-service-org-remove-tag node-id tag-id)
(message "Tag '%s' removed from node %s." tag-id node-id))))
;;; --- Enhanced Tag Management Commands ---
(defun supertag-rename-tag (&optional old-id)
"Interactively rename OLD-ID's canonical Semantic Tag name.
When OLD-ID is nil, prompt for the tag to rename."
(interactive)
(let* ((old-id (or old-id
(supertag-ui-read-tag
"Tag to rename: "
(supertag-view-api-list-tag-ids) nil nil)))
(new-id (when (and old-id (not (string-empty-p old-id)))
(read-string (format "New name for '%s': " old-id)))))
(when (and old-id (not (string-empty-p old-id))
new-id (not (string-empty-p new-id)))
(when (yes-or-no-p
(format "Rename Semantic Tag '%s' to '%s'? Org tokens stay unchanged. "
old-id new-id))
;; Call the single, authoritative backend function
(supertag-tag-rename old-id new-id)))))
(defun supertag-delete-tag-everywhere (&optional tag-name)
"Interactively delete TAG-NAME and all its instances.
When TAG-NAME is nil, prompt for the tag to delete.
WARNING: This removes the tag from the database and from all org files."
(interactive)
(let ((tag-name (or tag-name
(supertag-ui-read-tag
"Delete tag permanently: "
(supertag-view-api-list-tag-ids) nil nil))))
(when (and (not (string-empty-p tag-name))
(yes-or-no-p (format "DELETE tag '%s' and ALL its uses? This is irreversible." tag-name)))
;; Call the centralized ops function to perform the deletion.
(supertag-ops-delete-tag-everywhere tag-name))))
(defun supertag-ui-select-tag-on-node (node-id)
"Interactively select a tag from the ones associated with NODE-ID.
Returns the selected tag ID (a string), or nil if canceled."
(let* ((node (supertag-node-get node-id))
(tags (and node (plist-get node :tags))))
(unless tags
(user-error "Node has no tags to select from."))
(supertag-ui-read-tag "Select tag: " tags nil nil)))
(defun supertag-change-tag-at-point ()
"Interactively change a tag on the current node to a different tag.
This command reads the authoritative list of tags from the database."
(interactive)
(require 'supertag-view-helper)
(let* ((node-id (supertag-ui--get-containing-node-at-point))
(current-tag (supertag-ui-select-tag-on-node node-id)))
(unless current-tag
(user-error "No tag selected."))
(let* ((all-tags (supertag-view-api-list-tag-ids))
(new-tag-raw
(or (supertag-ui-read-tag
(format "Change tag '%s' to: " current-tag)
all-tags t t)
""))
(new-token (supertag-sanitize-tag-name new-tag-raw))
(new-tag (or (and (supertag-tag-get new-token) new-token)
(supertag-tag-resolve-occurrence new-token))))
(when (and new-token (not (string-empty-p new-token)))
;; 1. Create new tag if it doesn't exist
(unless new-tag
(when (yes-or-no-p (format "Tag '%s' does not exist. Create it? " new-token))
(setq new-tag
(plist-get (supertag-tag-create `(:name ,new-token)) :id))))
(when new-tag
(supertag-service-org-replace-tag node-id current-tag new-tag)
(message "Tag changed from '%s' to '%s'." current-tag new-tag))))))
;;; --- Tag Inheritance Model ---
;; `supertag' implements a schematic inheritance model for tags, which is
;; distinct from Org-mode's default structural inheritance.
;;
;; - Inheritance is defined via the `:extends` property in a tag's definition,
;; creating a parent-child relationship between tag schemas.
;; - A tag inherits the *fields* from its parent tag(s).
;; - This model is based on the tag definitions stored in the database, not on
;; the headline structure of an Org file.
;; - The commands `supertag-set-child` and `supertag-clear-parent` are used
;; to manage these `:extends` relationships.
;;; --- Capture Commands ---
(defvar supertag-capture--last-node-id nil
"Stores the ID of the last node created during capture for enrichment.")
(defun supertag-capture (&optional target-file headline)
"Independent capture command for Supertag.
Creates a new node with optional tags and field values.
TARGET-FILE is optional file path to capture to.
HEADLINE is optional headline text."
(interactive)
;; Phase 1: Get capture details
(let* ((capture-info (supertag-capture-interactive-headline))
(full-title (plist-get capture-info :headline))
(selected-tags (plist-get capture-info :tags))
(target-file (or target-file (read-file-name "Capture to file: ")))
;; Optional body content below the headline
(body (read-string "Body (optional, RET to skip): "))
(insert-info (supertag-ui-select-insert-position target-file))
(insert-pos (plist-get insert-info :position))
(insert-level (plist-get insert-info :level)))
(unless insert-info
(user-error "No valid insert position selected"))
;; Phase 2: Create the node in the file
(let ((new-node-id (org-id-new)))
(supertag-capture--insert-node-into-buffer
(find-file-noselect target-file)
insert-pos insert-level full-title selected-tags body new-node-id
supertag-capture-tag-position)
;; Phase 3: Sync and enrich
(let ((node-id new-node-id))
(when node-id
(supertag-node-create (list :id node-id
:title full-title
:tags selected-tags
:file target-file))
(message "Node %s created in %s" node-id (file-name-nondirectory target-file))
;; Phase 4: Auto field enrichment for tags with fields
(when selected-tags
(let ((fields (supertag-capture--get-fields-for-tags selected-tags)))
(when fields
(let* ((field-values (supertag-capture--prompt-for-field-values fields))
(batch-entries
(cl-loop for fv in field-values append
(cl-loop for tag-id in selected-tags
collect (list :tag tag-id
:field (car fv)
:value (cdr fv))))))
(when batch-entries
(supertag-field-set-many node-id batch-entries)))))))
;; Phase 5: Optional manual field enrichment
(when (y-or-n-p "Add additional properties to this node? ")
(supertag-capture-enrich-node node-id))
node-id))))
;;; --- Sync Commands ---
;;;###autoload
(defun supertag-sync-full-initialize ()
"Perform full initialization sync for new users.
This command will clear all sync state and reimport all files from
configured directories into the database. Intended for first-time
setup or when rebuilding the entire database."
(interactive)
(when (yes-or-no-p "This will clear all sync state and reimport all files. Continue? ")
(message "Starting full initialization sync...")
;; Step 1: Clear sync state
(message "Step 1: Clearing sync state...")
(setq supertag-sync--state (make-hash-table :test 'equal))
(supertag-sync-save-state)
;; Step 2: Get all files in sync directories
(message "Step 2: Scanning all files in sync directories...")
(let ((all-files (supertag-scan-sync-directories t)) ; Force scan all files
(counters '(:nodes-created 0 :nodes-updated 0 :nodes-deleted 0
:references-created 0 :references-deleted 0))
(total-files 0)
(processed-files 0))
(setq total-files (length all-files))
(message "Found %d files to process" total-files)
(if (= total-files 0)
(message "No files found in sync directories: %s" supertag-sync-directories)
(progn
;; Step 3: Process each file within transaction
(message "Step 3: Processing files...")
(supertag-with-transaction
(dolist (file all-files)
(setq processed-files (1+ processed-files))
(message "Processing file %d/%d: %s" processed-files total-files
(file-name-nondirectory file))
(condition-case err
(progn
;; Force process the file (ignore existing state)
(supertag-sync--process-single-file file counters)
;; Update sync state for the file
(supertag-sync-update-state file))
(error
(message "ERROR processing file %s: %s" file err)))))
;; Step 4: Save state and report results
(supertag-sync-save-state)
;; ponytail: stamp store origin so the auto-save timer can save the
;; freshly built store. Without this, full-initialize leaves origin
;; nil and supertag-save-store keeps refusing.
(when (fboundp 'supertag--record-store-origin)
(supertag--record-store-origin :ok
(list :loaded-from supertag-db-file
:seeded-by 'supertag-sync-full-initialize)))
(supertag-mark-dirty)
(supertag-save-store)
(let ((nodes-created (plist-get counters :nodes-created))
(nodes-updated (plist-get counters :nodes-updated))
(refs-created (or (plist-get counters :references-created) 0)))
(message "Full initialization completed!")
(message "Results: %d files processed, %d nodes created, %d nodes updated, %d references created"
processed-files nodes-created nodes-updated refs-created)
;; Show summary
(when (> nodes-created 0)
(message "Database successfully initialized with %d nodes from %d files."
nodes-created processed-files))
(when (= nodes-created 0)
(message "WARNING: No nodes were created. Please check:")
(message " - supertag-sync-directories: %s" supertag-sync-directories)
(message " - supertag-sync-file-pattern: %s" supertag-sync-file-pattern)
(message " - File contents have proper org headings with IDs"))))))))
;;;###autoload
(defun supertag-sync-force-resync-file (&optional file)
"Force resync a specific file, ignoring existing sync state.
If FILE is not provided, prompt user to select a file."
(interactive)
(let ((target-file (or file
(read-file-name "Force resync file: " nil nil t))))
(unless (file-exists-p target-file)
(user-error "File does not exist: %s" target-file))
(unless (supertag-sync--in-sync-scope-p target-file)
(user-error "File is not in sync scope: %s" target-file))
(when (yes-or-no-p (format "Force resync file %s? " (file-name-nondirectory target-file)))
(message "Force resyncing file: %s" target-file)
;; Remove from sync state to force processing
(let ((state-table (supertag-sync--get-state-table)))
(remhash target-file state-table))
;; Process the file
(let ((counters '(:nodes-created 0 :nodes-updated 0 :nodes-deleted 0
:references-created 0 :references-deleted 0)))
(supertag-with-transaction
(supertag-sync--process-single-file target-file counters))
;; Update state and report
(supertag-sync-update-state target-file)
(supertag-sync-save-state)
(message "Force resync completed: %d created, %d updated, %d deleted"
(plist-get counters :nodes-created)
(plist-get counters :nodes-updated)
(plist-get counters :nodes-deleted))))))
;;;###autoload
(defun supertag-sync-force-resync-current-file ()
"Force resync the current file."
(interactive)
(unless (buffer-file-name)
(user-error "Current buffer is not visiting a file"))
(supertag-sync-force-resync-file (buffer-file-name)))
;;;###autoload
(defun supertag-sync-reset-state ()
"Reset all sync state without touching the database.
This forces all files to be considered 'modified' on next sync."
(interactive)
(when (yes-or-no-p "This will reset all sync state. Continue? ")
(setq supertag-sync--state (make-hash-table :test 'equal))
(supertag-sync-save-state)
(message "Sync state reset. All files will be reprocessed on next sync.")))
;;;###autoload
(defun supertag-start-auto-sync (&optional interval)
"Start automatic synchronization.
If INTERVAL is provided, use it as the sync interval in seconds."
(interactive "P")
(let ((sync-interval (if interval
(prefix-numeric-value interval)
supertag-sync-auto-interval)))
(supertag-sync-start-auto-sync sync-interval)
(message "Auto-sync started with %d second interval." sync-interval)))
;;;###autoload
(defun supertag-stop-auto-sync ()
"Stop automatic synchronization."
(interactive)
(supertag-sync-stop-auto-sync)
(message "Auto-sync stopped."))
;;;###autoload
(defun supertag-sync-check-now ()
"Immediately check and sync modified files."
(interactive)
(message "Starting manual sync check...")
(supertag-sync--check-and-sync)
(message "Manual sync check completed."))
;;;###autoload
(defun supertag-sync-status ()
"Show current sync status and configuration."
(interactive)
(let* ((state-table (supertag-sync--get-state-table))