jao-org-notes.el 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. ;;; jao-org-notes.el --- A simple system for org note taking -*- lexical-binding: t; -*-
  2. ;; Copyright (C) 2020, 2021, 2022, 2024, 2025 jao
  3. ;; Author: jao <mail@jao.io>
  4. ;; Keywords: tools
  5. ;; This program is free software; you can redistribute it and/or modify
  6. ;; it under the terms of the GNU General Public License as published by
  7. ;; the Free Software Foundation, either version 3 of the License, or
  8. ;; (at your option) any later version.
  9. ;; This program is distributed in the hope that it will be useful,
  10. ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. ;; GNU General Public License for more details.
  13. ;; You should have received a copy of the GNU General Public License
  14. ;; along with this program. If not, see <https://www.gnu.org/licenses/>.
  15. ;;; Commentary:
  16. ;; An org note per file, with consultable title and tags and a
  17. ;; backlinks approximation.
  18. ;;; Code:
  19. (require 'org)
  20. (require 'consult)
  21. (require 'jao-shell)
  22. (defvar jao-org-notes-dir (expand-file-name "notes" org-directory))
  23. (defun jao-org-notes-list ()
  24. (directory-files-recursively jao-org-notes-dir "\\.org$"))
  25. (defun jao-org-notes--rg-cmd (rgx &rest args)
  26. `("rg" "--null" "--line-buffered" "--color=never" "--max-columns=250"
  27. "--type=org" "--line-number" "--no-heading" "--smart-case"
  28. ,@args ,default-directory "-e" ,rgx))
  29. (defun jao-org-notes--rg-title-or-tags (str)
  30. (let* ((m (string-match "^\\([^/]+\\)/\\(.*\\)" str))
  31. (d (or (and m (match-string 1 str)) ""))
  32. (str (if m (match-string 2 str) str))
  33. (default-directory
  34. (if (file-directory-p d) (expand-file-name d) default-directory))
  35. (ts (mapconcat #'identity (split-string str "[:,]+" t) ":|"))
  36. (rgx (format "^#.(title: .*%s|(tags:.*(%s:)))" str ts)))
  37. (jao-org-notes--rg-cmd rgx "-m" "2")))
  38. (defun jao-org-notes--clean-match (m)
  39. (list (format "%s %s"
  40. (replace-regexp-in-string default-directory "" (car m) nil t)
  41. (replace-regexp-in-string "[0-9]+:#\\+\\(title\\|tags\\):"
  42. "" (cadr m)))
  43. (expand-file-name (car m) default-directory)
  44. (string-to-number (cadr m))))
  45. (defun jao-org-notes--matches (lines)
  46. (mapcar (lambda (l) (jao-org-notes--clean-match (split-string l "\0" t))) lines))
  47. (defun jao-org-notes--grep-rx (rx &rest rg-args)
  48. (let ((default-directory jao-org-notes-dir))
  49. (jao-org-notes--matches
  50. (apply #'jao-shell-cmd-lines (apply #'jao-org-notes--rg-cmd rx rg-args)))))
  51. (defvar jao-org-notes--grep-history nil)
  52. (defun jao-org-notes--consult-group (m transform)
  53. (or (and transform m)
  54. (and (string-match-p "^[^:]+ + :" m) "tags")
  55. "titles"))
  56. (defun jao-org-notes--consult-rg (prompt &optional cat no-req cmd initial)
  57. (let ((default-directory (expand-file-name (or cat "") jao-org-notes-dir)))
  58. (consult--read
  59. (consult--async-pipeline
  60. (consult--process-collection #'jao-org-notes--rg-title-or-tags)
  61. (consult--async-transform #'jao-org-notes--matches))
  62. :prompt prompt
  63. :initial (or initial "")
  64. :add-history (thing-at-point 'symbol)
  65. :require-match (not no-req)
  66. :category 'jao-org-notes-lookup
  67. :group 'jao-org-notes--consult-group
  68. :lookup (lambda (cand cands &rest _)
  69. (or (cadr (assoc cand cands)) (substring cand 1)))
  70. :history '(:input jao-org-notes--grep-history))))
  71. (defun jao-org-notes-cats ()
  72. (seq-difference (directory-files jao-org-notes-dir) '("." ".." "attic")))
  73. (defun jao-org-notes--cat ()
  74. (let* ((cat (completing-read "Top level category: " (jao-org-notes-cats))))
  75. (cond ((file-exists-p (expand-file-name cat jao-org-notes-dir)) cat)
  76. ((yes-or-no-p "New category, create?") cat))))
  77. (defun jao-org-notes--insert-title (&optional title)
  78. (let* ((cat (jao-org-notes--cat))
  79. (note (jao-org-notes--consult-rg "Title: " cat t nil title))
  80. (title (file-name-base note))
  81. (title (replace-regexp-in-string "^#" "" title)))
  82. (when (not (string-empty-p title))
  83. (let* ((base (replace-regexp-in-string " +" "-" (downcase title)))
  84. (base (replace-regexp-in-string "[^-[:alnum:][:digit:]]" "" base))
  85. (fname (expand-file-name (concat cat "/" base ".org")
  86. jao-org-notes-dir))
  87. (exists? (file-exists-p fname)))
  88. (find-file fname)
  89. (when (not exists?)
  90. (insert "#+title: " title "\n")
  91. t)))))
  92. (defun jao-org-notes--find-tag (tag)
  93. (jao-org-notes--grep-rx (format "^#.tags:.*:%s:" tag) "-m" "1"))
  94. (defvar jao-org-notes--tags nil)
  95. (defvar jao-org-notes--tag-history nil)
  96. (defun jao-org-notes--read-tags ()
  97. (let* ((tags (completing-read-multiple "Tags: " jao-org-notes--tags nil nil nil
  98. 'jao-org-notes--tag-history)))
  99. (setq jao-org-notes--tags (seq-union jao-org-notes--tags tags #'string=))
  100. tags))
  101. (defun jao-org-notes--template (k)
  102. `(,k "Note" plain (file jao-org-notes-create)
  103. "%(if %:url \"#+link: %:url\" \"\")\n\n- %a\n %i"))
  104. (defun jao-org-notes-all-tags ()
  105. (let ((tags nil))
  106. (dolist (m (jao-org-notes--find-tag ".*"))
  107. (setq tags (seq-union tags (cdr (split-string (car m) ":" t)))))
  108. (sort tags #'string<)))
  109. (defun jao-org-notes-find-for-pdf (&optional file-name)
  110. "Given a PDF file name, find its org notes counterpart."
  111. (let* ((file-name (or file-name buffer-file-name))
  112. (bn (file-name-base file-name))
  113. (rx (format "%s\\.org$" (regexp-quote bn)))
  114. (pred (lambda () (string-prefix-p jao-org-notes-dir buffer-file-name))))
  115. (save-some-buffers nil pred)
  116. (or (car (directory-files-recursively jao-org-notes-dir rx))
  117. (let* ((d (completing-read "Notes subdir: " (jao-org-notes-cats) nil t))
  118. (d (file-name-as-directory d)))
  119. (expand-file-name (concat d bn ".org") jao-org-notes-dir)))))
  120. (defun jao-org-notes-open ()
  121. "Search for a note file, matching tags and titles with completion."
  122. (interactive)
  123. (when-let (f (jao-org-notes--consult-rg "Search notes: "))
  124. (find-file f)))
  125. (defun jao-org-notes-consult-tags ()
  126. "Search for a note file, matching all tags with completion."
  127. (interactive)
  128. (let* ((tags (jao-org-notes--read-tags))
  129. (init (concat "^..tags: " (mapconcat #'identity tags " "))))
  130. (consult-ripgrep jao-org-notes-dir init)))
  131. (defun jao-org-notes-consult-ripgrep (&optional initial cat)
  132. (interactive)
  133. (consult-ripgrep (expand-file-name (or cat "") jao-org-notes-dir) initial))
  134. (defun jao-org-notes-create (&optional title)
  135. "Create a new note file, matching tags and titles with completion."
  136. (interactive)
  137. (when (jao-org-notes--insert-title title)
  138. (org-insert-time-stamp (current-time) t t "#+date: " "\n")
  139. (insert "#+tags: :"
  140. (mapconcat #'identity (jao-org-notes--read-tags) ":")
  141. ":\n"))
  142. (save-buffer)
  143. (current-buffer))
  144. (defun jao-org-notes-backlinks ()
  145. "Show a list of note files linking to the current one."
  146. (interactive)
  147. (if-let* ((res (jao-org-notes--grep-rx
  148. (concat "\\[file:.*" (regexp-quote (buffer-name)) "\\]\\[")))
  149. (file (completing-read "File: " res nil t nil))
  150. (entry (assoc file res)))
  151. (progn (find-file (cadr entry))
  152. (when-let (line (caddr entry)) (goto-line line)))
  153. (message "Nobody links here!")))
  154. (defun jao-org-notes-insert-tags ()
  155. "Insert a list of tags at point, with completing read."
  156. (interactive)
  157. (insert ":" (mapconcat 'identity (jao-org-notes--read-tags) ":") ":"))
  158. (defun jao-org-notes-insert-link ()
  159. "Select a note file (with completion) and insert a link to it."
  160. (interactive)
  161. (when-let (f (jao-org-notes--consult-rg "Notes file: "))
  162. (let ((rel-path (file-relative-name f default-directory))
  163. (title (with-current-buffer (find-file-noselect f)
  164. (save-excursion
  165. (goto-char (point-min))
  166. (when (re-search-forward "^#\\+title: \\(.+\\)" nil t)
  167. (match-string 1))))))
  168. (insert (format "[[file:%s][%s]]" rel-path title)))))
  169. (defun jao-org-notes-stats ()
  170. (interactive)
  171. (message "%d notes, %d tags in %s"
  172. (length (jao-org-notes-list))
  173. (length jao-org--notes-tags)
  174. jao-org-notes-dir))
  175. ;;;###autoload
  176. (defun jao-org-notes-setup (mnemonic)
  177. "Set up the notes system, providing a mnemonic character for its org template."
  178. (setq org-capture-templates
  179. (add-to-list 'org-capture-templates (jao-org-notes--template mnemonic))
  180. jao-org-notes--tags (jao-org-notes-all-tags))
  181. (when (fboundp 'org-capture-upgrade-templates)
  182. (org-capture-upgrade-templates org-capture-templates)))
  183. (provide 'jao-org-notes)
  184. ;;; jao-org-notes.el ends here