-
Notifications
You must be signed in to change notification settings - Fork 4
/
Copy pathorg-recent-headings.el
496 lines (426 loc) · 22.4 KB
/
org-recent-headings.el
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
;;; org-recent-headings.el --- Jump to recently used Org headings -*- lexical-binding: t; -*-
;; Author: Adam Porter <[email protected]>
;; Url: http://github.com/alphapapa/org-recent-headings
;; Version: 0.2-pre
;; Package-Requires: ((emacs "26.1") (org "9.0.5") (dash "2.18.0") (frecency "0.1") (s "1.12.0"))
;; Keywords: hypermedia, outlines, Org
;;; Commentary:
;; This package keeps a list of recently used Org headings and lets
;; you quickly choose one to jump to by calling one of these commands:
;; The list is kept by advising functions that are commonly called to
;; access headings in various ways. You can customize this list in
;; `org-recent-headings-advise-functions'. Suggestions for additions
;; to the default list are welcome.
;; Note: This probably works with Org 8 versions, but it's only been
;; tested with Org 9.
;; This package makes use of handy functions and settings in
;; `recentf'.
;;; Installation:
;; Install from MELPA, or manually by putting this file in your
;; `load-path'. Then put this in your init file:
;; (require 'org-recent-headings)
;; (org-recent-headings-mode)
;; You may also install Helm and/or Ivy, but they aren't required.
;;; Usage:
;; Activate `org-recent-headings-mode' to install the advice that will
;; track recently used headings. Then play with your Org files by
;; going to headings from the Agenda, calling
;; `org-tree-to-indirect-buffer', etc. Then call one of these
;; commands to jump to a heading:
;; + `org-recent-headings'
;; + `org-recent-headings-ivy'
;; + `org-recent-headings-helm'
;;; License:
;; 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/>.
;;; Code:
;;;; Requirements
(require 'cl-lib)
(require 'org)
(require 'org-agenda)
(require 'recentf)
(require 'seq)
(require 'subr-x)
(require 'dash)
(require 'frecency)
(require 's)
;;;; Structs
(cl-defstruct org-recent-headings-entry
(id) (file) (outline-path) (display) (frecency))
;;;; Variables
(defvar org-recent-headings-debug nil
"When non-nil, enable debug warnings.")
(defvar org-recent-headings-list nil
"List of recent Org headings.")
(defconst org-recent-headings-save-file-header
";;; Automatically generated by `org-recent-headings' on %s.\n"
"Header to be written into the `org-recent-headings-save-file'.")
(defgroup org-recent-headings nil
"Jump to recently used Org headings."
:group 'org)
(defcustom org-recent-headings-advise-functions
'(org-agenda-goto org-agenda-show org-agenda-show-mouse org-show-entry
org-reveal org-refile org-tree-to-indirect-buffer
org-bookmark-jump
helm-org-parent-headings helm-org-in-buffer-headings
helm-org-agenda-files-headings helm-org-bookmark-jump-indirect)
"Functions to advise to store recent headings.
Whenever one of these functions is called, the heading for the
entry at point will be added to the recent-headings list. This
means that the point should be in a regular Org buffer (i.e. not
an agenda buffer)."
;; FIXME: This needs to toggle the mode when set, if it's active
;; MAYBE: Add `org-cycle', or make a special option for it.
:type '(repeat function))
(defcustom org-recent-headings-store-heading-hooks '(org-capture-prepare-finalize-hook)
"Hooks to add heading-storing function to."
:type '(repeat variable))
(defcustom org-recent-headings-save-file (locate-user-emacs-file "org-recent-headings")
"File to save the recent Org headings list into."
:type 'file
:initialize 'custom-initialize-default
:set (lambda (symbol value)
(let ((oldvalue (eval symbol)))
(custom-set-default symbol value)
(and (not (equal value oldvalue))
org-recent-headings-mode
(org-recent-headings--load-list)))))
(defcustom org-recent-headings-show-entry-function 'org-recent-headings--show-entry-indirect
"Default function to use to show selected entries."
:type '(radio (function :tag "Show entries in real buffers." org-recent-headings--show-entry-direct)
(function :tag "Show entries in indirect buffers." org-recent-headings--show-entry-indirect)
(function :tag "Custom function")))
(defcustom org-recent-headings-list-size 200
"Maximum size of recent headings list."
:type 'integer)
(defcustom org-recent-headings-reverse-paths nil
"Reverse outline paths.
This way, the most narrowed-down heading will be listed first."
:type 'boolean)
(defcustom org-recent-headings-truncate-paths-by 12
"Truncate outline paths by this many characters.
Depending on your org-level faces, you may want to adjust this to
prevent paths from being wrapped onto a second line."
:type 'integer)
(defcustom org-recent-headings-use-ids 'when-available
"Use Org IDs to find headings instead of file/outline paths.
Org IDs are more flexible, because Org may be able to find them
when headings are refiled to other files or locations. Also,
file/outline paths can be ambiguous if a file's outline has
multiple headings with the same name. But finding by ID may
cause Org to load other Org files while searching for an ID,
which takes time, so some users may prefer to just use
file/outline paths, which will always search only one file."
:type '(radio (const :tag "Never: just use file/outline paths" nil)
(const :tag "When an ID already exists" when-available)
(const :tag "Always: create new IDs when necessary" always)))
(defcustom org-recent-headings-reject-any-fns nil
"Functions used to test potential headings.
If any function in this list returns non-nil, the heading is not
saved. Functions are called with one argument, an entry, which
is returned by function `org-recent-headings--current-entry',
which see."
:type '(repeat function))
;;;; Minor mode
;;;###autoload
(define-minor-mode org-recent-headings-mode
"Global minor mode to keep a list of recently used Org headings so they can be quickly selected and jumped to.
With prefix argument ARG, turn on if positive, otherwise off."
:global t
(let ((advice-function (if org-recent-headings-mode
(lambda (to fun)
;; Enable mode
(advice-add to :after fun))
(lambda (from fun)
;; Disable mode
(advice-remove from fun))))
(hook-setup (if org-recent-headings-mode 'add-hook 'remove-hook)))
(dolist (target org-recent-headings-advise-functions)
(when (fboundp target)
(funcall advice-function target 'org-recent-headings--store-heading)))
(dolist (hook org-recent-headings-store-heading-hooks)
(funcall hook-setup hook 'org-recent-headings--store-heading))
;; Add/remove save hook
(funcall hook-setup 'kill-emacs-hook 'org-recent-headings--save-list)
;; Load/save list
(if org-recent-headings-mode
(org-recent-headings--load-list)
(org-recent-headings--save-list))
;; Display message
(if org-recent-headings-mode
(message "org-recent-headings-mode enabled.")
(message "org-recent-headings-mode disabled."))))
;;;; Commands
;;;;; Plain completing-read
(defun org-recent-headings ()
"Choose from recent Org headings."
(interactive)
(org-recent-headings--prepare-list)
(let* ((heading-display-strings (-map #'org-recent-headings-entry-display org-recent-headings-list))
(selected-heading (completing-read "Heading: " heading-display-strings))
;; FIXME: If there are two headings with the same name, this will only
;; pick the first one. I guess it won't happen if full-paths are used,
;; which most likely will be, but maybe it should still be fixed.
(entry (car (--select (string= selected-heading (org-recent-headings-entry-display it))
org-recent-headings-list))))
(funcall org-recent-headings-show-entry-function entry)))
;;;; Functions
(defun org-recent-headings--bookmark-entry (entry)
"Bookmark heading specified by ENTRY."
(org-with-point-at (org-recent-headings--entry-marker entry)
(bookmark-set)))
(defun org-recent-headings--remove-entry (entry)
"Remove ENTRY from recent headings list."
(setf org-recent-headings-list
(cl-remove entry org-recent-headings-list
:test #'org-recent-headings--equal)))
(defun org-recent-headings--store-heading (&rest _ignore)
"Add current heading to `org-recent-headings' list."
(if-let* ((entry (org-recent-headings--current-entry))
(store-p (not (--any? (funcall it entry)
org-recent-headings-reject-any-fns))))
(if-let* ((existing-entry (car (cl-member entry org-recent-headings-list :test #'org-recent-headings--equal))))
;; Update existing item.
(setf (org-recent-headings-entry-frecency existing-entry)
(frecency-update (org-recent-headings-entry-frecency existing-entry)))
;; No existing item: add new one.
(setf (org-recent-headings-entry-frecency entry)
(frecency-update (org-recent-headings-entry-frecency entry)))
(push entry org-recent-headings-list))
;; No entry: warn about possible non-Org buffer. If this happens, it probably means
;; that a function should be removed from `org-recent-headings-advise-functions'.
(when org-recent-headings-debug
(warn "`org-recent-headings--store-heading' called in non-Org buffer: %s. Please report this bug." (current-buffer)))))
(defun org-recent-headings--current-entry ()
"Return entry for current Org entry, suitable for `org-recent-headings-list'."
(when-let* ((buffer (pcase major-mode
('org-agenda-mode (org-agenda-with-point-at-orig-entry
(current-buffer)))
('org-mode (current-buffer))))
;; Save point, because if we switch to a base buffer, point will change.
(pos (point))
;; Get base buffer when applicable
(buffer (or (buffer-base-buffer buffer)
buffer))
(file-path (buffer-file-name buffer)))
(with-current-buffer buffer
(org-with-wide-buffer
(goto-char pos)
(unless (org-before-first-heading-p)
(when-let* ((heading (org-get-heading t t)))
;; Heading is not empty
(let* ((outline-path (org-recent-headings--olp))
(id (or (org-id-get)
(when (eq org-recent-headings-use-ids 'always)
(org-id-get-create))))
(display (concat (file-name-nondirectory file-path) ":"
(if org-recent-headings-reverse-paths
(--> (org-get-outline-path t)
(org-format-outline-path it 1000 nil "")
(org-split-string it "")
(nreverse it)
(s-join "\\" it))
(org-format-outline-path (org-get-outline-path t))))))
(make-org-recent-headings-entry :id id :file file-path :outline-path outline-path :display display))))))))
(defun org-recent-headings--olp ()
"Return outline path for current entry.
Unlike `org-get-outline-path', this returns the raw heading
strings (without to-do keywords or tags), which are more suitable
for regexp searches."
;; `org-get-outline-path' replaces links in headings with their
;; descriptions, which prevents using them in regexp searches.
(org-with-wide-buffer
(nreverse (cl-loop collect (substring-no-properties (org-get-heading t t))
while (org-up-heading-safe)))))
(defun org-recent-headings--olp-marker (olp &optional unique)
"Return a marker pointing to outline path OLP in current buffer.
Return nil if not found. If UNIQUE, display a warning if OLP
points to multiple headings.
This works like `org-find-olp', but much faster."
;; `org-find-olp' provides the same results, but this function is about 3x faster.
;; The solution to the problem--of finding OLPs containing headings with links--was
;; returning raw heading text in `org-recent-headings--current-entry' rather than the
;; de-linked strings returned by `org-get-outline-path'. But while exploring that
;; problem, I wrote this function, and since it's faster, we might as well use it.
;; NOTE: Disabling `case-fold-search' is important to avoid voluntary hair loss.
(let ((case-fold-search nil))
(cl-labels ((find-at (level headings)
;; Could use `org-complex-heading-regexp-format', but this is actually much faster.
(let ((re (rx-to-string `(seq bol (repeat ,level "*") (1+ blank)
(optional (or ,@org-todo-keywords-1) (1+ blank)) ; To-do keyword
(optional "[#" (in "ABC") "]" (1+ blank)) ; Priority
,(car headings) (0+ blank) (or eol ":")))))
(when (re-search-forward re nil t)
(when (and unique (save-excursion
(save-restriction
(when (re-search-forward re nil t)
(if (cdr headings)
(find-at (1+ level) (cdr headings))
t)))))
(display-warning 'org-recent-headings
(format "Multiple headings found in %S for outline path: %S" (current-buffer) olp)
:warning))
(if (cdr headings)
(progn
(org-narrow-to-subtree)
(find-at (1+ level) (cdr headings)))
(copy-marker (point-at-bol)))))))
(org-with-wide-buffer
(goto-char (point-min))
(find-at 1 olp)))))
;;;;; List maintenance
;; TODO: Add boolean var tracking whether list has changed and needs preparing.
(defun org-recent-headings--prepare-list ()
"Sort and trim `org-recent-headings-list'."
;; FIXME: See task in notes.org.
(setq org-recent-headings-list
(-sort (-on #'> (lambda (entry)
(frecency-score (org-recent-headings-entry-frecency entry))))
org-recent-headings-list))
(org-recent-headings--trim))
(defun org-recent-headings--trim ()
"Trim recent headings list.
This assumes the list is already sorted. Whichever entries are
at the end of the list, beyond the allowed list size, are
removed."
(let ((original-size (length org-recent-headings-list)))
(when (> original-size org-recent-headings-list-size)
(setq org-recent-headings-list (-take org-recent-headings-list-size org-recent-headings-list)))
(when-let* ((debug-p org-recent-headings-debug)
(new-size (length org-recent-headings-list))
(difference (/= original-size new-size)))
(warn "org-recent-headings-list reduced from %s to %s entries" original-size new-size))))
(defun org-recent-headings--remove-duplicates ()
"Remove duplicates from `org-recent-headings-list'."
(setq org-recent-headings-list
(cl-delete-duplicates org-recent-headings-list
:test #'org-recent-headings--equal
:from-end t)))
(defun org-recent-headings--equal (a b)
"Return non-nil if A and B point to the same Org entry.
A and B should be entries from `org-recent-headings-list' as
conses in (key . attrs) format."
(pcase-let* (((cl-struct org-recent-headings-entry (id a-id) (file a-file) (outline-path a-outline-path)) a)
((cl-struct org-recent-headings-entry (id b-id) (file b-file) (outline-path b-outline-path)) b))
(when (and a-file b-file) ; Sanity check
(or (when (and a-id b-id)
;; If the Org IDs are set and are the same, the entries point to
;; the same heading
(string= a-id b-id))
(when (and a-outline-path b-outline-path)
;; If both entries have outline-path in keys, compare file and olp
(and (string= a-file b-file)
(equal a-outline-path b-outline-path)))))))
;;;;; Show entries
(defun org-recent-headings--show-entry-default (entry)
"Show heading specified by ENTRY using default function.
Default function set in `org-recent-headings-show-entry-function'."
;; This is for the Helm source, to allow it to make use of a
;; customized option setting the default function. Maybe there's a
;; better way, but this works.
(funcall org-recent-headings-show-entry-function entry))
(defun org-recent-headings--show-entry-direct (entry)
"Go to heading specified by ENTRY."
(let ((marker (org-recent-headings--entry-marker entry)))
(switch-to-buffer (marker-buffer marker))
(widen)
(goto-char marker)
(org-reveal)
(org-show-entry)))
(defun org-recent-headings--show-entry-indirect (real)
"Show heading specified by REAL in an indirect buffer.
REAL is a plist with `:file', `:id', and `:regexp' entries. If
`:id' is non-nil, `:file' and `:regexp may be nil.'"
;; By using `save-excursion' and `save-restriction', this function doesn't
;; change the position or narrowing of the entry's underlying buffer.
(let ((marker (org-recent-headings--entry-marker real)))
(save-excursion
(save-restriction
(switch-to-buffer (marker-buffer marker))
(widen)
(goto-char marker)
(org-reveal)
(org-show-entry)
(org-tree-to-indirect-buffer)))))
(defun org-recent-headings--entry-marker (entry)
"Return marker for ENTRY.
Raises an error if entry can't be found."
(pcase-let* (((cl-struct org-recent-headings-entry id file outline-path) entry)
(buffer (or (org-find-base-buffer-visiting file)
(find-file-noselect file)
(unless id
;; Don't give error if an ID, because Org might still be able to find it
(error "File not found: %s" file))))
(marker (if buffer
(with-current-buffer buffer
(save-excursion
(save-restriction
(widen)
(goto-char (point-min))
;; TODO: If showing the entry fails, optionally automatically remove it from list.
;; TODO: Factor out entry-finding into separate function.
(cond (id (org-id-find id 'marker))
(outline-path (org-recent-headings--olp-marker outline-path))
(t (error "org-recent-headings: Entry has no ID or OLP: %S" entry))))))
;; No buffer; let Org try to find it.
;; NOTE: Not sure if it's helpful to do this separately in the code above when `buffer' is set.
(org-id-find id 'marker))))
(or marker
(error "org-recent-headings: Can't find entry: %S" entry))))
;;;;; File saving/loading
;; Mostly copied from `recentf'
(defun org-recent-headings--save-list ()
"Save the recent Org headings list.
Write data into the file specified by `org-recent-headings-save-file'."
(condition-case err
(with-temp-buffer
(erase-buffer)
(set-buffer-file-coding-system recentf-save-file-coding-system)
(insert (format-message org-recent-headings-save-file-header
(current-time-string)))
(recentf-dump-variable 'org-recent-headings-list)
(insert "\n\n;; Local Variables:\n"
(format ";; coding: %s\n" recentf-save-file-coding-system)
";; End:\n")
(write-file (expand-file-name org-recent-headings-save-file))
(when recentf-save-file-modes
(set-file-modes org-recent-headings-save-file recentf-save-file-modes))
nil)
(error
(warn "org-recent-headings-mode: %s" (error-message-string err)))))
;; TODO: Remove 0.1->0.2 conversion code after 0.3 is released.
(defun org-recent-headings--load-list ()
"Load a previously saved recent list.
Read data from the file specified by `org-recent-headings-save-file'."
(let ((file (expand-file-name org-recent-headings-save-file)))
(when (file-readable-p file)
(load-file file)))
(-when-let* ((old-style-list-p (listp (car org-recent-headings-list)))
;; Some of the keys might be missing, but all of the attrs should be present, so test only those.
((_
. (&keys :display :frecency-timestamps :frecency-num-timestamps :frecency-total-count))
(car org-recent-headings-list)))
;; Try to convert 0.1-style list to 0.2-style.
(setf org-recent-headings-list (org-recent-headings--convert org-recent-headings-list))))
(defun org-recent-headings--convert (list)
"Return LIST converted from 0.1-style to 0.2-style."
(--map (-let* ((((&keys :id :file :outline-path)
. (&keys :display :frecency-timestamps :frecency-num-timestamps :frecency-total-count))
it)
(frecency (list (cons :frecency-timestamps frecency-timestamps)
(cons :frecency-num-timestamps frecency-num-timestamps)
(cons :frecency-total-count frecency-total-count))))
(make-org-recent-headings-entry :id id :file file :outline-path outline-path
:display display :frecency frecency))
list))
;;;; Footer
(provide 'org-recent-headings)
;;; org-recent-headings.el ends here