-
-
Notifications
You must be signed in to change notification settings - Fork 24
Expand file tree
/
Copy pathvulpea-db-sync.el
More file actions
2028 lines (1796 loc) · 88.3 KB
/
Copy pathvulpea-db-sync.el
File metadata and controls
2028 lines (1796 loc) · 88.3 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
;;; vulpea-db-sync.el --- File watching and async updates -*- lexical-binding: t; -*-
;;
;; Copyright (c) 2015-2026 Boris Buliga <boris@d12frosted.io>
;;
;; Author: Boris Buliga <boris@d12frosted.io>
;; Maintainer: Boris Buliga <boris@d12frosted.io>
;;
;; 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
;; <http://www.gnu.org/licenses/>.
;;
;; This file is not part of GNU Emacs.
;;
;; Created: 16 Nov 2025
;;
;; URL: https://github.com/d12frosted/vulpea
;;
;; License: GPLv3
;;
;;; Commentary:
;;
;; File watching and async update system for Vulpea v2.
;;
;; This module provides:
;; - File watchers using filenotify
;; - Async update queue with batching
;; - Debouncing for rapid changes
;; - Idle timer processing
;; - Dual-mode support (async vs sync)
;;
;; Design:
;; - Non-blocking: UI never waits for database updates
;; - Handles external changes: git pulls, sync tools, etc.
;; - Batch processing: 100 file changes = 1 transaction
;; - Configurable delays and thresholds
;;
;;; Code:
(require 'filenotify)
(require 'seq)
(require 'vulpea-db)
(require 'vulpea-db-extract)
(require 'vulpea-db-query)
(require 'vulpea-db-worker)
;;; Customization
(defgroup vulpea-db-sync nil
"File watching and synchronization for Vulpea."
:group 'vulpea)
(defcustom vulpea-db-sync-batch-delay 0.01
"Delay in seconds before processing batched updates.
After last file change, wait this long before updating database.
This allows batching multiple changes into single transaction."
:type 'number
:group 'vulpea-db-sync)
(defcustom vulpea-db-sync-idle-delay 0.5
"Idle delay in seconds for processing updates.
Process updates when Emacs has been idle for this duration."
:type 'number
:group 'vulpea-db-sync)
(defcustom vulpea-db-sync-batch-size 100
"Maximum number of files to process in single batch.
Limits memory usage and transaction size."
:type 'integer
:group 'vulpea-db-sync)
(defcustom vulpea-db-sync-directories (list org-directory)
"List of directories to watch for file changes.
Recursively watches these directories for .org files.
Defaults to `org-directory'."
:type '(repeat directory)
:group 'vulpea-db-sync)
(defcustom vulpea-db-sync-external-method 'auto
"Method to use for detecting external file changes.
Possible values:
- `auto' - try fswatch first, fallback to polling
- `fswatch' - use only fswatch (error if not available)
- `poll' - use only polling
- nil - rely only on filenotify (unreliable for external changes)"
:type '(choice (const :tag "Automatic (fswatch or poll)" auto)
(const :tag "FSWatch only" fswatch)
(const :tag "Polling only" poll)
(const :tag "Filenotify only (unreliable)" nil))
:group 'vulpea-db-sync)
(defcustom vulpea-db-sync-fswatch-path-style 'auto
"Path style to use when launching `fswatch' on Windows.
The available Windows builds of fswatch expect different directory
arguments. The MSYS2/mingw build accepts native paths like
\"c:/notes\"; the Cygwin build (e.g. the one distributed via winget)
only accepts \"/cygdrive/c/notes\" and fails to watch native paths.
Possible values:
- `auto' - start with native paths and automatically switch to the
Cygwin style if that build's path errors are detected at runtime
- `native' - always pass native paths (MSYS2/mingw build)
- `cygwin' - always pass `/cygdrive/' paths (Cygwin build)
Has no effect off Windows, where fswatch always receives native paths."
:type '(choice (const :tag "Automatic" auto)
(const :tag "Native (MSYS2/mingw)" native)
(const :tag "Cygwin (/cygdrive/)" cygwin))
:group 'vulpea-db-sync)
(defcustom vulpea-db-sync-poll-interval 2
"Interval in seconds for polling external changes.
Only used when `vulpea-db-sync-external-method' is `poll' or
`auto' (when fswatch is not available)."
:type 'number
:group 'vulpea-db-sync)
(defcustom vulpea-db-sync-progress-interval 100
"Number of files to process before reporting progress.
When syncing a directory, report progress every N files.
Set to nil to disable progress reporting.
Set to a smaller value (e.g., 100) for more frequent updates,
or a larger value (e.g., 1000) for less frequent updates."
:type '(choice (const :tag "Disabled" nil)
(integer :tag "Report every N files"))
:group 'vulpea-db-sync)
(defcustom vulpea-db-sync-verbose t
"Whether to report sync progress and completion in the echo area.
When non-nil, routine status messages such as \"Vulpea: Syncing N
files...\" and \"Vulpea: Sync complete\" are shown. Set to nil to
silence these messages, for example to avoid distraction on every
save when autosync is enabled.
Errors and warnings are always shown regardless of this setting.
For low-level timing diagnostics see `vulpea-db-sync-debug'."
:type 'boolean
:group 'vulpea-db-sync)
(defcustom vulpea-db-sync-scan-on-enable 'async
"Whether to scan all files when enabling autosync mode.
This initial scan detects changes made while Emacs was closed (e.g.,
from git pulls, Dropbox sync, or external edits). Without it, such
changes remain invisible until the files are touched again or
`vulpea-db-sync-full-scan' is run manually.
Options:
- `async': Scan asynchronously (may cause lag during processing)
- `blocking': Scan synchronously (blocks Emacs until complete)
- nil: Skip initial scan (fast startup, manual sync when needed)
Set to nil for very large repositories (10000+ notes) if startup
processing causes lag, then run `vulpea-db-sync-full-scan' manually
after external changes.
Exception: when the database is empty (e.g. the very first
activation), an async scan is performed even when this is nil -
otherwise the database would stay empty with no indication why."
:type '(choice (const :tag "Async scan (may lag)" async)
(const :tag "Blocking scan (wait for completion)" blocking)
(const :tag "Skip scan (fast startup)" nil))
:group 'vulpea-db-sync)
(defcustom vulpea-db-sync-reindex-on-dir-locals-change 'auto
"Whether a dir-locals change force re-indexes the affected subtree.
Extraction output can depend on directory-local variables: the
`find-file' and `temp-buffer' parse methods apply `.dir-locals.el'
while parsing (only `single-temp-buffer' skips it), and an extractor
plugin may read dir-locals from the note's path. For such setups,
editing a `.dir-locals.el' (or `.dir-locals-2.el') silently leaves
the database stale. When this reaction is enabled, autosync watches
those files alongside org files: on a real content change (creation,
edit, deletion or rename - detected by content hash, so a mere touch
does not count) all org files under that directory are force
re-indexed, announced with a message. The startup scan diffs the
stored hashes as well, so edits made while Emacs was closed are
picked up too.
Possible values:
- `auto' - react when extraction can see dir-locals:
`vulpea-db-parse-method' is `find-file' or `temp-buffer', or a
registered extractor declares :reads-dir-locals t
- t - always react
- nil - never react (hashes are still tracked, so enabling the
reaction later diffs against the last tracked state)
Boundary: only dir-locals files can be watched. Directory classes
defined from elisp via `dir-locals-set-class-variables' have no file
to watch; after changing those, re-index manually with
\\[universal-argument] \\[vulpea-db-sync-full-scan]."
:type '(choice (const :tag "When extraction depends on dir-locals" auto)
(const :tag "Always" t)
(const :tag "Never" nil))
:group 'vulpea-db-sync)
;;; Variables
(defvar vulpea-db-sync--watchers nil
"Alist of (path . descriptor) for active file watchers.")
(defvar vulpea-db-sync--queue nil
"Queue of files pending database update.
Each entry is (path . timestamp).")
(defvar vulpea-db-sync--queue-tail nil
"Tail pointer for the pending queue list.")
(defvar vulpea-db-sync--queue-set (make-hash-table :test 'equal)
"Hash table tracking files already queued.")
(defvar vulpea-db-sync--force-set (make-hash-table :test 'equal)
"Queued paths marked for forced re-indexing.
Forced entries bypass change detection and the unchanged-content
shortcuts: they exist for parser or settings changes, where file
content is identical but extraction output is not.")
(defvar vulpea-db-sync--timer nil
"Timer for processing batched updates.")
(defvar vulpea-db-sync--idle-timer nil
"Idle timer for processing updates.")
(defvar vulpea-db-sync--processing nil
"Non-nil when currently processing updates.")
(defvar vulpea-db-sync--fswatch-process nil
"Process handle for fswatch external monitoring.")
(defvar vulpea-db-sync--fswatch-restart-timer nil
"Timer for the delayed fswatch respawn scheduled by the sentinel.
Tracked so that stopping external monitoring can cancel a restart
that is already in flight.")
(defvar vulpea-db-sync--fswatch-buffer ""
"Buffer for incomplete fswatch output lines.")
(defvar vulpea-db-sync--fswatch-effective-style nil
"Auto-detected fswatch path style: `native', `cygwin', or nil.
Set when `vulpea-db-sync-fswatch-path-style' is `auto' and the Cygwin
build of fswatch is detected at runtime. Reset when external
monitoring stops.")
(defvar vulpea-db-sync--fswatch-seen-valid-event nil
"Non-nil once fswatch has reported a valid watched-path event.
Guards auto-detection so a watcher that is already working is never
switched to a different path style by a stray error.")
(defvar vulpea-db-sync--poll-timer nil
"Timer for polling-based external monitoring.")
(defvar vulpea-db-sync--poll-scan-in-progress nil
"Non-nil when an async poll scan subprocess is running.")
(defvar vulpea-db-sync--file-attributes (make-hash-table :test 'equal)
"Cache of file attributes for external change detection.")
(defvar vulpea-db-sync--queue-total 0
"Total number of files queued for async processing.
Set when async processing starts, used for progress reporting.")
(defvar vulpea-db-sync--processed-total 0
"Total number of files processed in current async batch.
Reset when async processing completes.")
(defvar vulpea-db-sync--updated-total 0
"Total number of files actually updated in current async batch.
Reset when async processing completes.")
(defvar vulpea-db-sync--sync-start-time nil
"Start time of current sync phase.")
(defvar vulpea-db-sync--async-dispatched 0
"Files sent to the extraction worker and not yet completed.")
(defvar vulpea-db-sync--async-applied 0
"Files the worker completed with new data in the current burst.")
(defvar vulpea-db-sync--async-unchanged 0
"Files the worker completed without changes in the current burst.")
(defvar vulpea-db-sync--async-start-time nil
"Start time of the current background extraction burst.")
(defvar vulpea-db-sync--async-completed 0
"Total completions (any terminal status) in the current burst.")
(defvar vulpea-db-sync--announced nil
"Non-nil after the sync-start message was shown for this burst.
Prevents the Syncing-N-files announcement from re-firing on
every batch while all work is dispatched to the worker (the legacy
processed counter only moves for synchronously processed files).")
(defvar vulpea-db-sync-debug nil
"When non-nil, log timing information for sync operations.")
;;; Mode
;;;###autoload
(define-minor-mode vulpea-db-autosync-mode
"Toggle automatic database synchronization.
When enabled:
- Watch org files for changes
- Update database asynchronously
- Batch multiple changes
- Process during idle time
When disabled:
- Stop all file watchers
- Clear update queue
- Updates must be triggered manually"
:global t
:group 'vulpea-db-sync
(if vulpea-db-autosync-mode
(vulpea-db-sync--start)
(vulpea-db-sync--stop)))
;;; Utilities
(defun vulpea-db-sync--message (format-string &rest args)
"Display a status message when `vulpea-db-sync-verbose' is non-nil.
FORMAT-STRING and ARGS are passed to `message'. Use this for routine
progress and completion reports; use `message' directly for errors and
warnings that should always be shown."
(when vulpea-db-sync-verbose
(apply #'message format-string args)))
;;; Core Functions
(defun vulpea-db-sync--effective-scan-mode ()
"Return scan mode to use during autosync activation.
Returns `vulpea-db-sync-scan-on-enable', except when the database
is empty: then `async' is returned regardless of the setting, so
the very first activation populates the database without requiring
a manual `vulpea-db-sync-full-scan'."
(cond
(vulpea-db-sync-scan-on-enable)
((= (vulpea-db-count-notes) 0)
(vulpea-db-sync--message
"Vulpea: database is empty, scanning all files...")
'async)))
(defun vulpea-db-sync--start ()
"Start file watching and async update.
Optionally performs initial scan based on
`vulpea-db-sync-scan-on-enable' (an async scan is forced when the
database is empty, see `vulpea-db-sync--effective-scan-mode').
When `vulpea-db-sync-scan-on-enable' is `async', this function
returns immediately without blocking. File listing, cleanup of
deleted files, and enqueueing are all performed asynchronously via
a subprocess. The `blocking' mode still scans synchronously."
(let ((t-total (current-time))
(scan-mode (vulpea-db-sync--effective-scan-mode))
t-phase)
;; Kill stale scan subprocess from previous activation
(when-let* ((proc (get-process "vulpea-scan")))
(delete-process proc))
;; Start idle timer immediately (so queue is ready to process)
(unless vulpea-db-sync--idle-timer
(setq vulpea-db-sync--idle-timer
(run-with-idle-timer vulpea-db-sync-idle-delay t
#'vulpea-db-sync--process-queue)))
;; Track background extraction completions for progress reporting
(add-hook 'vulpea-db-worker-done-functions
#'vulpea-db-sync--worker-done)
;; Start external monitoring (fswatch is async, no blocking)
(setq t-phase (current-time))
(vulpea-db-sync--setup-external-monitoring)
(when vulpea-db-sync-debug
(message "[vulpea-sync] setup-external-monitoring: %.0fms"
(* 1000 (float-time (time-subtract (current-time) t-phase)))))
;; Start watching directories via filenotify unless fswatch is
;; active. When fswatch is running it already monitors the
;; filesystem for all changes (including in-Emacs saves), making
;; filenotify redundant. Programmatic changes (vulpea-create,
;; vulpea-utils-with-note-sync) call vulpea-db-update-file
;; directly and never rely on filenotify.
(setq t-phase (current-time))
(let ((watcher-count 0))
(if vulpea-db-sync--fswatch-process
(when vulpea-db-sync-debug
(message "[vulpea-sync] watch-directory: skipped (fswatch active)"))
(when vulpea-db-sync-directories
(dolist (dir vulpea-db-sync-directories)
(vulpea-db-sync--watch-directory dir))
(setq watcher-count (length vulpea-db-sync--watchers)))
(when vulpea-db-sync-debug
(message "[vulpea-sync] watch-directory: %.0fms (%d watchers)"
(* 1000 (float-time (time-subtract (current-time) t-phase)))
watcher-count))))
;; Initial scan and cleanup based on configuration
(if (and scan-mode vulpea-db-sync-directories)
(pcase scan-mode
('async
;; Use subprocess to list files, then cleanup + enqueue
(when vulpea-db-sync-debug
(message "[vulpea-sync] launching async scan subprocess..."))
(let ((scan-start (current-time)))
(vulpea-db-sync--scan-files-async
vulpea-db-sync-directories
(lambda (files)
;; Guard: skip if autosync was disabled while
;; subprocess was running
(when vulpea-db-autosync-mode
(when vulpea-db-sync-debug
(message "[vulpea-sync] async scan found %d files in %.0fms"
(length files)
(* 1000 (float-time (time-subtract (current-time) scan-start)))))
;; Cleanup: remove DB entries not in the file list
(let ((cleanup-start (current-time)))
(vulpea-db-sync--cleanup-deleted-files-using files)
(when vulpea-db-sync-debug
(message "[vulpea-sync] async cleanup: %.0fms"
(* 1000 (float-time (time-subtract (current-time) cleanup-start))))))
;; Diff dir-locals hashes so edits made while Emacs
;; was closed re-index their subtree (force marks
;; land before the plain enqueue below, which then
;; deduplicates against them). Errors are contained:
;; the auxiliary check must never cost the primary
;; sync its enqueue loop below
(let ((dir-locals-start (current-time)))
(condition-case err
(vulpea-db-sync--check-dir-locals files)
(error
(message "Vulpea: dir-locals check failed: %s"
(error-message-string err))))
(when vulpea-db-sync-debug
(message "[vulpea-sync] dir-locals check: %.0fms"
(* 1000 (float-time (time-subtract (current-time) dir-locals-start))))))
;; Enqueue all found files for change detection
(let ((enqueue-start (current-time)))
(dolist (file files)
(vulpea-db-sync--enqueue file))
(when vulpea-db-sync-debug
(message "[vulpea-sync] async enqueue: %.0fms (%d files)"
(* 1000 (float-time (time-subtract (current-time) enqueue-start)))
(length files)))))))))
('blocking
;; Scan synchronously (blocks Emacs)
(setq t-phase (current-time))
(vulpea-db-sync--cleanup-deleted-files)
(when vulpea-db-sync-debug
(message "[vulpea-sync] cleanup-deleted-files: %.0fms"
(* 1000 (float-time (time-subtract (current-time) t-phase)))))
;; Diff dir-locals hashes before the smart scan: a changed
;; subtree is then force re-indexed once and skipped as
;; unchanged by the scan below. Errors are contained so the
;; scan itself cannot be lost to the auxiliary check
(condition-case err
(vulpea-db-sync--check-dir-locals
(mapcan #'vulpea-db-sync--list-org-files
vulpea-db-sync-directories))
(error
(message "Vulpea: dir-locals check failed: %s"
(error-message-string err))))
(setq t-phase (current-time))
(dolist (dir vulpea-db-sync-directories)
(vulpea-db-sync-update-directory dir))
(when vulpea-db-sync-debug
(message "[vulpea-sync] blocking-scan: %.0fms"
(* 1000 (float-time (time-subtract (current-time) t-phase)))))))
;; No scan requested, but still cleanup deleted files
(setq t-phase (current-time))
(vulpea-db-sync--cleanup-deleted-files)
(when vulpea-db-sync-debug
(message "[vulpea-sync] cleanup-deleted-files: %.0fms"
(* 1000 (float-time (time-subtract (current-time) t-phase))))))
;; If schema was rebuilt, extraction settings changed, the parser
;; epoch changed, or a plugin migrated its schema, trigger forced
;; re-index
(when (or vulpea-db--schema-rebuilt
vulpea-db--settings-changed
vulpea-db--parser-changed
vulpea-db--plugin-schema-changed)
(let ((reason (cond
(vulpea-db--schema-rebuilt "Schema upgraded")
(vulpea-db--settings-changed "Extraction settings changed")
(vulpea-db--parser-changed "Parser updated")
(vulpea-db--plugin-schema-changed "Plugin schema updated"))))
(setq vulpea-db--schema-rebuilt nil)
(setq vulpea-db--settings-changed nil)
(setq vulpea-db--parser-changed nil)
(setq vulpea-db--plugin-schema-changed nil)
(vulpea-db-sync--message "Vulpea: %s, re-indexing all files..." reason)
(dolist (dir vulpea-db-sync-directories)
(vulpea-db-sync-update-directory dir 'force))))
(when vulpea-db-sync-debug
(message "[vulpea-sync] start complete: %.0fms total (sync portion)"
(* 1000 (float-time (time-subtract (current-time) t-total)))))))
(defun vulpea-db-sync--stop ()
"Stop file watching and clear queue."
;; Remove all watchers
(dolist (entry vulpea-db-sync--watchers)
(file-notify-rm-watch (cdr entry)))
(setq vulpea-db-sync--watchers nil)
;; Stop async scan subprocess if running
(when-let* ((proc (get-process "vulpea-scan")))
(delete-process proc))
;; Stop the extraction worker
(remove-hook 'vulpea-db-worker-done-functions
#'vulpea-db-sync--worker-done)
(setq vulpea-db-sync--async-dispatched 0
vulpea-db-sync--async-applied 0
vulpea-db-sync--async-unchanged 0
vulpea-db-sync--async-completed 0
vulpea-db-sync--async-start-time nil
vulpea-db-sync--announced nil)
(vulpea-db-worker-stop)
;; Stop external monitoring
(vulpea-db-sync--stop-external-monitoring)
;; Cancel timers
(when vulpea-db-sync--timer
(cancel-timer vulpea-db-sync--timer)
(setq vulpea-db-sync--timer nil))
(when vulpea-db-sync--idle-timer
(cancel-timer vulpea-db-sync--idle-timer)
(setq vulpea-db-sync--idle-timer nil))
;; Clear queue
(setq vulpea-db-sync--queue nil
vulpea-db-sync--queue-tail nil)
(clrhash vulpea-db-sync--queue-set)
(clrhash vulpea-db-sync--force-set))
(defun vulpea-db-sync--watch-directory (dir)
"Watch DIR and all subdirectories for org file change."
(when (and dir (file-directory-p dir) (not (file-symlink-p dir)))
(unless (assoc dir vulpea-db-sync--watchers)
(let ((descriptor (file-notify-add-watch
dir
'(change)
#'vulpea-db-sync--file-notify-callback)))
(push (cons dir descriptor) vulpea-db-sync--watchers)))
;; Recursively watch subdirectories
(dolist (subdir (directory-files dir t "\\`[^.]" t))
(when (and (file-directory-p subdir)
(not (file-symlink-p subdir))
(not (string-match-p "/\\.git/" subdir)))
(vulpea-db-sync--watch-directory subdir)))))
(defun vulpea-db-sync--watch-file (path)
"Watch file at PATH for change."
(unless (assoc path vulpea-db-sync--watchers)
(when (file-exists-p path)
(let ((descriptor (file-notify-add-watch
path
'(change)
#'vulpea-db-sync--file-notify-callback)))
(push (cons path descriptor) vulpea-db-sync--watchers)))))
(defun vulpea-db-sync--unwatch-file (path)
"Stop watching file at PATH."
(when-let* ((entry (assoc path vulpea-db-sync--watchers)))
(file-notify-rm-watch (cdr entry))
(setq vulpea-db-sync--watchers
(delq entry vulpea-db-sync--watchers))))
(defun vulpea-db-sync--org-file-p (path)
"Return non-nil when PATH points to a tracked org file.
Excludes:
- Files not matching tracked extensions
- Files in hidden directories (paths containing /.)"
(and path
(seq-some (lambda (ext) (string-suffix-p ext path))
(vulpea-db--all-extensions))
(not (string-match-p "/\\." path))))
(defun vulpea-db-sync--list-org-files (dir)
"List all tracked org files in DIR recursively.
Uses `vulpea-db-sync--org-file-p' to filter files, ensuring
consistency with file watcher filtering.
DIR is expanded via `expand-file-name' to ensure returned paths
are absolute (e.g., ~/notes becomes /home/user/notes). This
keeps paths consistent with `vulpea-db-sync--scan-files-async'
and prevents tilde-vs-absolute mismatches in database queries."
(let ((dir (expand-file-name dir))
(regex (mapconcat (lambda (ext)
(concat (regexp-quote ext) "\\'"))
(vulpea-db--all-extensions)
"\\|")))
(mapcar #'vulpea-db-normalize-path
(seq-filter #'vulpea-db-sync--org-file-p
(directory-files-recursively dir regex)))))
(defun vulpea-db-sync--scan-files-async (dirs callback)
"List org files in DIRS asynchronously, call CALLBACK with file list.
Uses fd (or find as fallback) subprocess to avoid blocking Emacs.
CALLBACK receives a list of absolute file paths."
(let* ((buffer "")
(dir (car dirs))
(expanded-dir (expand-file-name dir))
(extensions (vulpea-db--all-extensions))
(cmd (if (executable-find "fd")
(append (list "fd" "--type" "f")
(mapcan (lambda (ext)
(list "--extension" (substring ext 1)))
extensions)
(list "--hidden" "--no-ignore"
"--exclude" ".*"
"." expanded-dir))
(let* ((name-args
(mapcar (lambda (ext)
(list "-name" (concat "*" ext)))
extensions))
(name-clause
(cl-loop for args in name-args
for first = t then nil
append (if first args (cons "-o" args)))))
(append (list "find" expanded-dir "-type" "f")
(if (> (length name-args) 1)
(append '("(") name-clause '(")"))
name-clause)
(list "-not" "-path" "*/.*"))))))
(make-process
:name "vulpea-scan"
:command cmd
:connection-type 'pipe
:noquery t
:filter (lambda (_proc output)
(setq buffer (concat buffer output)))
:sentinel (lambda (_proc event)
(when (string-prefix-p "finished" event)
;; Subprocess output bypasses filename decoding, so
;; paths arrive in whatever normalization the file
;; system uses (NFD on macOS); canonicalize them to
;; match paths obtained through filename syscalls.
(let ((files (mapcar
#'vulpea-db-normalize-path
(seq-filter
#'vulpea-db-sync--org-file-p
(split-string buffer "\n" t)))))
(if (cdr dirs)
;; More directories to scan
(vulpea-db-sync--scan-files-async
(cdr dirs)
(lambda (more-files)
(funcall callback (append files more-files))))
(funcall callback files))))))))
(defun vulpea-db-sync--drop-from-queue (path)
"Remove PATH from the pending queue."
(let (result)
(dolist (entry vulpea-db-sync--queue)
(unless (equal (car entry) path)
(push entry result)))
(setq vulpea-db-sync--queue (nreverse result))
(setq vulpea-db-sync--queue-tail (last vulpea-db-sync--queue))
(remhash path vulpea-db-sync--queue-set)
(remhash path vulpea-db-sync--force-set)))
(defun vulpea-db-sync--handle-removed-file (path)
"Permanently remove PATH from database tracking.
PATH can be a file or directory. If PATH is a directory (detected
by trailing slash or by having files under it in the database),
all files under that directory are removed."
(setq path (vulpea-db-normalize-path path))
(vulpea-db-sync--drop-from-queue path)
(vulpea-db-sync--unwatch-file path)
(let ((db (vulpea-db)))
(emacsql-with-transaction db
;; Try exact match first
(vulpea-db--delete-file-notes path)
(emacsql db [:delete :from files :where (= path $s1)] path)
;; Also handle directory removal - delete all files under this path
;; This handles the case when a directory is deleted externally
;; Use GLOB instead of LIKE - GLOB uses * and ? as wildcards,
;; which avoids issues with % and _ in directory names
(let* ((dir-prefix (if (string-suffix-p "/" path)
path
(concat path "/")))
;; Escape GLOB special characters: *, ?, [
(escaped-prefix (vulpea-db--escape-glob-pattern dir-prefix))
(glob-pattern (concat escaped-prefix "*")))
(dolist (file-path (mapcar #'car
(emacsql db [:select path :from files
:where (glob path $s1)]
glob-pattern)))
(vulpea-db--delete-file-notes file-path))
(emacsql db [:delete :from files :where (glob path $s1)]
glob-pattern)
;; Dir-locals rows under a removed directory are gone too; no
;; reaction needed, there is nothing left under them to re-index
(emacsql db [:delete :from dir-locals-files :where (glob path $s1)]
glob-pattern)))))
(defun vulpea-db-sync--file-notify-callback (event)
"Handle file notification EVENT."
(pcase-let ((`(,_descriptor ,action ,file . ,rest) event))
(pcase action
((or 'changed 'created 'attribute-changed)
(cond
((and file (file-directory-p file) (eq action 'created))
(vulpea-db-sync--watch-directory file))
((vulpea-db-sync--dir-locals-file-p file)
(vulpea-db-sync--handle-dir-locals-event file))
((vulpea-db-sync--org-file-p file)
(vulpea-db-sync--enqueue file))))
('deleted
(cond
((vulpea-db-sync--dir-locals-file-p file)
(vulpea-db-sync--handle-dir-locals-event file))
((vulpea-db-sync--org-file-p file)
(vulpea-db-sync--handle-removed-file file))))
('renamed
(pcase rest
(`(,new-path)
(cond
((vulpea-db-sync--dir-locals-file-p file)
(vulpea-db-sync--handle-dir-locals-event file))
((vulpea-db-sync--org-file-p file)
(vulpea-db-sync--handle-removed-file file)))
(cond
((vulpea-db-sync--dir-locals-file-p new-path)
(vulpea-db-sync--handle-dir-locals-event new-path))
((vulpea-db-sync--org-file-p new-path)
(vulpea-db-sync--enqueue new-path)))))))
nil))
(defun vulpea-db-sync--enqueue (path &optional force no-count)
"Add PATH to update queue.
With FORCE non-nil, PATH is re-indexed even if its content is
unchanged (see `vulpea-db-sync--force-set'). A force mark also
upgrades an already-queued entry.
With NO-COUNT non-nil the entry does not increment the progress
total: used when re-queueing an entry that was already counted
\(flow-control saturation retries), which would otherwise inflate
the reported total on every retry."
(setq path (vulpea-db-normalize-path path))
(let ((timestamp (float-time)))
(when force
(puthash path t vulpea-db-sync--force-set))
(unless (gethash path vulpea-db-sync--queue-set)
(puthash path t vulpea-db-sync--queue-set)
;; Add new entry at the tail to maintain FIFO order
(let ((node (list (cons path timestamp))))
(if vulpea-db-sync--queue
(setcdr vulpea-db-sync--queue-tail node)
(setq vulpea-db-sync--queue node))
(setq vulpea-db-sync--queue-tail node))
;; Reset batch timer
(when vulpea-db-sync--timer
(cancel-timer vulpea-db-sync--timer))
(setq vulpea-db-sync--timer
(run-with-timer vulpea-db-sync-batch-delay nil
#'vulpea-db-sync--process-queue))
;; When a sync is already in progress, account for newly discovered files
(when (and (not no-count)
(> vulpea-db-sync--queue-total 0))
(setq vulpea-db-sync--queue-total
(1+ vulpea-db-sync--queue-total))))))
(defun vulpea-db-sync--process-queue ()
"Process queued file update."
;; When the worker request window is full, leave the queue alone
;; and retry shortly - pulling a batch only to re-enqueue it would
;; churn the main thread for nothing during bulk syncs
(if (and vulpea-db-sync--queue
(not vulpea-db-sync--processing)
vulpea-db-async-extraction
(vulpea-db-worker-saturated-p))
(progn
(when vulpea-db-sync--timer
(cancel-timer vulpea-db-sync--timer))
(setq vulpea-db-sync--timer
(run-with-timer 0.05 nil #'vulpea-db-sync--process-queue)))
(when (and vulpea-db-sync--queue
(not vulpea-db-sync--processing))
(setq vulpea-db-sync--processing t)
(unwind-protect
(let* ((vulpea-db-sync--batch-start-time (current-time))
(batch-size (min (length vulpea-db-sync--queue)
vulpea-db-sync-batch-size))
(batch (seq-take vulpea-db-sync--queue batch-size))
(paths (mapcar #'car batch))
(db (vulpea-db))
(updated 0)
(unchanged 0)
(dispatched 0))
;; Initialize totals if starting fresh
(when (zerop vulpea-db-sync--processed-total)
(setq vulpea-db-sync--queue-total (length vulpea-db-sync--queue)
vulpea-db-sync--updated-total 0
vulpea-db-sync--sync-start-time (current-time))
(when (and (> vulpea-db-sync--queue-total 1)
(not vulpea-db-sync--announced))
(setq vulpea-db-sync--announced t)
(vulpea-db-sync--message "Vulpea: Syncing %d file%s..."
vulpea-db-sync--queue-total
(if (= vulpea-db-sync--queue-total 1) "" "s"))))
;; Remove processed items from queue
(setq vulpea-db-sync--queue
(seq-drop vulpea-db-sync--queue batch-size))
(unless vulpea-db-sync--queue
(setq vulpea-db-sync--queue-tail nil))
(dolist (path paths)
(remhash path vulpea-db-sync--queue-set))
;; Fetch all file hashes in one query (huge speedup)
(let* ((hash-rows (emacsql db [:select [path hash mtime size] :from files
:where (in path $v1)]
(vconcat paths)))
(hash-cache (make-hash-table :test 'equal)))
;; Build hash table for O(1) lookups
(dolist (row hash-rows)
(puthash (elt row 0)
(list :hash (elt row 1)
:mtime (elt row 2)
:size (elt row 3))
hash-cache))
;; Split off files the extraction worker will handle:
;; for those, only a cheap mtime/size comparison happens
;; here - reading, hashing, parsing and extraction all
;; run in the worker subprocess. Forced entries (parser
;; or settings changed) skip change detection entirely.
(let (sync-paths)
(dolist (path paths)
(let ((force (gethash path vulpea-db-sync--force-set)))
(remhash path vulpea-db-sync--force-set)
(cond
;; Worker window full: keep it queued, the timer
;; retries as completions free the window
((and vulpea-db-async-extraction
(vulpea-db-worker-should-handle-p path)
(vulpea-db-worker-saturated-p))
;; Already counted when first enqueued
(vulpea-db-sync--enqueue path force 'no-count))
((and vulpea-db-async-extraction
(vulpea-db-worker-should-handle-p path))
(condition-case err
(when (file-exists-p path)
(if (or force
(vulpea-db-sync--changed-on-disk-p
path hash-cache))
(progn
(when (zerop vulpea-db-sync--async-dispatched)
(setq vulpea-db-sync--async-applied 0
vulpea-db-sync--async-unchanged 0
vulpea-db-sync--async-start-time
(current-time)))
(vulpea-db-worker-request path force)
(setq vulpea-db-sync--async-dispatched
(1+ vulpea-db-sync--async-dispatched))
(setq dispatched (1+ dispatched)))
(setq unchanged (1+ unchanged))))
(error
;; Worker spawn/send failure: fall back to
;; synchronous processing in this batch so an
;; environmental failure cannot silently drop
;; the file
(message "Vulpea: Error dispatching %s (falling back to sync): %s"
path (error-message-string err))
(push (cons path force) sync-paths))))
(t
(push (cons path force) sync-paths)))))
;; Process the rest in a single transaction as before
(when sync-paths
(emacsql-with-transaction db
(pcase-dolist (`(,path . ,force) (nreverse sync-paths))
(condition-case err
(when (file-exists-p path)
(cond
(force
(vulpea-db-update-file path)
(setq updated (1+ updated)))
((vulpea-db-sync--update-file-if-changed path hash-cache)
(setq updated (1+ updated)))
(t
(setq unchanged (1+ unchanged)))))
(error
(message "Vulpea: Error updating %s: %s"
path (error-message-string err)))))))))
;; Update totals
(setq vulpea-db-sync--processed-total (+ vulpea-db-sync--processed-total updated unchanged)
vulpea-db-sync--updated-total (+ vulpea-db-sync--updated-total updated))
(when vulpea-db-sync-debug
(message "[vulpea-sync] batch: %.0fms (%d files, %d updated, %d unchanged, %d dispatched)"
(* 1000 (float-time (time-subtract (current-time) vulpea-db-sync--batch-start-time)))
batch-size updated unchanged dispatched))
;; Report progress (synchronous processing only; background
;; extraction reports through the worker done hook)
(when (and vulpea-db-sync-progress-interval
(> vulpea-db-sync--processed-total 0)
(> vulpea-db-sync--queue-total vulpea-db-sync-progress-interval)
(or (zerop (mod vulpea-db-sync--processed-total
vulpea-db-sync-progress-interval))
(null vulpea-db-sync--queue)))
(vulpea-db-sync--message "Vulpea: Progress: %d/%d files (%d updated, %d unchanged)"
vulpea-db-sync--processed-total
vulpea-db-sync--queue-total
vulpea-db-sync--updated-total
(- vulpea-db-sync--processed-total vulpea-db-sync--updated-total)))
;; Final summary when done
(when (null vulpea-db-sync--queue)
(when (> vulpea-db-sync--processed-total 0)
(let ((duration (when vulpea-db-sync--sync-start-time
(float-time (time-subtract (current-time)
vulpea-db-sync--sync-start-time)))))
(vulpea-db-sync--message "Vulpea: Sync complete - %d file%s (%d updated, %d unchanged%s)"
vulpea-db-sync--processed-total
(if (= vulpea-db-sync--processed-total 1) "" "s")
vulpea-db-sync--updated-total
(- vulpea-db-sync--processed-total vulpea-db-sync--updated-total)
(if duration
(format ", %.2fs" duration)
""))))
;; Reset counters (the announce flag stays while
;; background work is still in flight; the worker done
;; handler clears it with its completion summary)
(setq vulpea-db-sync--queue-total 0
vulpea-db-sync--processed-total 0
vulpea-db-sync--updated-total 0
vulpea-db-sync--sync-start-time nil)
(when (zerop vulpea-db-sync--async-dispatched)
(setq vulpea-db-sync--announced nil))
(clrhash vulpea-db-sync--queue-set)))
(setq vulpea-db-sync--processing nil)
;; If queue still has items, schedule another processing
(when vulpea-db-sync--queue
(when vulpea-db-sync--timer
(cancel-timer vulpea-db-sync--timer))
(setq vulpea-db-sync--timer
(run-with-timer vulpea-db-sync-batch-delay nil
#'vulpea-db-sync--process-queue)))))))
(defun vulpea-db-sync--worker-done (_path status count)
"Track background extraction completions for progress reporting.
Registered on `vulpea-db-worker-done-functions' while autosync is
enabled. STATUS `stale' and `requeued' are terminal for the current
dispatch - their retry re-enters the queue and counts anew. COUNT is
unused beyond distinguishing applied results.
Emits the honest completion message: background sync complete only
fires when the last in-flight file has actually landed in the
database, not when it was dispatched."
(ignore count)
(when (> vulpea-db-sync--async-dispatched 0)
(pcase status
('applied (setq vulpea-db-sync--async-applied
(1+ vulpea-db-sync--async-applied)))
('unchanged (setq vulpea-db-sync--async-unchanged
(1+ vulpea-db-sync--async-unchanged)))
(_ nil))
(setq vulpea-db-sync--async-dispatched
(1- vulpea-db-sync--async-dispatched))