aboutsummaryrefslogtreecommitdiff
path: root/modules/org-capture-config.el
blob: 393f1d97b36a5966b21ee560a862107181caf2a5 (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
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
;;; org-capture-config.el --- Org Capture Configuration  -*- lexical-binding: t; coding: utf-8; -*-
;; author: Craig Jennings <c@cjennings.net>

;;; Commentary:
;;
;; Layer: 3 (Domain Workflow).
;; Category: D/P.
;; Load shape: eager.
;; Eager reason: capture is a daily hot path; org-protocol capture handlers must
;;   be registered for external capture to work.
;; Top-level side effects: capture templates and org-protocol handlers via
;;   use-package.
;; Runtime requires: none (configures packages via use-package).
;; Direct test load: yes.
;;
;; Customizations related to org-capture and org-refile.
;; Includes capture templates for tasks, links, PDFs, EPUBs, emails, and drill questions.

;; bookmarklet code:
;; text
;; javascript:location.href='org-protocol://capture?template=L&url=%27+encodeURIComponent(location.href)+%27&title=%27+encodeURIComponent(document.title)+%27&body=%27+encodeURIComponent(window.getSelection())

;; text + selection
;; javascript:location.href='org-protocol://capture?template=p&url=%27+encodeURIComponent(location.href)+%27&title=%27+encodeURIComponent(document.title)+%27&body=%27+encodeURIComponent(window.getSelection())

;;; Code:

(defvar org-capture-plist)
(defvar org-capture-templates)
(defvar org-complex-heading-regexp-format)

(declare-function cj/--drill-pick-file "org-drill-config")
(declare-function org-at-encrypted-entry-p "org-crypt")
(declare-function org-at-heading-p "org")
(declare-function org-back-to-heading "org")
(declare-function org-capture-expand-file "org-capture")
(declare-function org-capture-get "org-capture")
(declare-function org-capture-put "org-capture")
(declare-function org-capture-put-target-region-and-position "org-capture")
(declare-function org-capture-target-buffer "org-capture")
(declare-function org-display-warning "org")
(declare-function org-get-heading "org")
(declare-function org-parse-time-string "org")
(declare-function pdf-view-active-region-text "pdf-view")
(declare-function projectile-project-root "projectile" (&optional dir))
(defvar inbox-file)

(defvar cj/org-capture--file-headline-target-cache (make-hash-table :test #'equal)
  "Cache Org capture file+headline target markers by expanded file and headline.")

(defun cj/org-capture-clear-target-cache ()
  "Clear cached Org capture target markers."
  (interactive)
  (clrhash cj/org-capture--file-headline-target-cache)
  (message "Cleared org-capture target cache"))

(defun cj/org-capture--file-headline-target-p (target)
  "Return non-nil when TARGET is an Org capture file+headline target."
  (pcase target
    (`(file+headline ,_path ,(and _headline (pred stringp))) t)
    (_ nil)))

(defun cj/org-capture--headline-marker-valid-p (marker headline)
  "Return non-nil when MARKER still points at HEADLINE."
  (and (markerp marker)
       (marker-buffer marker)
       (buffer-live-p (marker-buffer marker))
       (with-current-buffer (marker-buffer marker)
         (save-excursion
           (goto-char marker)
           (and (derived-mode-p 'org-mode)
                (org-at-heading-p)
                (string= (org-get-heading t t t t) headline))))))

(defun cj/org-capture--file-headline-cache-key (path headline)
  "Return the cache key for PATH and HEADLINE."
  (list (org-capture-expand-file path) headline))

(defun cj/org-capture--goto-file-headline (path headline)
  "Move to capture target PATH and HEADLINE, using a cached marker when valid.
This implements Org's `file+headline' target positioning behavior, but avoids
re-scanning large target files after the first successful lookup."
  (set-buffer (org-capture-target-buffer path))
  ;; Org expects the target file to be in Org mode, otherwise it throws an
  ;; error.  Match Org's stock file+headline behavior here.
  (unless (derived-mode-p 'org-mode)
    (org-display-warning
     (format "Capture requirement: switching buffer %S to Org mode"
             (current-buffer)))
    (org-mode))
  (org-capture-put-target-region-and-position)
  (widen)
  (let* ((key (cj/org-capture--file-headline-cache-key path headline))
         (marker (gethash key cj/org-capture--file-headline-target-cache)))
    (if (cj/org-capture--headline-marker-valid-p marker headline)
        (goto-char marker)
      (goto-char (point-min))
      (if (re-search-forward (format org-complex-heading-regexp-format
                                      (regexp-quote headline))
                              nil t)
          (forward-line 0)
        (goto-char (point-max))
        (unless (bolp) (insert "\n"))
        (insert "* " headline "\n")
        (forward-line -1))
      (puthash key (copy-marker (point))
               cj/org-capture--file-headline-target-cache))))

(defun cj/org-capture--set-file-headline-target-location (target)
  "Set Org capture target location for file+headline TARGET."
  (pcase target
    (`(file+headline ,path ,headline)
     (let ((target-entry-p t))
       (save-excursion
         (cj/org-capture--goto-file-headline path headline)
         (org-capture-put :buffer (current-buffer)
                          :pos (point)
                          :target-entry-p target-entry-p
                          :decrypted
                          (and (featurep 'org-crypt)
                               (org-at-encrypted-entry-p)
                               (save-excursion
                                 (org-decrypt-entry)
                                 (and (org-back-to-heading t) (point))))))))))

(defun cj/org-capture--set-target-location-advice (orig-fun &optional target)
  "Use cached target lookup around ORIG-FUN for file+headline capture targets."
  (let ((resolved-target (or target (org-capture-get :target))))
    (if (cj/org-capture--file-headline-target-p resolved-target)
        (cj/org-capture--set-file-headline-target-location resolved-target)
      (funcall orig-fun target))))

(with-eval-after-load 'org-capture
  (advice-add 'org-capture-set-target-location
              :around #'cj/org-capture--set-target-location-advice))

;; ----------------------- Project-Aware Capture Target ------------------------
;; C-c c t (Task) and C-c c b (Bug) file into the current projectile project's
;; todo.org under its "... Open Work" heading.  Outside a project they fall back
;; to the global inbox; in a project with no todo.org they fall back to the
;; inbox with a warning (they never create a project's todo.org).

(defconst cj/--org-open-work-heading-regexp
  "^\\*[ \t]+.*Open Work\\(?:[ \t]+:[^\n]*:\\)?[ \t]*$"
  "Regexp matching a top-level \"... Open Work\" Org heading line.")

(defun cj/--org-capture-project-name (root)
  "Return a display project name for ROOT directory, or nil.
The basename of ROOT with a single leading dot stripped and the first
letter upcased: \"~/.emacs.d/\" -> \"Emacs.d\", \"~/code/duet/\" -> \"Duet\"."
  (when (and (stringp root) (not (string-empty-p root)))
    (let* ((base (file-name-nondirectory (directory-file-name root)))
           (clean (if (and (> (length base) 1) (eq ?. (aref base 0)))
                      (substring base 1)
                    base)))
      (and (not (string-empty-p clean))
           (concat (upcase (substring clean 0 1)) (substring clean 1))))))

(defun cj/--org-capture-project-target (root inbox)
  "Pure capture-target decision for project-aware capture.
ROOT is the projectile project root (or nil); INBOX is the global inbox
file path.  Return a plist (:file F :open-work BOOL :project NAME :warn MSG):
- ROOT with a todo.org -> F is that todo.org, :open-work t.
- ROOT without a todo.org -> F is INBOX, :open-work nil, :warn names the project.
- ROOT nil -> F is INBOX, :open-work nil, :warn nil."
  (if (and (stringp root) (not (string-empty-p root)))
      (let ((todo (expand-file-name "todo.org" root))
            (name (cj/--org-capture-project-name root)))
        (if (file-exists-p todo)
            (list :file todo :open-work t :project name :warn nil)
          (list :file inbox :open-work nil :project name
                :warn (format "No todo.org in project \"%s\"; captured to the inbox instead"
                              name))))
    (list :file inbox :open-work nil :project nil :warn nil)))

(defun cj/--org-capture-goto-open-work (project-name)
  "Move point to a top-level \"... Open Work\" heading in the current buffer.
Create \"* PROJECT-NAME Open Work\" at end of buffer when none exists.
Leave point at the start of the heading line."
  (goto-char (point-min))
  (if (re-search-forward cj/--org-open-work-heading-regexp nil t)
      (forward-line 0)
    (goto-char (point-max))
    (unless (bolp) (insert "\n"))
    (insert (format "* %s Open Work\n" project-name))
    (forward-line -1)))

(defun cj/--org-capture-goto-exact-headline (headline)
  "Move point to the top-level HEADLINE in the current buffer.
Create \"* HEADLINE\" at end of buffer when absent.  Leave point at the
start of the heading line."
  (goto-char (point-min))
  (if (re-search-forward (format org-complex-heading-regexp-format
                                 (regexp-quote headline))
                         nil t)
      (forward-line 0)
    (goto-char (point-max))
    (unless (bolp) (insert "\n"))
    (insert "* " headline "\n")
    (forward-line -1)))

(defun cj/--org-capture-project-location ()
  "Org-capture `function' target for project-aware Task/Bug capture.
File into the current projectile project's todo.org under its \"... Open
Work\" heading, else the global inbox (`inbox-file') under \"Inbox\"."
  (let* ((root (and (fboundp 'projectile-project-root)
                    (ignore-errors (projectile-project-root))))
         (plan (cj/--org-capture-project-target root inbox-file)))
    (when (plist-get plan :warn)
      (message "%s" (plist-get plan :warn)))
    (set-buffer (org-capture-target-buffer (plist-get plan :file)))
    (unless (derived-mode-p 'org-mode) (org-mode))
    (org-capture-put-target-region-and-position)
    (widen)
    (if (plist-get plan :open-work)
        (cj/--org-capture-goto-open-work (plist-get plan :project))
      (cj/--org-capture-goto-exact-headline "Inbox"))))

;; --------------------------- Org-Capture Templates ---------------------------
;; you can bring up the org capture menu with C-c c

(use-package org-protocol
  :ensure nil ;; built-in
  :after org
  :config

  ;; -------------------------- Event Capture Formatting -------------------------

  (defun cj/org-capture--date-prefix (timestamp)
    "Return \"YY-MM-DD: \" prefix from org TIMESTAMP string, or nil if unparseable."
    (when-let ((parsed (ignore-errors (org-parse-time-string timestamp))))
      (let ((year (nth 5 parsed))
            (month (nth 4 parsed))
            (day (nth 3 parsed)))
        (when (and year month day
                   (> year 0) (> month 0) (> day 0))
          (format "%02d-%02d-%02d: "
                  (mod year 100) month day)))))

  (defun cj/org-capture-format-event-headline ()
    "Format the event headline with YY-MM-DD prefix from the WHEN timestamp.
This function is called during `org-capture' finalization to prepend the date
to the event title for better organization in the schedule file."
    (when (string= (plist-get org-capture-plist :key) "e")
      (save-excursion
        (goto-char (point-min))
        (when (re-search-forward "^WHEN: \\(<[^>]+>\\)" nil t)
          (let ((date-prefix (cj/org-capture--date-prefix (match-string 1))))
            (when date-prefix
              (goto-char (point-min))
              (when (looking-at "^\\(\\*+ \\)\\(.*\\)$")
                (replace-match (concat "\\1" date-prefix "\\2")))))))))

  (defun cj/org-capture-event-content ()
    "Get the appropriate content for event capture based on context.
Returns the selected text from either Emacs or browser (via org-protocol)
formatted appropriately for insertion into the capture template."
    (cond
     ;; If called from org-protocol (browser), get the initial from org-store-link-plist
     ((and (boundp 'org-store-link-plist)
           org-store-link-plist
           (let ((val (plist-get org-store-link-plist :initial)))
             (and (stringp val) (not (string-empty-p val)))))
      (concat "\n" (plist-get org-store-link-plist :initial)))
     ;; If there's a selected region in Emacs, use it from capture plist
     ((and (stringp (plist-get org-capture-plist :initial))
           (not (string= (plist-get org-capture-plist :initial) "")))
      (concat "\n" (plist-get org-capture-plist :initial)))
     ;; Otherwise, return empty string
     (t "")))

  ;; ----------------------- Org Capture PDF Active Region -----------------------

  (defun cj/org-capture-pdf-active-region ()
    "Capture the active region of the pdf-view buffer.

Intended to be called within an org capture template."
    (let* ((pdf-buf-name (plist-get org-capture-plist :original-buffer))
           (pdf-buf (get-buffer pdf-buf-name)))
      (if (buffer-live-p pdf-buf)
          (with-current-buffer pdf-buf
            (car (pdf-view-active-region-text)))
        (user-error "Buffer %S not alive" pdf-buf-name))))

  ;; ORG-CAPTURE TEMPLATES
  (setq org-protocol-default-template-key "L")
  (setq org-capture-templates
        '(("t" "Task" entry (function cj/--org-capture-project-location)
           "* TODO %?" :prepend t)

          ("b" "Bug" entry (function cj/--org-capture-project-location)
           "* TODO [#C] %?" :prepend t)

          ("e" "Event" entry (file+headline schedule-file "Scheduled Events")
           "* %?%:description
SCHEDULED: %^t%(cj/org-capture-event-content)
Captured On: %U"
           :prepend t
           :prepare-finalize cj/org-capture-format-event-headline)

          ("E" "Epub Text" entry (file+headline inbox-file "Inbox")
           "* %?
#+BEGIN_QUOTE\n %i\n#+END_QUOTE
Source: [[%:link][%(buffer-name (org-capture-get :original-buffer))]]
Captured On: %U" :prepend t)

          ;; requires cj/org-capture-pdf-active-region function defined above
          ("P" "PDF Text" entry (file+headline inbox-file "Inbox")
           "* %?
#+BEGIN_QUOTE\n%(cj/org-capture-pdf-active-region)\n#+END_QUOTE
Source: [[%L][%(buffer-name (org-capture-get :original-buffer))]]
Captured On: %U" :prepend t)

          ("p" "Link with Selection" entry (file+headline inbox-file "Inbox")
           "* %?%:description
#+BEGIN_QUOTE\n%i\n#+END_QUOTE
[[%:link][%:description]]
Captured On: %U\n" :prepend t :immediate-finish t)

          ("L" "Link" entry (file+headline inbox-file "Inbox")
           "* %?%:description
[[%:link][%:description]]\nCaptured On: %U" :prepend t :immediate-finish t)

          ("m" "Mu4e Email" entry (file+headline inbox-file "Inbox")
           "* TODO %?
%(if (string= \"%i\" \"\") \"\" \"\n#+BEGIN_QUOTE\n%i\n#+END_QUOTE\")
[[%:link][%:description]]
Captured On: %U"
           :prepend t)

          ("d" "Drill Question" entry
           (file (lambda () (cj/--drill-pick-file drill-dir)))
           "* Item   :drill:\n%?
** Answer\n%i\nSource: [[%:link][%:description]]
Captured On: %U" :prepend t)

          ("f" "Drill Question (from PDF)" entry
           (file (lambda () (cj/--drill-pick-file drill-dir)))
           "* Item   :drill:\n%?
** Answer\n%(cj/org-capture-pdf-active-region)
Source: [[%L][%(buffer-name (org-capture-get :original-buffer))]]
Captured On: %U" :prepend t)

          )) ;; end setq
  ) ;; end use-package org-protocol

;; ---------------------- Popup Capture Frame Auto-Close ----------------------
;; The quick-capture script (Hyprland Super+Shift+N) opens an emacsclient
;; frame named "org-capture"; Hyprland window rules float and center it by
;; that name. These hooks close the frame when the capture finalizes or
;; aborts, so the popup never lingers. Frames not named "org-capture" are
;; untouched — normal in-Emacs captures keep their windows.

(defun cj/org-capture--popup-frame-p ()
  "Return non-nil when the selected frame is the quick-capture popup."
  (equal (frame-parameter nil 'name) "org-capture"))

(defun cj/org-capture--delete-popup-frame ()
  "Delete the current frame when it is the quick-capture popup."
  (when (cj/org-capture--popup-frame-p)
    (delete-frame)))

(add-hook 'org-capture-after-finalize-hook #'cj/org-capture--delete-popup-frame)

;; The popup opens a fresh emacsclient frame still showing the daemon's last
;; buffer.  `org-mks' shows the *Org Select* menu via
;; `switch-to-buffer-other-window', and `org-capture-place-template' shows the
;; CAPTURE-* buffer via `pop-to-buffer' with a split action — both split the
;; small floating frame, so two reverse-video modelines read like tmux bars and
;; the working buffer leaks into a popup that should only show capture UI.  A
;; frame-scoped `display-buffer-alist' entry forces both into the frame's sole
;; window.  Gated on the "org-capture" frame name, so normal in-Emacs captures
;; keep their windows.

(defun cj/org-capture--popup-sole-window-p (frame-name buffer-name)
  "Return non-nil when BUFFER-NAME in a frame named FRAME-NAME is capture popup UI.
Capture popup UI is the *Org Select* template menu or a CAPTURE-* buffer
shown in the quick-capture frame (FRAME-NAME equal to \"org-capture\")."
  (and (equal frame-name "org-capture")
       (stringp buffer-name)
       (or (equal buffer-name "*Org Select*")
           (string-prefix-p "CAPTURE-" buffer-name))))

(defun cj/org-capture--popup-display-condition (buffer-name &optional _action)
  "`display-buffer' CONDITION matching capture UI in the quick-capture popup.
BUFFER-NAME is the buffer's name; the selected frame supplies the frame name."
  (cj/org-capture--popup-sole-window-p (frame-parameter nil 'name) buffer-name))

(defun cj/org-capture--display-sole-window (buffer _alist)
  "`display-buffer' ACTION showing BUFFER as the only window of the frame.
Used for the quick-capture popup so the template menu and capture buffer
never split the small floating frame."
  (let ((window (frame-root-window)))
    (delete-other-windows window)
    (set-window-buffer window buffer)
    window))

(add-to-list 'display-buffer-alist
             '(cj/org-capture--popup-display-condition
               cj/org-capture--display-sole-window))

;; The desktop quick-capture popup is launched globally (no browser selection,
;; no mu4e message, no pdf/epub buffer), so most templates make no sense there:
;; the context fields (%:link, %i) come up empty or point at the daemon's last
;; buffer, and the pdf templates error outright.  `cj/quick-capture' offers only
;; Task, Bug, and Event; Task and Bug file to the global inbox rather than a
;; project todo.org, since a desktop capture has no meaningful project context.
;; It also closes the popup frame on every exit path (abort, error, finalize) —
;; `org-capture' only runs `org-capture-after-finalize-hook' on a completed
;; capture, so a q/C-g at the template menu or an erroring template would
;; otherwise orphan the frame.  The Hyprland script calls this instead of
;; `org-capture'.

(defun cj/--org-capture-popup-templates (templates inbox)
  "Return the desktop-popup subset of TEMPLATES: Task, Bug, Event.
Task (\"t\") and Bug (\"b\") are retargeted to INBOX's \"Inbox\" headline;
Event (\"e\") passes through unchanged.  All other templates are dropped.
Template bodies and properties are preserved."
  (delq nil
        (mapcar
         (lambda (entry)
           (pcase (car-safe entry)
             ((or "t" "b")
              ;; (KEY DESC TYPE TARGET TEMPLATE . PROPS) -> retarget TARGET
              (append (list (nth 0 entry) (nth 1 entry) (nth 2 entry)
                            (list 'file+headline inbox "Inbox"))
                      (nthcdr 4 entry)))
             ("e" entry)
             (_ nil)))
         templates)))

(defun cj/org-capture--popup-frame ()
  "Return a live frame named \"org-capture\" (the quick-capture popup), or nil."
  (seq-find (lambda (f)
              (and (frame-live-p f)
                   (equal (frame-parameter f 'name) "org-capture")))
            (frame-list)))

(defun cj/quick-capture ()
  "Org-capture entry point for the Hyprland desktop popup (frame \"org-capture\").
Offers only Task, Bug, and Event; Task and Bug file to the global inbox.
Closes the popup frame on abort or error so a stray selection never orphans it.

Selects the \"org-capture\" frame by name before capturing rather than trusting
the ambient selected frame: the launching =emacsclient -c -e= runs before
Hyprland settles focus on the new float, so =(selected-frame)= is still the
daemon's main frame and the capture would otherwise land there."
  (interactive)
  (let ((frame (cj/org-capture--popup-frame)))
    (condition-case err
        (progn
          (when frame (select-frame-set-input-focus frame))
          (let ((org-capture-templates
                 (cj/--org-capture-popup-templates org-capture-templates inbox-file)))
            (org-capture)))
      (quit (cj/org-capture--delete-popup-frame))
      (error (message "Quick-capture: %s" (error-message-string err))
             (cj/org-capture--delete-popup-frame)))))

;; The template menu's "C — Customize org-capture-templates" special makes no
;; sense in the desktop popup (it would open a Customize buffer in the floating
;; frame).  Strip it from the menu when the selection runs in the popup frame,
;; keeping "q — Abort".  `org-mks' is the menu primitive; advising it (gated on
;; the frame name) catches the capture template selection without touching
;; org-mks's other callers.

(defun cj/--org-capture-popup-strip-specials (specials)
  "Remove the \"C\" Customize entry from org-mks SPECIALS, keeping the rest.
SPECIALS is the org-mks specials alist (e.g. the Customize and Abort entries)."
  (delq nil (mapcar (lambda (s) (unless (equal (car-safe s) "C") s)) specials)))

(defun cj/org-capture--popup-mks-advice (orig table title &optional prompt specials)
  "Around-advice for `org-mks': hide the Customize special in the quick-capture popup.
ORIG is the real `org-mks'; TABLE TITLE PROMPT SPECIALS are its arguments."
  (funcall orig table title prompt
           (if (cj/org-capture--popup-frame-p)
               (cj/--org-capture-popup-strip-specials specials)
             specials)))

(advice-add 'org-mks :around #'cj/org-capture--popup-mks-advice)

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