Add the tag to captured bookmark by default
[org-bookmarks.git] / org-bookmarks.el
blob0ec6f45e5f189cdc44d5be5fa0d5a89077ef16f4
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"))
8 ;; Version: 1.0
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)
15 ;; any later version.
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/>.
26 ;;; Commentary:
28 ;; Usage
30 ;; 1. Record bookmark information into Org mode file.
32 ;; 2. bookmark entry is recorded with format like bellowing:
34 ;; #+begin_src org
35 ;; * bookmark title :bookmark:tag1:tag2:
36 ;; :PROPERTIES:
37 ;; :URL: https://www.example.com
38 ;; :END:
39 ;;
40 ;; #+end_src
42 ;; 3. execute command `org-bookmarks' to search and select bookmark to open in web browser.
44 ;;; Code:
46 (require 'org) ; for `org-tags-column'
47 (require 'org-element)
49 (eval-when-compile (require 'org-capture))
50 (eval-when-compile (require 'nerd-icons nil t))
51 (declare-function 'nerd-icons-mdicon "nerd-icons" (icon-name &rest args))
53 (defgroup org-bookmarks nil
54 "The defcustom group of `org-bookmarks'."
55 :prefix "org-boomarks-"
56 :group 'org)
58 ;; It would be better to default to the demo file in the repository.
59 ;; That avoids creating a directory on the user's system that may not exist yet.
60 ;; It also allows the user to try the command out without customizing anything first.
61 (defcustom org-bookmarks-file
62 (expand-file-name "bookmarks.org" (file-name-directory (or load-file-name (buffer-file-name))))
63 "The Org bookmarks filename."
64 :type 'string
65 :safe #'stringp
66 :group 'org-bookmarks)
68 (defcustom org-bookmarks-tag "bookmark"
69 "The tag to mark Org headline as bookmark entry."
70 :type 'string
71 :safe #'stringp
72 :group 'org-bookmarks)
74 (defcustom org-bookmarks-browse-function #'browse-url
75 "Function called by `org-bookmarks' with selected URL as its sole argument."
76 :type 'function
77 :group 'org-bookmarks)
79 (defcustom org-bookmarks-add-org-capture-template nil
80 "Add `org-capture' template for `org-bookmarks'."
81 :type 'boolean
82 :safe #'booleanp
83 :group 'org-bookmarks)
86 (defun org-bookmarks--candidate (headline)
87 "Return candidate string from Org HEADLINE."
88 (when-let* ((tags (org-element-property :tags headline))
89 ( (member org-bookmarks-tag tags))
90 (url (alist-get "URL" (org-entry-properties headline 'standard) nil nil #'equal))
91 (info (concat "\n" (propertize url 'face 'link) "\n")) ; multi-line candidate with "\n"
92 (headline-title (org-element-property :raw-value headline)))
93 ;; The URL and ANNOTATION properties will be used for candidate display and browsing.
94 (let* ((tags-searchable (delete org-bookmarks-tag tags))
95 ;; TODO: The length counting method not correct on Chinese.
96 (middle-line-length (when-let* ((length (- (- org-tags-column)
97 (length (string-join tags-searchable ":"))
98 (length headline-title) 2))
99 ((wholenump length)))
100 length)))
101 (propertize
102 (concat headline-title
103 (format " %s [%s]"
104 (make-string (or middle-line-length 0) ?―)
105 (if (= (length tags-searchable) 1)
106 (car tags-searchable)
107 (string-join tags-searchable ":"))))
108 'url url 'annotation info))))
110 (defun org-bookmarks--candidates (file)
111 "Return a list of candidates from FILE."
112 ;; It's better to use a temp buffer than touch the user's buffer.
113 ;; It also cleans up after itself.
114 (with-temp-buffer
115 (insert-file-contents file) ; don't need to actually open file.
116 (delay-mode-hooks ; This will prevent user hooks from running during parsing.
117 (org-mode)
118 (goto-char (point-min))
119 (let ((candidates nil))
120 (org-element-map (org-element-parse-buffer 'headline) 'headline
121 (lambda (headline)
122 (when-let ((candidate (org-bookmarks--candidate headline)))
123 (push candidate candidates))))
124 (nreverse candidates)))))
126 (defun org-bookmarks--annotator (candidate)
127 "Annotate bookmark completion CANDIDATE."
128 (concat (propertize " " 'display '(space :align-to center))
129 (get-text-property 0 'annotation candidate)))
131 (defun org-bookmarks--return-candidates (&optional file)
132 "Return `org-bookmarks' candidates which parsed from FILE."
133 (if-let ((file (or file org-bookmarks-file)))
134 (org-bookmarks--candidates file)
135 (user-error "File does not exist: %S" file)))
137 (defvar org-bookmarks--candidates-cache nil
138 "A cache variable of org-bookmarks--candidates.")
140 ;;;###autoload
141 (defun org-bookmarks (&optional file)
142 "Open bookmark read from FILE or `org-bookmarks-file'."
143 (interactive)
144 (unless org-bookmarks--candidates-cache
145 (setq org-bookmarks--candidates-cache (org-bookmarks--return-candidates)))
146 (if-let* ((file (or file org-bookmarks-file))
147 ( (file-exists-p file)))
148 (if-let* ((candidates org-bookmarks--candidates-cache)
149 (minibuffer-allow-text-properties t)
150 (completion-extra-properties
151 ;; Using the "bookmark" category caused the annotations to not show.
152 ;; I think that may have be do to vertico-mode, but
153 ;; it's probably worth using a unique category so users can exercise finer-grained customization.
154 (list :category 'org-bookmark
155 :annotation-function #'org-bookmarks--annotator))
156 (choice (completing-read "org-bookmarks: " candidates nil 'require-match))
157 (url (get-text-property 0 'url choice)))
158 (funcall org-bookmarks-browse-function url)
159 (user-error "No bookmarks found in %S" file))
160 (user-error "File does not exist: %S" file)))
162 ;;; TEST:
163 ;; (org-bookmarks "bookmarks.org")
164 ;; (org-bookmarks (expand-file-name org-bookmarks-file))
166 ;;; Add `org-capture' template for adding new bookmark to `org-bookmarks-file'
167 (when (bound-and-true-p org-bookmarks-add-org-capture-template)
168 (unless (assoc "b" org-capture-templates)
169 (add-to-list
170 'org-capture-templates
171 `("b" ,(format "%s\tAdd a new bookmark to %s"
172 (when (fboundp 'nerd-icons-mdicon)
173 (nerd-icons-mdicon "nf-md-bookmark_plus_outline" :face 'nerd-icons-blue))
174 org-bookmarks-file)
175 entry (file ,(expand-file-name org-bookmarks-file))
176 ,(concat "* %^{bookmark title}\t\t\t\t" (format ":%s:" org-bookmarks-tag) "
177 :PROPERTIES:
178 :URL: %^C
179 :DATE: %t
180 :END:")
181 :empty-lines 1
182 :jump-to-captured t)
183 :append)))
187 (provide 'org-bookmarks)
189 ;;; org-bookmarks.el ends here