1 ;;; org-bookmarks.el --- Manage bookmarks in Org mode -*- lexical-binding: t; -*-
2 ;; -*- coding: utf-8 -*-
4 ;; Copyright (C) 2020-2021 Free Software Foundation, Inc.
6 ;; Authors: stardiviner <numbchild@gmail.com>
7 ;; Package-Requires: ((emacs "26.1"))
9 ;; Keywords: outline matching hypermedia org
10 ;; URL: https://repo.or.cz/org-bookmarks.git
12 ;; org-bookmarks is free software; you can redistribute it and/or modify it
13 ;; under the terms of the GNU General Public License as published by
14 ;; the Free Software Foundation; either version 3, or (at your option)
17 ;; org-bookmarks is distributed in the hope that it will be useful, but WITHOUT
18 ;; ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
19 ;; or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
20 ;; License for more details.
23 ;; You should have received a copy of the GNU General Public License
24 ;; along with GNU Emacs. If not, see <https://www.gnu.org/licenses/>.
32 (require 'org
) ; for `org-tags-column'
33 (require 'org-element
)
35 (defgroup org-bookmarks nil
36 "The defcustom group of `org-bookmarks'."
37 :prefix
"org-boomarks-"
40 ;; The :group keyword is not necessary if a group has been defined in the same file.
41 ;; It will default to the last declared.
43 ;; It would be better to default to the demo file in the repository.
44 ;; That avoids creating a directory on the user's system that may not exist yet.
45 ;; It also allows the user to try the command out without customizing anything first.
46 (defcustom org-bookmarks-file
47 (expand-file-name "bookmarks.org" (file-name-directory (or load-file-name
(buffer-file-name))))
48 "The Org bookmarks filename."
51 :group
'org-bookmarks
)
53 (defcustom org-bookmarks-tag
"bookmark"
54 "The tag to mark Org headline as bookmark entry."
57 :group
'org-bookmarks
)
59 ;; It's possible the user may want to change which function is used to browse the URL.
60 (defcustom org-bookmarks-browse-function
#'browse-url
61 "Function called by `org-bookmarks' with selected URL as its sole argument."
63 :group
'org-bookmarks
)
65 (defcustom org-bookmarks-add-org-capture-template nil
66 "Add org-capture template for org-bookmarks."
69 :group
'org-bookmarks
)
71 ;; Decomposing the logic of the main funciton into smaller functions makes the program
72 ;; easier to reason about and more flexible.
74 ;; We're mapping over headlines via the org-element API, so we can assume that's
75 ;; what type of element we'll be operating on here.
76 (defun org-bookmarks--candidate (headline)
77 "Return candidate string from Org HEADLINE."
78 ;; We can use when-let to succinctly assign variables and return early for
79 ;; non-matching headlines
80 (when-let ((tags (org-element-property :tags headline
))
81 ((member org-bookmarks-tag tags
))
82 (tags-searchable (remove org-bookmarks-tag tags
))
83 (url (alist-get "URL" (org-entry-properties headline
'standard
) nil nil
#'equal
))
84 (info (concat "\n" (propertize url
'face
'link
) "\n"))
85 (headline-title (org-element-property :raw-value headline
))
86 ;; TODO: The length counting method not correct on Chinese.
87 (middle-line-length (when-let* ((length (- (- org-tags-column
)
88 (length tags-searchable
)
89 (length headline-title
) 2))
92 ;; The URL and ANNOTATION properties will be used for candidate display and browsing.
94 (concat headline-title
96 (make-string middle-line-length ?―
)
97 (if (= (length tags-searchable
) 1)
99 (string-join tags-searchable
":"))))
100 'url url
'annotation info
)))
102 (defun org-bookmarks--candidates (file)
103 "Return a list of candidates from FILE."
104 ;; It's better to use a temp buffer than touch the user's buffer.
105 ;; It also cleans up after itself.
107 (insert-file-contents file
)
108 (delay-mode-hooks ;; This will prevent user hooks from running during parsing.
110 (goto-char (point-min))
111 (let ((candidates nil
))
112 (org-element-map (org-element-parse-buffer 'headline
) 'headline
114 (when-let ((candidate (org-bookmarks--candidate headline
)))
115 (push candidate candidates
))))
116 (nreverse candidates
)))))
118 ;; The annotation function can look up the properties on each candidate.
119 (defun org-bookmarks--annotator (candidate)
120 "Annotate bookmark completion CANDIDATE."
121 (concat (propertize " " 'display
'(space :align-to center
))
122 (get-text-property 0 'annotation candidate
)))
124 (defun org-bookmarks (&optional file
)
125 "Open bookmark read from FILE or `org-bookmarks-file'."
127 (if-let ((file (or file org-bookmarks-file
))
128 ;; Ensure file exists first.
129 ((file-exists-p file
)))
130 (if-let ((candidates (org-bookmarks--candidates file
))
131 ;; Necessary for propertized text in minibuffer.
132 (minibuffer-allow-text-properties t
)
133 (completion-extra-properties
134 ;; Using the "bookmark" category caused the annotations to not show.
135 ;; I think that may have be do to vertico-mode, but
136 ;; it's probably worth using a unique category so users can exercise finer-grained customization.
137 (list :category
'org-bookmark
138 :annotation-function
#'org-bookmarks--annotator
))
139 (choice (completing-read "org-bookmarks: " candidates nil
'require-match
))
140 (url (get-text-property 0 'url choice
)))
141 (funcall org-bookmarks-browse-function url
)
142 (user-error "No bookmarks found in %S" file
))
143 (user-error "File does not exist: %S" file
)))
146 ;; (org-bookmarks "bookmarks.org")
147 ;; (org-bookmarks (expand-file-name org-bookmarks-file))
149 ;;; Add `org-capture' template for adding new bookmark to `org-bookmarks-file'
150 (when org-bookmarks-add-org-capture-template
151 (require 'org-capture
)
152 (unless (assoc "b" org-capture-templates
)
154 'org-capture-templates
155 `("b" ,(format "%s\tAdd a new bookmark to %s"
156 (when (require 'nerd-icons nil t
)
157 (nerd-icons-mdicon "nf-md-bookmark_plus_outline" :face
'nerd-icons-blue
))
159 entry
(file ,(expand-file-name org-bookmarks-file
))
160 "* %^{bookmark title}
171 (provide 'org-bookmarks
)
173 ;;; org-bookmarks.el ends here