-
Notifications
You must be signed in to change notification settings - Fork 16
Expand file tree
/
Copy pathorg-node-seq.el
More file actions
740 lines (656 loc) · 31.5 KB
/
org-node-seq.el
File metadata and controls
740 lines (656 loc) · 31.5 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
;;; org-node-seq.el --- Extension for navigating arbitrary node sequences -*- lexical-binding: t; -*-
;; Copyright (C) 2024-2026 Martin Edström
;; SPDX-License-Identifier: GPL-3.0-or-later
;;; Commentary:
;; Strictly an extension (core does not depend on this file).
;; Support programmatically defining node-sequences, based on such things as
;; Org tags, time-stamps and file-names. Then support easily navigating them.
;; Trivia: Here was a failed rename. The concept was initally called "series",
;; not "seq", and in retrospect that was better name IMO as:
;; 1. It's less ambiguous, you don't have to clarify that you're talking
;; about a "node seq" as oppposed to some other kind of seq.
;; 2. The name doesn't beg to be abbreviated like "sequence" does.
;; Anyway, it's not a big enough deal to be worth changing back now.
;;; Code:
;; TODO: More API
;; - goto latest in a seq
;; - goto earliest
;; - list files in a seq
;; - get a seq w/o needing (alist-get "d" org-node-seqs () () #'equal)
(require 'subr-x)
(require 'cl-lib)
(require 'calendar)
(require 'transient)
(require 'org-node)
(require 'org-mem)
(require 'llama)
(require 'cond-let)
(defvar org-node-proposed-seq)
(defvar org-mem--next-message)
(declare-function org-entry-get-with-inheritance "org")
(declare-function org-up-heading-or-point-min "org")
;;;; Easy wrappers to define a sequence
;;;###autoload
(defun org-node-seq-def-on-any-sort-by-property
(key name prop &optional capture)
"Define a sequence sorted by property PROP.
If an ID-node does not have property PROP, it is excluded.
For KEY, NAME and CAPTURE, see `org-node-seq-defs'."
`(,key
:name ,name
:version 2
:capture ,capture
:classifier (lambda (node)
(let ((sortstr (org-mem-entry-property ,prop node)))
(when (and sortstr (not (string-blank-p sortstr)))
(cons (concat sortstr " " (org-mem-entry-title node))
(org-mem-entry-id node)))))
:whereami (lambda ()
(when-let ((sortstr (org-entry-get nil ,prop t))
(node (org-node-at-point)))
(concat sortstr " " (org-mem-entry-title node))))
:prompter (lambda (key)
(let ((seq (cdr (assoc key org-node-seqs))))
(completing-read "Go to: " (plist-get seq :sorted-items))))
:try-goto (lambda (item)
(org-node-seq-try-goto-id (cdr item)))
:creator (lambda (sortstr key)
(let ((adder (lambda () (org-entry-put nil ,prop sortstr))))
(add-hook 'org-node-creation-hook adder)
(unwind-protect (org-node-create sortstr (org-id-new) key)
(remove-hook 'org-node-creation-hook adder))))))
;;;###autoload
(defun org-node-seq-def-on-tags-sort-by-property
(key name tags prop &optional capture)
"Define a sequence filtered by TAGS sorted by property PROP.
If a node does not have property PROP, it is excluded.
TAGS is a string of tags separated by colons.
Tag inheritance does not apply; a node must have one or more of TAGS
itself, even if a parent in the outline tree also has them.
For KEY, NAME and CAPTURE, see `org-node-seq-defs'."
`(,key
:name ,name
:version 2
:capture ,capture
:classifier (lambda (node)
(let ((sortstr (org-mem-entry-property ,prop node))
(tagged (seq-intersection (split-string ,tags ":" t)
(org-mem-entry-tags-local node))))
(when (and sortstr tagged (not (string-blank-p sortstr)))
(cons (concat sortstr " " (org-mem-entry-title node))
(org-mem-entry-id node)))))
:whereami (lambda ()
(when (seq-intersection (split-string ,tags ":" t)
(org-get-tags))
(let ((sortstr (org-entry-get nil ,prop t))
(node (org-node-at-point)))
(when (and sortstr node)
(concat sortstr " " (org-mem-entry-title node))))))
:prompter (lambda (key)
(let ((seq (cdr (assoc key org-node-seqs))))
(completing-read "Go to: " (plist-get seq :sorted-items))))
:try-goto (lambda (item)
(org-node-seq-try-goto-id (cdr item)))
;; NOTE: The sortstr should not necessarily become the title, but we make
;; it so anyway, and the user can edit afterwards.
;; REVIEW: This should probably change, better to prompt for title. But
;; how?
:creator (lambda (sortstr key)
(let ((adder (lambda ()
(org-entry-put nil ,prop sortstr)
(org-node-add-tags (split-string ,tags ":" t)))))
(add-hook 'org-node-creation-hook adder)
(unwind-protect (org-node-create sortstr (org-id-new) key)
(remove-hook 'org-node-creation-hook adder))))))
;;;###autoload
(defun org-node-seq-def-on-filepath-sort-by-basename
(key name dir &optional capture date-picker)
"Define a sequence as all files under directory DIR.
The files need not contain a top-level property drawer, but
they do need to contain at least one ID-node.
For KEY, NAME and CAPTURE, see `org-node-seq-defs'.
When optional argument DATE-PICKER is non-nil, let the prompter use the
interactive Org date picker.
\(Tip: No idea how to use the Org date picker? See `org-read-date'!)
\(Tip: If you never make notes for the future, you might prefer setting
`org-read-date-prefer-future' to nil.)
For the date-picker to work as expected, you need file names in
YYYY-MM-DD format, e.g. \"2024-01-31.org\"."
(cl-assert (file-name-absolute-p dir))
(setq dir (file-truename dir))
`(,key
:name ,name
:version 2
:capture ,capture
:classifier (lambda (node)
(when (string-prefix-p ,(file-truename dir)
(org-mem-entry-file-truename node))
(let* ((path (org-mem-entry-file node))
(sortstr (file-name-base path)))
(cons sortstr path))))
:whereami (lambda ()
(let ((file-name (org-mem-file-known-p buffer-file-name)))
(when (string-prefix-p ,dir file-name)
(file-name-base file-name))))
:prompter (lambda (key)
(if ,date-picker
(let ((org-node-seq-that-marks-calendar key))
(org-read-date))
(let ((seq (cdr (assoc key org-node-seqs))))
(completing-read "Go to: " (plist-get seq :sorted-items)))))
:try-goto (lambda (item)
(org-node-seq-try-goto-file (cdr item)))
:creator (lambda (sortstr key)
(let ((org-node-creation-fn #'org-node-new-file)
(org-node-file-directory-ask ,dir))
(org-node-create sortstr (org-id-new) key)))))
;;;; Helpers to use in a seq definition
;;;###autoload
(defun org-node-seq-try-goto-id (id)
"Try to visit org-id ID and return non-nil, else return nil."
(let ((node (org-mem-entry-by-id id)))
(when node
(org-node-goto node)
t)))
;;;###autoload
(defun org-node-seq-try-goto-file (file)
"If FILE exists or a buffer has it as filename, visit that.
On success, return non-nil; else nil. Never create FILE anew."
(let ((buf (find-buffer-visiting file)))
(if buf
(switch-to-buffer buf)
(when (file-readable-p file)
(find-file file)))))
;;;###autoload
(defun org-node-seq-filename->ymd (path)
"Check the filename PATH for a date, and return that date.
On failing to coerce a date, return nil.
Only works for names starting with either an YYYY-MM-DD date, or a
datestamp matching the style of `org-node-file-timestamp-format'.
The latter uses a sloppy algorithm so not all formats work, see
`org-node-seq-extract-ymd'."
(when path
(let ((clipped-name (file-name-base path)))
(if (string-match (rx bol (= 4 digit) "-" (= 2 digit) "-" (= 2 digit))
clipped-name)
(match-string 0 clipped-name)
;; Even in a non-daily file, pretend it is a daily if possible,
;; to allow entering the sequence at a more relevant date
(when-let ((stamp (org-node-extract-file-name-datestamp path)))
(org-node-seq-extract-ymd stamp org-node-file-timestamp-format))))))
;; TODO: Handle %s, %V, %y... is there a library?
;;;###autoload
(defun org-node-seq-extract-ymd (instance time-format)
"Try to extract a YYYY-MM-DD date out of string INSTANCE.
Assume INSTANCE is a string produced by TIME-FORMAT, e.g. if
TIME-FORMAT is %Y%m%dT%H%M%SZ then a possible INSTANCE is
20240814T123307Z. In that case, return 2024-08-14.
Will throw an error if TIME-FORMAT does not include either %F or
all three of %Y, %m and %d. May return odd results if other
format-constructs occur before these."
(let ((verify-re (org-node--make-regexp-for-time-format time-format)))
(when (string-match-p verify-re instance)
(let ((case-fold-search nil))
(let ((pos-year (string-search "%Y" time-format))
(pos-month (string-search "%m" time-format))
(pos-day (string-search "%d" time-format))
(pos-ymd (string-search "%F" time-format)))
(if (seq-some #'null (list pos-year pos-month pos-day))
(progn (cl-assert pos-ymd)
(substring instance pos-ymd (+ pos-ymd 10)))
;; TODO: Explanatory comment please
(when (> pos-month pos-year) (cl-incf pos-month 2))
(when (> pos-day pos-year) (cl-incf pos-day 2))
(concat (substring instance pos-year (+ pos-year 4))
"-"
(substring instance pos-month (+ pos-month 2))
"-"
(substring instance pos-day (+ pos-day 2)))))))))
(defun org-node-seq-create-roam-daily (ymd seq-key &optional goto keys)
"Create an org-roam-dailies note, for a day implied by YMD.
YMD must be a time string in YYYY-MM-DD form.
SEQ-KEY is the key that corresponds to the member of `org-node-seq-defs'
that should grow with the captured item after the capture is done.
GOTO and KEYS like in `org-roam-dailies--capture'."
(unless (require 'org-roam-dailies nil t)
(error "`org-node-seq-create-roam-daily' requires library \"org-roam\""))
(when (fboundp 'org-roam-dailies--capture)
(let ((creation-hook-runner (lambda () (run-hooks 'org-node-creation-hook))))
(add-hook 'org-roam-capture-new-node-hook creation-hook-runner)
(setq org-node-proposed-seq seq-key)
(unwind-protect
(org-roam-dailies--capture
(encode-time
(parse-time-string (concat ymd (format-time-string " %T %z"))))
goto keys)
(remove-hook 'org-roam-capture-new-node-hook creation-hook-runner)
(setq org-node-proposed-seq nil)))))
;;;; Plumbing
(defcustom org-node-seq-defs nil
"Alist defining each node sequence.
Each item looks like
\(KEY :name NAME
:classifier CLASSIFIER
:whereami WHEREAMI
:prompter PROMPTER
:try-goto TRY-GOTO
:creator CREATOR
:capture CAPTURE
:version VERSION)
KEY uniquely identifies the sequence, and is the key to type after
\\[org-node-seq-dispatch] to select it. It may not be \"j\",
\"n\", \"p\" or \"c\", these keys are reserved for
Jump/Next/Previous/Capture actions.
NAME describes the sequence, in one or a few words.
CLASSIFIER is a single-argument function taking an `org-mem-entry' object
and should return a list or cons cell if a sequence-item was found,
otherwise nil.
The list or cons cell may contain anything, but the first element must
be a sort-string, i.e. a string suitable for sorting on. An example is
a date in the format YYYY-MM-DD, but not in the format MM/DD/YY.
This is what determines the order of items in the sequence: after
all nodes have been processed by CLASSIFIER, the items found
are sorted by the sort-string, using `string>'.
Aside from returning a single item, CLASSIFIER may also return a list of
multiple such items. This can be useful if e.g. you have a special type
of node that \"defines\" a sequence by simply containing links to each
item that should go into it.
Function PROMPTER may be used during jump/capture/refile to
interactively prompt for a sort-string. This highlights the
other use of the sort-string: finding our way back from scant
context.
For example, in a sequence of daily-notes sorted on YYYY-MM-DD,
a prompter could use `org-read-date'.
PROMPTER receives one argument: the whole node-seq object, which is a
plist with the same form as one of the values in `org-node-seq-defs' but
includes two extra members :key, corresponding to KEY, and
:sorted-items, which may be useful for interactive completion.
Function WHEREAMI is like PROMPTER in that it should return a
sort-string or nil. However, it should do this without user
interaction, and may return nil. For example, if the user is not
currently in a daily-note, the daily-notes\\=' WHEREAMI should
return nil. It receives no arguments.
Function TRY-GOTO takes a single argument: one of the items
originally created by CLASSIFIER. That is, a list of not only a
sort-string but any associated data you put in. If TRY-GOTO
succeeds in using this information to visit a place of interest,
it should return non-nil, otherwise nil. It should not create or
write anything on failure - reserve that for the CREATOR
function.
Function CREATOR creates a place that did not exist. For
example, if the user picked a date from `org-read-date' but no
daily-note exists for that date, CREATOR is called to create that
daily-note. It receives a would-be sort-string as argument.
Optional string CAPTURE indicates the keys to a capture template
to autoselect, when you choose the capture option in the
`org-node-seq-dispatch' menu.
Integer VERSION indicates the definition language used for this
variable. New sequences should use version 2, as of 2024-09-05. When
org-node updates the seq definition language, old versions may
still work, but this is not heavily tested, so it will start printing a
message to remind you to check out the wiki on GitHub and port your
definitions."
:type 'alist
:group 'org-node
:package-version '(org-node . "1.9.0")
:set #'org-node--set-and-remind-reset)
;;;###autoload
(defvar org-node-seqs nil
"Alist of data for each node sequence.")
(defun org-node-seq--add-item (&optional key)
"Analyze node near point to maybe grow a node sequence.
The sequence is identified either by KEY, or if that is nil, by the
current value of `org-node-proposed-seq'. If that is also nil, do
nothing."
(when (bound-and-true-p org-node-proposed-sequence)
;; Old name existed 1.9.0 -> 2.0.0
;; but this warning added later
(display-warning
'org-node "Variable org-node-proposed-sequence should be org-node-proposed-seq, check initfiles"))
(when (or key org-node-proposed-seq)
(let* ((seq (cdr (assoc (or key org-node-proposed-seq)
org-node-seqs)))
(node-here (org-mem-entry-by-id
(org-entry-get-with-inheritance "ID")))
(new-item (when node-here
(funcall (plist-get seq :classifier) node-here))))
(when new-item
(unless (member new-item (plist-get seq :sorted-items))
(push new-item (plist-get seq :sorted-items))
(sort (plist-get seq :sorted-items)
(lambda (item1 item2)
(string> (car item1) (car item2)))))))))
;; TODO: Could the :creator just check if the buffer is unmodified and empty?
;; But possible pitfall that the user may have important stuff in undo.
(defun org-node-seq--kill-blank-unsaved-buffers (&rest _)
"Kill buffers created by org-node that have always been blank.
This exists to allow you to create a node, especially a journal note for
today via package \"org-node-seq\", change your mind, do an `undo' to
empty the buffer, then browse to the previous day\\='s note. When later
you want to create today\\='s note after all, the seq\\='s :creator
function should be made to run again, but it will not do so if the
buffer already exists, so the buffer stays blank. Thus this hook."
(unless (minibufferp)
(dolist (buf org-node--new-unsaved-buffers)
(if (or (not (buffer-live-p buf))
(file-exists-p (buffer-file-name buf)))
;; Stop checking the buffer
(setq org-node--new-unsaved-buffers
(delq buf org-node--new-unsaved-buffers))
(with-current-buffer buf
(when (and (not (get-buffer-window buf t))
(not (buffer-modified-p))
(string-blank-p (buffer-string)))
(when buffer-auto-save-file-name
;; Hopefully throw away a stale autosave
;; since its existence annoys the user on re-creating the file
(do-auto-save nil t))
(kill-buffer buf)))))))
(defun org-node-seq--jump (key)
"Prompt for and jump to an entry in node seq identified by KEY."
(org-node-seq--kill-blank-unsaved-buffers)
(require 'org)
(let* ((seq (cdr (assoc key org-node-seqs)))
(sortstr (funcall (plist-get seq :prompter) key))
(item (assoc sortstr (plist-get seq :sorted-items))))
(if item
(unless (funcall (plist-get seq :try-goto) item)
(delete item (plist-get seq :sorted-items))
(funcall (plist-get seq :creator) sortstr key))
(funcall (plist-get seq :creator) sortstr key))))
(defun org-node-seq--goto-next (key)
"Visit the next entry in node seq identified by KEY."
(org-node-seq--goto-previous key t))
(defun org-node-seq--goto-previous (key &optional next)
"Visit the previous entry in node seq identified by KEY.
With non-nil argument NEXT, visit the next entry, not previous."
(let* ((seq (cdr (assoc key org-node-seqs)))
(tail (plist-get seq :sorted-items))
head
here)
(unless tail
(error "No items in sequence \"%s\"" (plist-get seq :name)))
;; Depending on the design of the :whereami lambda, being in a sub-heading
;; may block discovering that a parent heading is a member of the sequence,
;; so re-try until the top level
(when (derived-mode-p 'org-mode)
(save-excursion
(without-restriction
(while (and (not (setq here (funcall (plist-get seq :whereami))))
(org-up-heading-or-point-min))))))
(when (or (when here
;; Find our location in the sequence
(cl-loop for item in tail
while (string> (car item) here)
do (push (pop tail) head))
(when (equal here (caar tail))
(pop tail)
;; Opportunistically clean up duplicate keys
(while (equal here (caar tail))
(setcar tail (cadr tail))
(setcdr tail (cddr tail))))
t)
(when (y-or-n-p
(format "Not in \"%s\". Jump to latest in that sequence?"
(plist-get seq :name)))
(setq head (take 1 tail))
t))
;; Usually this should return on the first try, but sometimes stale
;; items refer to something that has been erased from disk, so
;; deregister each item that TRY-GOTO failed to visit, and try again.
(cl-loop for item in (if next head tail)
if (funcall (plist-get seq :try-goto) item)
return t
else do (delete item (plist-get seq :sorted-items))
finally do (message "No %s item in sequence \"%s\""
(if next "next" "previous")
(plist-get seq :name))))))
(defvar org-node-seq--current-key nil
"Key identifying the node seq currently being browsed in dispatch.
Unlike `org-node-proposed-seq', does not need to revert to nil.")
(defun org-node-seq-capture-target ()
"Experimental."
(org-node-cache-ensure)
(org-node-seq--kill-blank-unsaved-buffers)
(let ((key (or org-node-seq--current-key
(let* ((valid-keys (mapcar #'car org-node-seq-defs))
(elaborations
(cl-loop for seq in org-node-seq-defs
concat
(format " %s(%s)"
(car seq)
(plist-get (cdr seq) :name))))
(input (read-char-from-minibuffer
(format "Press any of [%s] to capture into sequence: %s "
(string-join valid-keys ",")
elaborations)
(mapcar #'string-to-char valid-keys))))
(char-to-string input)))))
;; Almost identical to `org-node-seq--jump'
(require 'org)
(let* ((seq (cdr (assoc key org-node-seqs)))
(sortstr (or org-node-proposed-title
(funcall (plist-get seq :prompter) key)))
(item (assoc sortstr (plist-get seq :sorted-items))))
(when (or (null item)
(not (funcall (plist-get seq :try-goto) item)))
;; TODO: Move point after creation to most appropriate place
(funcall (plist-get seq :creator) sortstr key)))))
(defun org-node-seq--build-from-def (def)
"From DEF, make a plist for `org-node-seqs'.
DEF is a seq-def from `org-node-seq-defs'."
(unless (plist-get (cdr def) :version)
(user-error "Seq def :version must be 2 or higher"))
(let ((classifier (org-node--ensure-compiled
(plist-get (cdr def) :classifier))))
(nconc
(cl-loop for elt in (cdr def)
if (functionp elt)
collect (org-node--ensure-compiled elt)
else collect elt)
(cl-loop for node in (org-node-all-filtered-nodes)
as result = (funcall classifier node)
if (listp (car result))
nconc result into items
else collect result into items
finally return
;; Sort by `string>' due to most recent dailies probably being
;; most relevant, thus cycling recent dailies will be best perf.
(list :key (car def)
:sorted-items (delete-consecutive-dups
(cl-sort items #'string> :key #'car)))))))
(defcustom org-node-seq-that-marks-calendar nil
"Key for the node sequence that should mark days in the calendar.
This affects the appearance of the `org-read-date' calendar
popup. For example, you can use it to indicate which days have a
daily-journal entry.
This need usually not be customized! When you use
`org-node-seq-dispatch' to jump to a daily-note or some
other date-based sequence, that sequence may be designed to
temporarily set this variable.
Customize this mainly if you want a given node seq to always be
indicated, any time Org pops up a calendar for you.
The sort-strings in the node seq for this key
should be correctly parseable by `parse-time-string'."
:group 'org-node
:package-version '(org-node . "1.9.0")
:type '(choice key (const nil)))
;; TODO: How to cooperate with preexisting marks?
(defun org-node-seq--mark-days ()
"Mark days in the calendar popup.
The user option `org-node-seq-that-marks-calendar' controls
which dates to mark.
Meant to sit on these hooks:
- `calendar-today-invisible-hook'
- `calendar-today-visible-hook'"
(when (bound-and-true-p org-node-series-that-marks-calendar)
;; Old name existed until 1.9.0
(display-warning
'org-node "Variable org-node-series-that-marks-calendar should be org-node-seq-that-marks-calendar, check initfiles"))
(when org-node-seq-that-marks-calendar
(calendar-unmark)
(let* ((seq (cdr (assoc org-node-seq-that-marks-calendar
org-node-seqs)))
(sortstrs (mapcar #'car (plist-get seq :sorted-items)))
mdy)
(dolist (date sortstrs)
;; Use `parse-time-string' rather than `iso8601-parse' to fail quietly
(let ((decoded-time (parse-time-string date)))
(setq mdy (seq-let (_ _ _ d m y) decoded-time
(list m d y)))
(when (seq-every-p #'natnump mdy)
(when (calendar-date-is-visible-p mdy)
(calendar-mark-visible-date mdy))))))))
;;;###autoload
(define-minor-mode org-node-seq-mode
"Populate sequences according to `org-node-seq-defs'.
This permits \\[org-node-seq-dispatch] to work."
:global t
:group 'org-node
(if org-node-seq-mode
(progn
(org-node-cache-ensure t)
(org-node-seq--reset)
(add-hook 'org-node-creation-hook #'org-node-seq--add-item)
(add-hook 'org-mem-post-full-scan-functions #'org-node-seq--reset 50)
;; Put ourselves in front of org-roam-dailies unhygienic hook.
(add-hook 'calendar-today-invisible-hook #'org-node-seq--mark-days 5)
(add-hook 'calendar-today-visible-hook #'org-node-seq--mark-days 5))
(remove-hook 'org-node-creation-hook #'org-node-seq--add-item)
(remove-hook 'org-mem-post-full-scan-functions #'org-node-seq--reset)
(remove-hook 'calendar-today-invisible-hook #'org-node-seq--mark-days)
(remove-hook 'calendar-today-visible-hook #'org-node-seq--mark-days)))
(defvar org-node-seq--auto-enabled-once nil)
;; Not used inside this package; a convenience for end users.
(defun org-node-seq-goto (key sortstr)
"Visit an entry in sequence identified by KEY.
The entry to visit has sort-string SORTSTR. Create if it does
not exist."
(when (not (assoc key org-node-seqs))
(if org-node-seq-defs
(if org-node-seq-mode
(error "No seq for key \"%s\", maybe do M-x org-mem-reset?" key)
(if org-node-seq--auto-enabled-once
(user-error "`org-node-seq-mode' not enabled")
(setq org-node-seq--auto-enabled-once t)
(org-node-seq-mode)))
(error "`org-node-seq-defs' not configured for key \"%s\"" key)))
(let* ((seq (cdr (assoc key org-node-seqs)))
(item (assoc sortstr (plist-get seq :sorted-items))))
(require 'org)
;; TODO: When `item' not found, still run :try-goto and pass it a
;; list that contains only `sortstr', so it has a shot at finding a
;; pre-existing file/id even if `org-node-seqs' is not up to date
;; for some reason.
;; Would be nice, but we'd need to be able to assume that any
;; :try-goto in the wild can handle being passed such input, so
;; we first need to update the definition language and document
;; the new constraint.
(when (or (null item)
(if (funcall (plist-get seq :try-goto) item)
nil
(delete item (plist-get seq :sorted-items))
t))
;; FIXME: Sometimes we hit an error here if :creator tries to create a
;; file that already exists -- :try-goto failed because the file
;; contains no IDs so :classifier did not create an item for it.
(funcall (plist-get seq :creator) sortstr key))))
(defun org-node-seq--reset (&optional _)
"Wipe and re-build all seqs."
(setq org-node-seqs nil)
(let ((T (current-time)))
(dolist (def org-node-seq-defs)
(setf (alist-get (car def) org-node-seqs () () #'equal)
(org-node-seq--build-from-def def))
;; TODO: Clear any old seq from menu
(org-node-seq--add-to-dispatch (car def) (plist-get (cdr def) :name)))
(when (and org-mem--next-message
org-node-seqs)
(setq org-mem--next-message
(concat org-mem--next-message
(format " + %.2fs caching org-node-seqs"
(float-time (time-since T))))))))
;;;; Transient menu
(defun org-node-seq--add-to-dispatch (key name)
"Use KEY and NAME to add a sequence to the Transient menu."
(when (ignore-errors (transient-get-suffix 'org-node-seq-dispatch key))
(transient-remove-suffix 'org-node-seq-dispatch key))
(transient-append-suffix 'org-node-seq-dispatch '(0 -1)
(list key name key))
;; Make the switches mutually exclusive
(let ((old (car (slot-value (get 'org-node-seq-dispatch 'transient--prefix)
'incompatible))))
(setf (slot-value (get 'org-node-seq-dispatch 'transient--prefix)
'incompatible)
(list (seq-uniq (cons key old))))))
(transient-define-suffix org-node-seq--goto-previous* (args)
(interactive (list (transient-args 'org-node-seq-dispatch)))
(if args
(org-node-seq--goto-previous (car args))
(message "Choose sequence before navigating")))
(transient-define-suffix org-node-seq--goto-next* (args)
(interactive (list (transient-args 'org-node-seq-dispatch)))
(if args
(org-node-seq--goto-next (car args))
(message "Choose sequence before navigating")))
(transient-define-suffix org-node-seq--jump* (args)
(interactive (list (transient-args 'org-node-seq-dispatch)))
(if args
(org-node-seq--jump (car args))
(message "Choose sequence before navigating")))
(transient-define-suffix org-node-seq--capture (args)
(interactive (list (transient-args 'org-node-seq-dispatch)))
(if args
(progn (setq org-node-seq--current-key (car args))
(unwind-protect
(let* ((seq (cdr (assoc (car args) org-node-seqs)))
(capture-keys (plist-get seq :capture)))
(if capture-keys
(org-capture nil capture-keys)
(message "No capture template for sequence: %s"
(plist-get seq :name))))
(setq org-node-seq--current-key nil)))
(message "Choose sequence before navigating")))
;; TODO: In Emacs 30, simplify to just ###autoload.
;;;###autoload (autoload 'org-node-seq-dispatch "org-node-seq" nil t)
(transient-define-prefix org-node-seq-dispatch ()
["Sequence"
;; TODO: Don't use the invisible placeholder.
;; https://github.com/magit/transient/issues/49#issuecomment-2289762426
("|" "Invisible" "Placeholder" :if-nil t)]
["Navigation"
("p" "Previous in sequence" org-node-seq--goto-previous* :transient t)
("n" "Next in sequence" org-node-seq--goto-next* :transient t)
("j" "Jump (or create)" org-node-seq--jump*)
("c" "Capture into" org-node-seq--capture)]
(interactive)
(when org-node-seq-defs
(unless (or org-node-seq--auto-enabled-once
org-node-seq-mode)
(setq org-node-seq--auto-enabled-once t)
(org-node-seq-mode)))
(cond ((not org-node-seq-defs)
(message "`org-node-seq-defs' not defined"))
((not org-node-seq-mode)
(message "`org-node-seq-mode' not enabled"))
((not org-node-seqs)
(org-node-seq--reset)
(transient-setup 'org-node-seq-dispatch))
(t
(transient-setup 'org-node-seq-dispatch))))
(define-obsolete-function-alias 'org-node-seq-try-visit-file
#'org-node-seq-try-goto-file "3.0.0 (May 2025)")
(provide 'org-node-seq)
;;; org-node-seq.el ends here
;; Local Variables:
;; checkdoc-spellcheck-documentation-flag: nil
;; checkdoc-verb-check-experimental-flag: nil
;; emacs-lisp-docstring-fill-column: 72
;; read-symbol-shorthands: (("and$" . "cond-let--and$")
;; ("and>" . "cond-let--and>")
;; ("and-let" . "cond-let--and-let")
;; ("if-let" . "cond-let--if-let")
;; ("when$" . "cond-let--when$")
;; ("when-let" . "cond-let--when-let")
;; ("while-let" . "cond-let--while-let"))
;; End: