aboutsummaryrefslogtreecommitdiff
path: root/modules/org-refile-config.el
blob: a6b7ac3a46ea446b09439055a18cec5383aa1afe (plain)
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
;;; org-refile-config.el --- Org Refile Customizations -*- lexical-binding: t; coding: utf-8; -*-
;; author: Craig Jennings <c@cjennings.net>
;;; Commentary:
;;
;; Layer: 3 (Domain Workflow).
;; Category: D/S.
;; Load shape: eager.
;; Eager reason: daily refile workflow; the user expects refile targets ready at
;;   the first session.
;; Top-level side effects: an idle timer that builds the refile-target cache
;;   (guarded; spec tracks the cache lifecycle).
;; Runtime requires: system-lib, cj-cache-lib.
;; Direct test load: yes.
;;
;; Configuration and custom functions for org-mode refiling.
;;
;; Performance:
;; - Caches refile targets to avoid scanning 34,000+ files on every refile
;; - Cache builds asynchronously 5 seconds after Emacs startup (non-blocking)
;; - First refile uses cache if ready, otherwise builds synchronously (one-time delay)
;; - Subsequent refiles are instant (cached)
;; - Cache auto-refreshes after 1 hour
;; - Manual refresh: M-x cj/org-refile-refresh-targets (e.g., after adding projects)

;;; Code:

(require 'system-lib)
(require 'cj-cache-lib)

;; Forward-declare org-refile's dynamic var so byte-compiled code treats our
;; `let'/`setq' on it as dynamic.  Without this, compiling the module turns
;; `cj/org-refile-in-file's (let ((org-refile-targets ...)) ...) into a
;; lexical binding that never reaches `org-refile', silently breaking
;; in-file refiling under `make compile'.
(defvar org-refile-targets)

;; ----------------------------- Org Refile Targets ----------------------------
;; sets refile targets
;; - adds project files in org-roam to the refile targets
;; - adds todo.org files in subdirectories of the code and project directories

(defvar cj/--org-refile-targets-cache (cj/cache-make :ttl 3600)
  "Cache state for the refile targets list.  See `cj-cache.el'.")

(defun cj/org-refile-ensure-org-mode (file)
  "Ensure FILE is a .org file and its buffer is in org-mode.
Returns the buffer visiting FILE, switching it to org-mode if needed.
Signals an error if FILE doesn't have a .org extension.

This prevents issues where:
1. Buffers get stuck in fundamental-mode (e.g., opened before org loaded)
2. Non-.org files are accidentally added to refile targets"
  (unless (string-match-p "\\.org\\'" file)
    (error "Refile target \"%s\" is not a .org file" file))

  (let ((buf (org-get-agenda-file-buffer file)))
    (with-current-buffer buf
      (unless (derived-mode-p 'org-mode)
        (cj/log-silently "Switching %s to org-mode (was in %s)"
                         (buffer-name) major-mode)
        (org-mode)))
    buf))

(defun cj/--org-refile-scan-dir (dir)
  "Return the todo.org files under DIR, or nil with a warning if unusable.
A missing, unreadable, or permission-denied DIR is non-fatal: it logs a
`display-warning' and returns nil so the rest of the refile-target scan
continues, rather than silently swallowing the failure or crashing the
whole scan on a missing directory."
  (cond
   ((not (file-directory-p dir))
    (display-warning 'org-refile
                     (format "Refile scan: directory missing, skipped: %s" dir)
                     :warning)
    nil)
   ((not (file-readable-p dir))
    (display-warning 'org-refile
                     (format "Refile scan: directory unreadable, skipped: %s" dir)
                     :warning)
    nil)
   (t
    (condition-case _
        (directory-files-recursively
         dir "^[Tt][Oo][Dd][Oo]\\.[Oo][Rr][Gg]$" nil
         (lambda (d) (not (string-match-p "airootfs" d))))
      (permission-denied
       (display-warning 'org-refile
                        (format "Refile scan: permission denied, skipped: %s" dir)
                        :warning)
       nil)))))

(defun cj/--org-refile-scan-targets ()
  "Scan disk for the refile-targets list.  Pure-ish: no caching, no logging.
Returns the list to assign to `org-refile-targets'.  Slow -- walks
30,000+ files across `code-dir' and `projects-dir'."
  (let ((new-files
         (list
          (cons inbox-file     '(:maxlevel . 1))
          (cons reference-file '(:maxlevel . 2))
          (cons schedule-file  '(:maxlevel . 1)))))
    (when (and (fboundp 'cj/org-roam-list-notes-by-tag)
               (fboundp 'org-roam-node-list))
      (let* ((project-and-topic-files
              (append (cj/org-roam-list-notes-by-tag "Project")
                      (cj/org-roam-list-notes-by-tag "Topic")))
             (file-rule '(:maxlevel . 1)))
        (dolist (file project-and-topic-files)
          (unless (assoc file new-files)
            (push (cons file file-rule) new-files)))))
    (let ((file-rule '(:maxlevel . 1)))
      (dolist (dir (list user-emacs-directory code-dir projects-dir))
        (dolist (file (cj/--org-refile-scan-dir dir))
          (unless (assoc file new-files)
            (push (cons file file-rule) new-files)))))
    (nreverse new-files)))

(defun cj/build-org-refile-targets (&optional force-rebuild)
  "Build =org-refile-targets= with caching.

When FORCE-REBUILD is non-nil, bypass cache and rebuild from scratch.
Otherwise, returns cached targets if available and not expired.

This function scans 30,000+ files across code/projects directories,
so caching improves performance from 15-20 seconds to instant."
  (interactive "P")
  (when (cj/cache-building-p cj/--org-refile-targets-cache)
    (cj/log-silently "Waiting for background cache build to complete..."))
  (let* ((start-time (current-time))
         (targets
          (cj/cache-value-or-rebuild
           cj/--org-refile-targets-cache
           #'cj/--org-refile-scan-targets
           :force-rebuild force-rebuild
           :on-hit (lambda (v)
                     (cj/log-silently "Using cached refile targets (%d files)"
                                      (length v)))
           :on-build-success
           (lambda (v)
             (cj/log-silently "Built refile targets: %d files in %.2f seconds"
                              (length v)
                              (- (float-time) (float-time start-time)))))))
    (setq org-refile-targets targets)))

;; Build cache asynchronously after startup to avoid blocking Emacs.
(unless noninteractive
  (run-with-idle-timer
   5  ; Wait 5 seconds after Emacs is idle
   nil ; Don't repeat
   (lambda ()
     (cj/log-silently "Building org-refile targets cache in background...")
     (cj/build-org-refile-targets))))

(defun cj/org-refile-refresh-targets ()
  "Force rebuild of refile targets cache.

Use this after adding new projects or todo.org files.
Bypasses cache and scans all directories from scratch."
  (interactive)
  (cj/build-org-refile-targets 'force-rebuild))

(defun cj/org-refile (&optional ARG DEFAULT-BUFFER RFLOC MSG)
  "Call org-refile with cached refile targets.

Uses cached targets for performance (instant vs 15-20 seconds).
Cache auto-refreshes after 1 hour or on Emacs restart.

To manually refresh cache (e.g., after adding projects):
  M-x cj/org-refile-refresh-targets

ARG DEFAULT-BUFFER RFLOC and MSG parameters passed to org-refile."
  (interactive "P")
  ;; Use cached targets (don't rebuild every time!)
  (cj/build-org-refile-targets)
  (org-refile ARG DEFAULT-BUFFER RFLOC MSG))

;; ----------------------------- Org Refile In File ----------------------------
;; convenience function for scoping the refile candidates to the current buffer.

(defun cj/org-refile-in-file ()
  "Refile to a target within the current file and save the buffer."
  (interactive)
  (let ((org-refile-targets `(((,(buffer-file-name)) :maxlevel . 6))))
	(call-interactively 'org-refile)
	(save-buffer)))


;; --------------------------------- Org Refile --------------------------------

(use-package org-refile
  :ensure nil ;; built-in
  :defer .5
  :bind
  (:map org-mode-map
		("C-c C-w"   . cj/org-refile)
		("C-c w"     . cj/org-refile-in-file))
  :config
  ;; save all open org buffers after a refile is complete
  (advice-add 'org-refile :after
			  (lambda (&rest _)
				(org-save-all-org-buffers)))

  ;; Ensure refile target buffers are in org-mode before processing
  ;; Fixes issue where buffers opened before org loaded get stuck in fundamental-mode
  (advice-add 'org-refile-get-targets :before
              (lambda (&rest _)
                "Ensure all refile target buffers are in org-mode."
                (dolist (target org-refile-targets)
                  (let ((file (car target)))
                    (when (stringp file)
                      (cj/org-refile-ensure-org-mode file)))))))

(provide 'org-refile-config)
;;; org-refile-config.el ends here.