-
Notifications
You must be signed in to change notification settings - Fork 15
Expand file tree
/
Copy pathsupertag-services-sync.el
More file actions
2548 lines (2301 loc) · 111 KB
/
Copy pathsupertag-services-sync.el
File metadata and controls
2548 lines (2301 loc) · 111 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/services/sync.el --- Synchronization mechanism for Supertag -*- lexical-binding: t; -*-
;;; Commentary:
;; This file implements the synchronization mechanism for the Supertag
;; data-centric architecture. It handles importing data from Org files into the
;; central store and exporting data from the store back to Org files.
;;; Code:
(require 'cl-lib)
(require 'subr-x)
(require 'ht)
(require 'org-element) ; For parsing Org files
(require 'org-id) ; For generating Org IDs
(require 'supertag-core-store)
(require 'supertag-core-schema)
(require 'supertag-core-transform)
(require 'supertag-core-state) ; For supertag-with-transaction
(require 'supertag-ops-node) ; For supertag-node-create
(require 'supertag-ops-batch) ; For supertag-batch-create
(require 'supertag-services-query) ; For supertag-find-nodes-by-file
(require 'supertag-core-persistence) ; For supertag-data-directory
(require 'supertag-ops-tag) ; For supertag-tag-create
(require 'supertag-ops-relation) ; For supertag-relation-create, supertag-relation-find-between
(require 'supertag-core-async) ; For async job queue
(defvar supertag-file-id-source 'org-roam
"Policy for recognizing stable file node IDs.")
;;; Customization (from supertag-old/supertag-sync.el)
(defgroup supertag-sync nil
"Synchronization settings for Supertag."
:group 'supertag)
(defcustom supertag-sync-state-file
(expand-file-name "sync-state.el" supertag-data-directory) ; Use new data directory
"File to store sync state data."
:type 'file
:group 'supertag-sync)
(defvar supertag-sync--state-source nil
"Resolved sync-state file path last loaded into memory.")
(defcustom supertag-sync-auto-interval 900
"Interval in seconds for automatic synchronization."
:type 'integer
:group 'supertag-sync)
(defcustom supertag-sync-idle-delay 1.0
"Seconds of idle time required before automatic sync runs."
:type 'number
:group 'supertag-sync)
(defcustom supertag-sync-directories nil
"List of directories to monitor for automatic synchronization.
Each entry should be an absolute path. Subdirectories will also be monitored.
If nil, no automatic synchronization will occur."
:type '(repeat directory)
:group 'supertag-sync)
(defcustom supertag-sync-directories-mode 'unified
"How to interpret `supertag-sync-directories`.
- `unified`: all directories share one database (legacy/default behavior).
- `vaults`: each directory is treated as an isolated vault with its own DB/state,
and sync only runs for the currently active vault directory.
Vault activation is handled by `supertag.el` (see `supertag-vault-activate`)."
:type '(choice (const :tag "Unified DB" unified)
(const :tag "Vaults (isolated per directory)" vaults))
:group 'supertag-sync)
(defun supertag-sync--effective-directories ()
"Return effective sync directories.
When Supertag is running in vault mode, this resolves to the active vault's
directory (single-element list). Otherwise returns `supertag-sync-directories`."
(if (and (eq supertag-sync-directories-mode 'vaults)
(fboundp 'supertag--effective-sync-directories))
(supertag--effective-sync-directories)
supertag-sync-directories))
(defcustom supertag-sync-exclude-directories nil
"List of directories to exclude from synchronization.
Takes precedence over `supertag-sync-directories`."
:type '(repeat directory)
:group 'supertag-sync)
(defcustom supertag-sync-file-pattern "\.org$"
"Regular expression for matching files to synchronize."
:type 'string
:group 'supertag-sync)
(defcustom supertag-sync-quiet-when-idle t
"If non-nil, suppress routine sync summary/diagnostic messages when no changes were detected."
:type 'boolean
:group 'supertag-sync)
(defcustom supertag-sync-snapshot-guard t
"When non-nil, sync uses snapshot state to guard destructive operations."
:type 'boolean
:group 'supertag-sync)
(defun supertag-sync--state-file ()
"Return the resolved sync-state file path for current data directory."
(let* ((data-dir (file-name-as-directory (expand-file-name supertag-data-directory)))
(default-file (expand-file-name "sync-state.el" data-dir)))
(setq supertag-sync-state-file default-file)
supertag-sync-state-file))
(defcustom supertag-sync-hash-props
'(:raw-value :olp :tags :todo :priority :content :properties :parent-id)
"Additional properties to include when calculating node hashes.
All keys in `supertag-sync-document-fact-hash-props' are always included;
this option can extend that contract, but cannot remove Document Facts."
:type '(repeat symbol)
:group 'supertag-sync)
(defconst supertag-sync-document-fact-hash-props
'(:title :raw-value :olp :tags :todo :priority :scheduled :deadline
:tag-occurrences :unresolved-tags :content :properties :ref-to :file
:level :position :pos :parent-id :link-type)
"Document Projection properties that must participate in node hashes.")
(defcustom supertag-sync-smart-detection-enabled nil
"If non-nil, enable smart detection to skip unchanged files during sync.
When enabled, files are hashed and only re-parsed if their content has changed."
:type 'boolean
:group 'supertag-sync)
(defcustom supertag-sync-smart-detection-verbose nil
"If non-nil, show messages about smart detection decisions during sync."
:type 'boolean
:group 'supertag-sync)
(defvar supertag-sync--last-smart-detection-decision nil
"Internal state tracking the last smart detection decision.
Stores a plist with :file, :decision, :reason, and :time.")
(defcustom supertag-sync-auto-create-node nil
"Deprecated compatibility option; sync never invents heading IDs.
Use an explicit document command such as `supertag-create-node' to persist
an Org ID before projection. A read-only scan skips ID-less headings."
:type 'boolean
:group 'supertag-sync)
(defcustom supertag-sync-node-creation-level 1
"Minimum heading level for automatic node creation.
Only headings at this level or deeper will be considered for node creation."
:type 'integer
:group 'supertag-sync)
;; Safety guards against accidental mass-deletion/data loss
(defcustom supertag-sync-orphan-grace-seconds 3600
"Grace period in seconds before deleting orphaned nodes.
A node must remain orphaned (its :file property nil) for at least this long
before garbage collection can remove it."
:type 'integer
:group 'supertag-sync)
(defcustom supertag-sync-max-delete-ratio 0.5
"Maximum allowed ratio of nodes to delete in a single GC pass.
If the fraction of candidate orphan deletions exceeds this ratio of total nodes,
the deletion pass is aborted to prevent accidental mass deletion."
:type 'number
:group 'supertag-sync)
(defcustom supertag-sync-max-delete-count 1000
"Maximum number of nodes allowed to be deleted in a single GC pass.
If candidate deletions exceed this number, the deletion pass is aborted."
:type 'integer
:group 'supertag-sync)
;;; Auto-start configuration (safer defaults to reduce user setup)
(defcustom supertag-sync-auto-start t
"Automatically start Supertag auto-sync after Emacs startup.
Start is delayed and retried until sync directories are available
to avoid race conditions at early startup."
:type 'boolean
:group 'supertag-sync)
(defcustom supertag-sync-auto-start-initial-delay 3
"Seconds to wait after startup before the first auto-start attempt."
:type 'integer
:group 'supertag-sync)
(defcustom supertag-sync-auto-start-retry-interval 5
"Seconds between auto-start retry attempts when directories are not yet available."
:type 'integer
:group 'supertag-sync)
(defcustom supertag-sync-auto-start-max-retries 24
"Maximum number of auto-start retries before giving up.
With the default interval, this caps retries to about 2 minutes."
:type 'integer
:group 'supertag-sync)
(defvar supertag-sync--auto-start-timer nil
"Internal timer used for deferred auto-start of the sync worker.")
(defvar supertag-sync--auto-start-retries-left 0
"Internal counter for remaining auto-start retries.")
;; Tag write style for rendering headlines
(defcustom supertag-tag-style 'inline
"Style to write tags when generating or inserting Org headlines.
Supported values:
- 'inline => Title with inline #tags.
- 'org => Title with org native :tag: syntax.
- 'both => Combine both inline and org native forms.
- 'auto => Heuristic; currently defaults to 'inline."
:type '(choice (const :tag "Inline #tags" inline)
(const :tag "Org :tag:" org)
(const :tag "Both" both)
(const :tag "Auto" auto))
:group 'supertag-sync)
;; Legacy tag handling policy
(defcustom supertag-sync-legacy-tags-policy 'read-only
"How to handle legacy org native :tag: found in headlines.
Supported values:
- 'read-only => Read and import to DB, do not modify files (default).
- 'lazy-convert => When touching a headline, convert :tag: to inline #tags.
- 'preserve => Always preserve :tag: in files.
- 'ignore => Do not read or import :tag: into the database."
:type '(choice (const :tag "Read only" read-only)
(const :tag "Lazy convert on edit" lazy-convert)
(const :tag "Preserve in files" preserve)
(const :tag "Ignore" ignore))
:group 'supertag-sync)
;;; Variables
(defvar supertag-sync--state (make-hash-table :test 'equal)
"Track file modification states.
Key: file path
Value: last sync time")
(defvar supertag-sync--internal-modifications (make-hash-table :test 'equal)
"Track files modified internally by Supertag code.
Key: file path (absolute)
Value: timestamp of last internal modification.
This is used to distinguish internal modifications (by automation/UI) from
external modifications (by user/other tools), preventing unnecessary re-sync.")
(defvar supertag-sync--deferred-files (make-hash-table :test 'equal)
"Files processed while destructive sync is disabled.
These files will be re-verified once the snapshot becomes complete.")
(defvar supertag-sync--is-full-rescan-p nil
"Dynamically bound to t during a full rescan.
This allows special behavior, like one-time import of legacy tags.")
;;; Helper functions for accessing sync state data
(defun supertag--mark-internal-modification (file)
"Mark FILE as internally modified by Supertag.
FILE should be an absolute path. This function records the current time
to prevent sync from re-parsing the file we just modified."
(when file
(let ((abs-file (file-truename (expand-file-name file))))
(puthash abs-file (current-time) supertag-sync--internal-modifications))))
(defun supertag--clear-internal-modification (file)
"Forget the internal modification marker for FILE."
(when file
(remhash (file-truename (expand-file-name file))
supertag-sync--internal-modifications)))
(defun supertag--is-internal-modification-p (file)
"Check if FILE was recently modified internally by Supertag.
Returns t if the file's modification time is within 1 second of the last
internal modification timestamp, indicating this save is from Supertag code."
(when file
(let* ((abs-file (file-truename (expand-file-name file)))
(last-internal (gethash abs-file supertag-sync--internal-modifications))
(file-mtime (when (file-exists-p abs-file)
(file-attribute-modification-time (file-attributes abs-file)))))
(and last-internal
file-mtime
;; If file mtime is within 2 seconds after internal modification, skip sync
(time-less-p file-mtime (time-add last-internal 2))))))
(defun supertag-sync--get-state-table ()
"Get the actual state hash table from supertag-sync--state.
Handles both old format (direct hash table) and new format (plist with :sync-state key)."
(cond
((hash-table-p supertag-sync--state)
;; Old format: direct hash table
supertag-sync--state)
((and (listp supertag-sync--state) (plist-get supertag-sync--state :sync-state))
;; New format: plist with :sync-state key
(plist-get supertag-sync--state :sync-state))
(t
;; Fallback: create empty hash table
(let ((new-table (make-hash-table :test 'equal)))
(setq supertag-sync--state (list :sync-state new-table))
new-table))))
(defun supertag-sync--ensure-state-format ()
"Ensure supertag-sync--state is in the correct format for current code.
If it's a hash table, wrap it in a plist so metadata can be stored."
(cond
((hash-table-p supertag-sync--state)
(setq supertag-sync--state (list :sync-state supertag-sync--state)))
((and (listp supertag-sync--state)
(plist-get supertag-sync--state :sync-state))
supertag-sync--state)
(t
(setq supertag-sync--state (list :sync-state (make-hash-table :test 'equal))))))
(defun supertag-sync--snapshot-get ()
"Return snapshot metadata stored in sync state."
(when (and (listp supertag-sync--state)
(plist-get supertag-sync--state :snapshot))
(plist-get supertag-sync--state :snapshot)))
(defun supertag-sync--snapshot-set (snapshot)
"Store SNAPSHOT metadata in sync state (in-memory)."
(supertag-sync--ensure-state-format)
(setq supertag-sync--state (plist-put supertag-sync--state :snapshot snapshot))
snapshot)
(defun supertag-sync--snapshot-status ()
"Return current snapshot status symbol, or nil."
(plist-get (supertag-sync--snapshot-get) :status))
(defun supertag-sync--allow-destructive-p ()
"Return non-nil when destructive sync operations are allowed."
(or (not supertag-sync-snapshot-guard)
(eq (supertag-sync--snapshot-status) 'complete)))
(defun supertag-sync--ensure-state-source ()
"Ensure in-memory sync state matches the current data directory."
(let ((state-file (supertag-sync--state-file)))
(unless (and (stringp supertag-sync--state-source)
(string= supertag-sync--state-source state-file))
(supertag-sync-load-state))))
;;; --- Sync Mechanism ---
;; Core Functions - File State Tracking
(defun supertag-sync--in-scope-path-p (file)
"Check if FILE path is within synchronization scope.
Does not require the file to exist."
(when file
(let* ((expanded-file (expand-file-name file))
(file-dir (file-name-directory expanded-file))
(excluded (and supertag-sync-exclude-directories
(cl-some (lambda (dir)
(let ((expanded-exclude-dir (expand-file-name dir)))
(string-prefix-p expanded-exclude-dir file-dir)))
supertag-sync-exclude-directories)))
(sync-dirs (supertag-sync--effective-directories))
(included (if sync-dirs
(cl-some (lambda (dir)
(let ((expanded-dir (expand-file-name dir)))
(string-prefix-p expanded-dir file-dir)))
sync-dirs)
t)))
(let ((result (and included
(not excluded)
(string-match-p supertag-sync-file-pattern file))))
result))))
(defun supertag-sync--in-sync-scope-p (file)
"Check if FILE is within synchronization scope.
Returns t if file should be synchronized based on configured directories.
If no directories are configured, returns t for all org files."
(when (and file (file-exists-p file))
(supertag-sync--in-scope-path-p file)))
(defun supertag-scan-sync-directories (&optional all-files-p)
"Scan sync directories for org files.
If ALL-FILES-P is non-nil, return all files in scope.
Otherwise, returns a list of new files that are not yet in sync state."
(let ((files nil)
(state-table (supertag-sync--get-state-table)))
(let ((sync-dirs (supertag-sync--effective-directories)))
(if (not sync-dirs)
(message "WARNING: supertag-sync-directories is not configured. No files will be synced.")
(dolist (dir sync-dirs)
(when (file-exists-p dir)
(let ((dir-files (directory-files-recursively
dir supertag-sync-file-pattern t)))
(dolist (file dir-files)
(when (and (file-regular-p file)
(supertag-sync--in-sync-scope-p file)
(or all-files-p
(not (gethash file state-table))))
(push file files))))))))
files))
(defun supertag-sync--snapshot-build ()
"Build a snapshot of sync directories.
Returns a plist with :status, :files, :scope, :errors, :observed-at."
(let* ((sync-dirs (supertag-sync--effective-directories))
(errors '()))
(cond
((not sync-dirs)
(list :status 'unavailable
:files nil
:scope nil
:errors (list "sync directories not configured")
:observed-at (current-time)))
(t
(let ((unavailable nil))
(dolist (dir sync-dirs)
(unless (and (file-directory-p dir)
(file-readable-p dir))
(push (list :dir dir :error 'unavailable) errors)
(setq unavailable t)))
(if unavailable
(list :status 'unavailable
:files nil
:scope sync-dirs
:errors (nreverse errors)
:observed-at (current-time))
(let ((partial nil)
(files '()))
(dolist (dir sync-dirs)
(condition-case err
(let ((dir-files (directory-files-recursively
dir supertag-sync-file-pattern t)))
(dolist (file dir-files)
(when (and (file-regular-p file)
(supertag-sync--in-scope-path-p file))
(push file files))))
(error
(setq partial t)
(push (list :dir dir :error (error-message-string err)) errors))))
(list :status (if partial 'partial 'complete)
:files (cl-delete-duplicates files :test #'string-equal)
:scope sync-dirs
:errors (nreverse errors)
:observed-at (current-time)))))))))
(defun supertag-sync--snapshot-new-files (snapshot-files)
"Return files that are in SNAPSHOT-FILES but missing from sync state."
(let ((state-table (supertag-sync--get-state-table))
(new-files '()))
(dolist (file snapshot-files)
(unless (gethash file state-table)
(push file new-files)))
new-files))
(defun supertag-sync--snapshot-files-to-remove (snapshot-files)
"Return state files that should be removed based on SNAPSHOT-FILES."
(let ((state-table (supertag-sync--get-state-table))
(snapshot-set (make-hash-table :test 'equal))
(files-to-remove '()))
(dolist (file snapshot-files)
(puthash file t snapshot-set))
(maphash
(lambda (file _state)
(let ((in-scope (supertag-sync--in-scope-path-p file)))
(when (or (not in-scope)
(not (gethash file snapshot-set)))
(push file files-to-remove))))
state-table)
files-to-remove))
(defun supertag-sync-update-state (file &optional content-hash)
"Update sync state for FILE.
If CONTENT-HASH is provided, store it in the state entry."
(when (file-exists-p file)
(let* ((state-table (supertag-sync--get-state-table))
(attrs (file-attributes file))
(mtime (file-attribute-modification-time attrs))
(size (file-attribute-size attrs))
(old-state (gethash file state-table))
(old-hash (when (and (listp old-state) (keywordp (car old-state)))
(plist-get old-state :content-hash))))
(puthash file
(list :mtime mtime
:size size
:content-hash (or content-hash old-hash)
:hash-algo 'sha1)
state-table))))
(defun supertag-sync--state-mtime (state)
"Extract the last sync mtime from STATE.
STATE may be a time value or a plist containing the :mtime keyword."
(cond
((and (listp state) (keywordp (car state)))
(plist-get state :mtime))
(t state)))
(defun supertag-sync--normalize-time (time-val)
"Normalize TIME-VAL to a value accepted by `time-less-p`."
(cond
((null time-val) nil)
((stringp time-val)
(apply #'encode-time
(mapcar (lambda (x) (or x 0))
(parse-time-string time-val))))
((numberp time-val)
(seconds-to-time time-val))
(t time-val)))
(defun supertag-sync-check-state (file)
"Check if FILE needs synchronization.
Returns t if file has been modified since last sync."
(let ((state-table (supertag-sync--get-state-table)))
(when-let* ((state (gethash file state-table)))
(let ((last-sync (supertag-sync--normalize-time
(supertag-sync--state-mtime state)))
(mtime (file-attribute-modification-time
(file-attributes file))))
(and last-sync mtime (time-less-p last-sync mtime))))))
(defun supertag-get-modified-files ()
"Get list of files that need synchronization.
Returns files that have been modified since last sync."
(let ((files nil)
(state-table (supertag-sync--get-state-table)))
(maphash
(lambda (file state)
(when (and (file-exists-p file)
(supertag-sync--in-sync-scope-p file)
(supertag-sync-check-state file))
(push file files)))
state-table)
files))
;; --- State Management ---
(defun supertag-sync-import-file (file)
"Import data from FILE into the store.
Reads the file, parses Org nodes, and creates/updates them using hybrid architecture.
Returns a list of imported/updated node data."
(let ((nodes (supertag--parse-org-nodes file))
(imported-nodes '()))
;; Process each node with hybrid architecture (strict validation + direct storage)
(dolist (node-props nodes)
(let ((imported-node (supertag-node-create node-props)))
(push imported-node imported-nodes)))
(nreverse imported-nodes)))
(defun supertag-sync-export-file (file)
"Export data from the store to FILE.
Finds all nodes associated with FILE, generates Org content,
and writes it to the file.
Returns a list of exported node data."
(let* ((nodes (supertag-find-nodes-by-file file))
(node-data (mapcar #'cdr nodes)) ; Extract only the node data from (id . data) pairs
(org-content (supertag--generate-org-content node-data)))
(with-temp-file file (insert org-content))
node-data))
(defun supertag--generate-org-content (nodes)
"Helper function to generate Org content from node plists.
NODES is a list of node plists.
Returns a string containing the Org content.
Respects `supertag-tag-style` configuration for tag formatting."
(with-temp-buffer
(dolist (node nodes)
(let* ((title (plist-get node :title))
(tags (plist-get node :tags))
(level (or (plist-get node :level) 1))
(content (or (plist-get node :content) ""))
(id (plist-get node :id))
(file (plist-get node :file))
;; Use the configured tag style
(tag-style (supertag--resolve-tag-style node file))
(tags-part (supertag--format-tags-by-style tags tag-style)))
;; Reconstruct the node with configured tag formatting
(insert (format "%s %s%s\n"
(make-string level ?*)
title
tags-part))
(insert (format ":PROPERTIES:\n:ID: %s\n:END:\n" id))
;; Insert content
(when content
(insert content))
(unless (or (string-empty-p content) (string-suffix-p "\n" content))
(insert "\n"))))
(buffer-string)))
(defun supertag-sync-save-state ()
"Save sync state to file."
(supertag-sync--ensure-state-format)
(let ((state-file (supertag-sync--state-file)))
(make-directory (file-name-directory state-file) t)
(with-temp-file state-file
(let ((print-length nil)
(print-level nil))
(prin1 supertag-sync--state (current-buffer))))
(setq supertag-sync--state-source state-file)))
(defun supertag-sync-load-state ()
"Load sync state from file.
If file doesn't exist, initialize empty state.
Returns the loaded or initialized sync state."
(let ((state-file (supertag-sync--state-file)))
(condition-case err
(let ((result
(if (file-exists-p state-file)
(with-temp-buffer
(insert-file-contents state-file)
(goto-char (point-min))
;; Check if file is empty
(if (= (point-min) (point-max))
(progn
(message "Warning: Sync state file is empty, initializing new state")
(setq supertag-sync--state (make-hash-table :test 'equal))
(supertag-sync--ensure-state-format)
(setq supertag-sync--state-source state-file))
(condition-case read-err
(progn
(setq supertag-sync--state (read (current-buffer)))
(message "Loaded sync state with %d entries"
(let ((state-table (supertag-sync--get-state-table)))
(if (hash-table-p state-table)
(hash-table-count state-table)
0)))
;; Ensure the loaded state is in the correct format
(supertag-sync--ensure-state-format)
(setq supertag-sync--state-source state-file)
supertag-sync--state)
(error
(message "Error reading sync state: %s" (error-message-string read-err))
(message "Initializing new sync state")
(setq supertag-sync--state (make-hash-table :test 'equal))
(supertag-sync--ensure-state-format)
(setq supertag-sync--state-source state-file)
supertag-sync--state))))
;; Initialize empty state if file doesn't exist
(progn
(message "Sync state file does not exist, initializing empty state")
(setq supertag-sync--state (make-hash-table :test 'equal))
;; Save the initial state
(supertag-sync-save-state)
supertag-sync--state))))
result)
(error
(message "Critical error loading sync state: %s" (error-message-string err))
(message "Initializing fresh sync state")
(setq supertag-sync--state (make-hash-table :test 'equal))
(setq supertag-sync--state-source state-file)
supertag-sync--state))))
;; --- Check and sync ---
(defun supertag-sync-ensure-directories ()
"Ensure sync directories are properly configured."
(unless (supertag-sync--effective-directories)
(message "Warning: `supertag-sync-directories` is not set. Auto-sync will not occur.")))
(defvar supertag-sync--timer nil
"Timer for periodic sync checks.")
(defvar supertag-sync--idle-dispatch nil
"Idle timer used to defer sync execution until Emacs is idle.")
(defun supertag-sync--cancel-idle-dispatch ()
"Cancel any pending idle dispatch for the sync worker."
(when (timerp supertag-sync--idle-dispatch)
(cancel-timer supertag-sync--idle-dispatch))
(setq supertag-sync--idle-dispatch nil))
(defun supertag-sync--queue-idle-dispatch ()
"Schedule sync execution for the next idle period."
(unless (timerp supertag-sync--idle-dispatch)
(setq supertag-sync--idle-dispatch
(run-with-idle-timer
(max supertag-sync-idle-delay 0)
nil
#'supertag-sync--run-idle-dispatch))))
(defun supertag-sync--run-idle-dispatch ()
"Run the sync worker after idle delay."
(setq supertag-sync--idle-dispatch nil)
(when (fboundp 'supertag-sync--check-and-sync)
(supertag-sync--check-and-sync)))
;;; Auto-start manager -------------------------------------------------
(defun supertag-sync--dirs-ready-p ()
"Return non-nil when all configured sync directories exist and are accessible."
(let ((sync-dirs (supertag-sync--effective-directories)))
(and sync-dirs
(cl-every #'file-directory-p sync-dirs))))
(defun supertag-sync--cancel-auto-start ()
"Cancel any pending auto-start timer."
(when (timerp supertag-sync--auto-start-timer)
(cancel-timer supertag-sync--auto-start-timer))
(setq supertag-sync--auto-start-timer nil)
(setq supertag-sync--auto-start-retries-left 0))
(defun supertag-sync--auto-start-tick ()
"Auto-start attempt: start sync when directories are ready, otherwise retry."
(cond
((not supertag-sync-auto-start)
(supertag-sync--cancel-auto-start))
((supertag-sync--dirs-ready-p)
(supertag-sync--cancel-auto-start)
(message "Supertag: directories ready; starting auto-sync")
(supertag-sync-start-auto-sync))
((<= supertag-sync--auto-start-retries-left 0)
(supertag-sync--cancel-auto-start)
(message "Supertag: auto-sync not started; directories unavailable"))
(t
(setq supertag-sync--auto-start-retries-left (1- supertag-sync--auto-start-retries-left)))))
(defun supertag-sync-schedule-auto-start ()
"Schedule deferred auto-start of auto-sync with retries until directories are ready."
(when supertag-sync-auto-start
(supertag-sync--cancel-auto-start)
(setq supertag-sync--auto-start-retries-left supertag-sync-auto-start-max-retries)
(setq supertag-sync--auto-start-timer
(run-with-timer
(max 0 supertag-sync-auto-start-initial-delay)
(max 1 supertag-sync-auto-start-retry-interval)
#'supertag-sync--auto-start-tick))))
;; (defun supertag-sync-emergency-recovery ()
;; "Emergency recovery function to clean up all sync-related timers and state.
;; Use this when experiencing persistent timer-related errors."
;; (interactive)
;; (message "Starting emergency recovery for sync system...")
;; ;; Cancel our timer
;; (when (timerp supertag-sync--timer)
;; (cancel-timer supertag-sync--timer)
;; (setq supertag-sync--timer nil)
;; (message "Canceled supertag-sync--timer"))
;; ;; Clean up any other timers that might be calling our function
;; (let ((all-timers (timer-list))
;; (cleaned-count 0))
;; (dolist (timer all-timers)
;; (when (and (timerp timer)
;; (or (equal (timer--function timer) 'supertag-sync--check-and-sync)
;; (and (listp (timer--function timer))
;; (equal (car (timer--function timer)) 'lambda))))
;; (cancel-timer timer)
;; (cl-incf cleaned-count)))
;; (when (> cleaned-count 0)
;; )
;; ;; Reset sync state
;; (setq supertag-sync--state (make-hash-table :test 'equal))
;; (message "Reset sync state")
;; ;; Verify function is defined
;; (if (fboundp 'supertag-sync--check-and-sync)
;; (message "Function supertag-sync--check-and-sync is properly defined")
;; (message "WARNING: Function supertag-sync--check-and-sync is NOT defined"))
;; (message "Emergency recovery completed. You can now try M-x supertag-sync-start-auto-sync"))
;;; Core Functions - Node Hash Support (from supertag-old/supertag-sync.el)
(defun supertag--node-hash--properties-to-alist (props)
"Normalize PROPS into an alist of (key . value) pairs for hashing."
(cond
((hash-table-p props)
(let (alist)
(maphash (lambda (k v)
(push (cons k v) alist))
props)
(nreverse alist)))
((and (listp props) (consp (car props)))
;; Already an alist such as ((:KEY . "value"))
(cl-copy-list props))
((plistp props)
(let ((cursor props)
(alist '()))
(while cursor
(let ((key (car cursor))
(val (cadr cursor)))
(push (cons key val) alist))
(setq cursor (cddr cursor)))
(nreverse alist)))
(t nil)))
(defun supertag--node-hash--property-key-string (key)
"Return a comparable string representation for property KEY."
(cond
((keywordp key) (symbol-name key))
((symbolp key) (symbol-name key))
((stringp key) key)
(t (format "%s" key))))
(defun supertag--node-hash--property-value-string (value)
"Return stable string representation for property VALUE."
(cond
((null value) "")
((stringp value) value)
(t (format "%s" value))))
(defun supertag--node-hash--value (node key)
"Return normalized string value for NODE's KEY."
(pcase key
((or :todo :todo-type)
(or (plist-get node :todo) ""))
(:raw-value
(or (plist-get node :raw-value) ""))
(:olp
(let ((olp (plist-get node :olp)))
(cond
((listp olp) (string-join olp "/"))
((stringp olp) olp)
(t ""))))
(:content
(or (plist-get node :content) ""))
(:tags
(let ((tag-list (plist-get node :tags)))
(cond
((listp tag-list) (mapconcat #'identity (sort (copy-sequence tag-list) #'string<) "|"))
((stringp tag-list) tag-list)
(t ""))))
(:priority
(or (plist-get node :priority) ""))
(:properties
(let* ((raw-props (plist-get node :properties))
(props-alist (supertag--node-hash--properties-to-alist raw-props)))
(if (null props-alist)
""
(let* ((sorted (sort (cl-copy-list props-alist)
(lambda (a b)
(string< (supertag--node-hash--property-key-string (car a))
(supertag--node-hash--property-key-string (car b)))))))
(mapconcat (lambda (pair)
(format "%s=%s"
(supertag--node-hash--property-key-string (car pair))
(supertag--node-hash--property-value-string (cdr pair))))
sorted
"|")))))
(_
(supertag--node-hash--property-value-string (plist-get node key)))))
(defun supertag-node-hash (node)
"Calculate hash value for NODE.
Includes the node's ID to ensure absolute uniqueness of the state fingerprint."
(let* ((id (or (plist-get node :id) "")) ; Ensure ID is part of the hash
(hash-props
(delete-dups
(append supertag-sync-document-fact-hash-props
(copy-sequence (or supertag-sync-hash-props '())))))
(payload (mapconcat
(lambda (key)
(format "%s=%s"
(supertag--node-hash--property-key-string key)
(supertag--node-hash--value node key)))
hash-props
"|")))
(secure-hash 'sha1 (format "%s|%s" id payload))))
(defun supertag-node-file-node-p (node)
"Return non-nil when NODE is a file node (level 0).
File nodes represent file-level identity rather than Org headings."
(eq (plist-get node :level) 0))
(defun supertag-sync--resolve-node-tag-occurrences (props)
"Resolve PROPS Tag Occurrences against existing Semantic Tags.
The returned copy stores Org tokens in :tag-occurrences, resolved Semantic
Tag IDs in :tags, and unresolved tokens in :unresolved-tags. Resolution is
read-only and never creates or modifies Semantic Tags."
(if (not (or (plist-member props :tag-occurrences)
(plist-member props :tags)))
props
(let* ((raw (if (plist-member props :tag-occurrences)
(plist-get props :tag-occurrences)
(plist-get props :tags)))
(occurrences
(delete-dups (mapcar #'supertag-sanitize-tag-name (or raw '()))))
resolved unresolved
(result (copy-sequence props)))
(dolist (occurrence occurrences)
(let ((tag-id (supertag-tag-resolve-occurrence occurrence)))
(if tag-id
(push tag-id resolved)
(push occurrence unresolved))))
(setq result (plist-put result :tag-occurrences occurrences))
(setq result (plist-put result :tags (delete-dups (nreverse resolved))))
(plist-put result :unresolved-tags (nreverse unresolved)))))
(defun supertag-node-mark-deleted-from-file (id)
"Mark a node as deleted from its file by setting its :file property to nil.
This does not remove the node from the store immediately."
(supertag-node-update
id
(lambda (node)
(when node
(let ((modified (copy-sequence node)))
(plist-put modified :file nil)
;; Record when the node first became orphaned to allow a grace period
(plist-put modified :orphaned-at (supertag-current-time)))))))
(defun supertag-db-add-with-hash (id props &optional counters)
"Add node with ID and PROPS to database, including hash value.
Existing creation time is preserved while file-backed properties are updated.
This function also handles tag creation and relations.
COUNTERS is an optional plist for tracking statistics."
(when-let* ((existing (supertag-node-get id))
(created-at (plist-get existing :created-at)))
(setq props (plist-put (copy-sequence props) :created-at created-at)))
(setq props (supertag-sync--resolve-node-tag-occurrences props))
(let ((node-hash (supertag-node-hash props)))
;; Ensure :id, :type and :hash are added to props while preserving existing fields
(let ((node-props (plist-put props :id id)))
(setq node-props (plist-put node-props :type :node))
(setq node-props (plist-put node-props :hash node-hash))
;; Process tags only when actually saving the node
(supertag--process-node-tags node-props)
;; Reference reconciliation is part of projection, not reporting.
(let ((reference-counters
(or counters (list :references-created 0 :references-deleted 0)))
(current-refs (plist-get node-props :ref-to)))
(supertag--cleanup-orphaned-references
id current-refs reference-counters)
(supertag--process-node-references node-props reference-counters))
;; If this node comes from a file (i.e., has :file), clear any orphan marker
(when (plist-get node-props :file)
(setq node-props (plist-put node-props :orphaned-at nil)))
(supertag-node-create node-props))))
(defun supertag-node-changed-p (old-node new-node)
"Compare OLD-NODE and NEW-NODE to detect changes.
If OLD-NODE doesn't have a hash value, calculate it on the fly."
(let* ((projected-new
(supertag-sync--resolve-node-tag-occurrences new-node))
(old-hash (or (plist-get old-node :hash)
(supertag-node-hash old-node)))
(new-hash (supertag-node-hash projected-new)))
(not (string= old-hash new-hash))))
(defun supertag-sync--reconcile-node (new-props &optional counters)
"Reconcile NEW-PROPS with its current node Projection.
COUNTERS, when non-nil, receives create/update counts. Both file and point
sync use this function so change detection, tag membership, and reference
reconciliation cannot diverge. Org parsing replaces the complete Document
Projection; Semantic Facts live in their own Store collections."
(let* ((id (plist-get new-props :id))
(old-props (and id (supertag-node-get id))))
(cond
((null id) nil)
((null old-props)
(prog1 (supertag-db-add-with-hash id new-props counters)
(when counters
(setf (plist-get counters :nodes-created)
(1+ (or (plist-get counters :nodes-created) 0))))))
((or supertag-sync--is-full-rescan-p
(supertag-node-changed-p old-props new-props))
(prog1
(supertag-db-add-with-hash id new-props counters)
(when counters
(setf (plist-get counters :nodes-updated)
(1+ (or (plist-get counters :nodes-updated) 0))))))
(t old-props))))
(defun supertag-sync--parse-file-header ()
"Parse file header in current buffer for file node properties.
Returns a plist with identity, title, tags, and top-level :ref-to links.
Identity selection follows `supertag-file-id-source'."
(save-excursion
(goto-char (point-min))
(let (org-id denote-id id link-type title file-tags ref-to)
;; A file-level Org ID must be in the drawer at the start of the file.
(skip-chars-forward " \t\r\n")
(when (looking-at "^:PROPERTIES:")
(let ((drawer-end (save-excursion
(when (re-search-forward "^:END:" nil t)
(point)))))
(when (and drawer-end
(re-search-forward "^:ID:\\s-*\\(.+\\)" drawer-end t))
(setq org-id (string-trim (match-string 1))))))
;; Denote mirrors its persistent file identifier in Org front matter.
(goto-char (point-min))
(when (re-search-forward
"^#\\+IDENTIFIER:\\s-*\\(.+\\)"
(min 2000 (point-max)) t)
(setq denote-id (string-trim (match-string 1))))
(pcase supertag-file-id-source
((or 'org-roam 'org-id)
(setq id org-id link-type (and org-id 'id)))
('denote
(setq id denote-id link-type (and denote-id 'denote)))
('auto
(setq id (or org-id denote-id)
link-type (cond (org-id 'id) (denote-id 'denote))))
('disabled nil)
(_ (user-error "Unknown file node policy: %S" supertag-file-id-source)))
;; Read #+TITLE:
(goto-char (point-min))
(when (re-search-forward
"^#\\+TITLE:\\s-*\\(.+\\)"
(min 2000 (point-max)) t)
(setq title (string-trim (match-string 1))))
;; Read #+FILETAGS: