1 ;;; org-bookmarks.el --- Manage bookmarks in Org mode -*- lexical-binding: t; -*-
2 ;; -*- coding: utf-8 -*-
4 ;; Copyright (C) 2024-2025 stardiviner <numbchild@gmail.com>
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. https://www.gnu.org/licenses/gpl-3.0.txt
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 ;; (use-package org-bookmarks
34 ;; :custom ((org-bookmarks-file "~/Org/Bookmarks/Bookmarks.org")
35 ;; (org-bookmarks-add-org-capture-template t))
36 ;; :commands (org-bookmarks))
38 ;; 1. Record bookmark information into Org mode file.
40 ;; 2. bookmark entry is recorded with format like bellowing:
43 ;; * bookmark title :bookmark:tag1:tag2:
45 ;; :URL: https://www.example.com
50 ;; 3. execute command `org-bookmarks' to search and select bookmark to open in web browser.
54 (require 'org
) ; for `org-tags-column'
55 (require 'org-element
)
57 (eval-when-compile (require 'org-capture
))
58 (eval-when-compile (require 'nerd-icons nil t
))
59 (declare-function 'nerd-icons-mdicon
"nerd-icons" (icon-name &rest args
))
61 (defgroup org-bookmarks nil
62 "The defcustom group of `org-bookmarks'."
63 :prefix
"org-boomarks-"
66 ;; It would be better to default to the demo file in the repository.
67 ;; That avoids creating a directory on the user's system that may not exist yet.
68 ;; It also allows the user to try the command out without customizing anything first.
69 (defcustom org-bookmarks-file
70 (expand-file-name "bookmarks.org" (file-name-directory (or load-file-name
(buffer-file-name))))
71 "The Org bookmarks filename."
74 :group
'org-bookmarks
)
76 (defcustom org-bookmarks-tag
"bookmark"
77 "The tag to mark Org headline as bookmark entry."
80 :group
'org-bookmarks
)
82 (defcustom org-bookmarks-browse-function
#'browse-url
83 "Function called by `org-bookmarks' with selected URL as its sole argument."
85 :group
'org-bookmarks
)
87 (defcustom org-bookmarks-add-org-capture-template nil
88 "Add `org-capture' template for `org-bookmarks'."
91 :group
'org-bookmarks
)
94 (defun org-bookmarks--candidate (headline)
95 "Return candidate string from Org HEADLINE."
96 (when-let* ((tags (org-element-property :tags headline
))
97 ( (member org-bookmarks-tag tags
))
98 (url (alist-get "URL" (org-entry-properties headline
'standard
) nil nil
#'equal
))
99 (info (concat "\n" (propertize url
'face
'link
) "\n")) ; multi-line candidate with "\n"
100 (headline-title (org-element-property :raw-value headline
)))
101 ;; The URL and ANNOTATION properties will be used for candidate display and browsing.
102 (let* ((tags-searchable (delete org-bookmarks-tag tags
))
103 ;; TODO: The length counting method not correct on Chinese.
104 (middle-line-length (when-let* ((length (- (- org-tags-column
)
105 (length (string-join tags-searchable
":"))
106 (length headline-title
) 2))
107 ((wholenump length
)))
110 (concat headline-title
112 (make-string (or middle-line-length
0) ?―
)
113 (if (= (length tags-searchable
) 1)
114 (car tags-searchable
)
115 (string-join tags-searchable
":"))))
116 'url url
'annotation info
))))
118 (defun org-bookmarks--candidates (file)
119 "Return a list of candidates from FILE."
120 ;; It's better to use a temp buffer than touch the user's buffer.
121 ;; It also cleans up after itself.
123 (insert-file-contents file
) ; don't need to actually open file.
124 (delay-mode-hooks ; This will prevent user hooks from running during parsing.
126 (goto-char (point-min))
127 (let ((candidates nil
))
128 (org-element-map (org-element-parse-buffer 'headline
) 'headline
130 (when-let ((candidate (org-bookmarks--candidate headline
)))
131 (push candidate candidates
))))
132 (nreverse candidates
)))))
134 (defun org-bookmarks--annotator (candidate)
135 "Annotate bookmark completion CANDIDATE."
136 (concat (propertize " " 'display
'(space :align-to center
))
137 (get-text-property 0 'annotation candidate
)))
139 (defun org-bookmarks--return-candidates (&optional file
)
140 "Return `org-bookmarks' candidates which parsed from FILE."
141 (if-let ((file (or file org-bookmarks-file
)))
142 (org-bookmarks--candidates file
)
143 (user-error "File does not exist: %S" file
)))
145 (defvar org-bookmarks--candidates-cache nil
146 "A cache variable of org-bookmarks--candidates.")
149 (defun org-bookmarks (&optional file
)
150 "Open bookmark read from FILE or `org-bookmarks-file'."
152 (unless org-bookmarks--candidates-cache
153 (setq org-bookmarks--candidates-cache
(org-bookmarks--return-candidates)))
154 (if-let* ((file (or file org-bookmarks-file
))
155 ( (file-exists-p file
)))
156 (if-let* ((candidates org-bookmarks--candidates-cache
)
157 (minibuffer-allow-text-properties t
)
158 (completion-extra-properties
159 ;; Using the "bookmark" category caused the annotations to not show.
160 ;; I think that may have be do to vertico-mode, but
161 ;; it's probably worth using a unique category so users can exercise finer-grained customization.
162 (list :category
'org-bookmark
163 :annotation-function
#'org-bookmarks--annotator
))
164 (choice (completing-read "org-bookmarks: " candidates nil
'require-match
))
165 (url (get-text-property 0 'url choice
)))
166 (funcall org-bookmarks-browse-function url
)
167 (user-error "No bookmarks found in %S" file
))
168 (user-error "File does not exist: %S" file
)))
171 ;; (org-bookmarks "bookmarks.org")
172 ;; (org-bookmarks (expand-file-name org-bookmarks-file))
174 ;;; Add `org-capture' template for adding new bookmark to `org-bookmarks-file'
175 (when (bound-and-true-p org-bookmarks-add-org-capture-template
)
176 (unless (assoc "b" org-capture-templates
)
178 'org-capture-templates
179 `("b" ,(format "%s\tAdd a new bookmark to %s"
180 (when (fboundp 'nerd-icons-mdicon
)
181 (nerd-icons-mdicon "nf-md-bookmark_plus_outline" :face
'nerd-icons-blue
))
182 (file-name-nondirectory org-bookmarks-file
))
183 entry
(file ,(expand-file-name org-bookmarks-file
))
184 ,(concat "* %^{bookmark title}\t\t\t\t" (format ":%s:" org-bookmarks-tag
) "
191 :refile-targets
((,org-bookmarks-file
:maxlevel
4)))
196 (provide 'org-bookmarks
)
198 ;;; org-bookmarks.el ends here