;;; jao-org-notes.el --- A simple system for org note taking -*- lexical-binding: t; -*- ;; Copyright (C) 2020, 2021, 2022, 2024 jao ;; Author: jao ;; Keywords: tools ;; 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 . ;;; Commentary: ;; An org note per file, with consultable title and tags and a ;; backlinks approximation. ;;; Code: (require 'org) (require 'consult) (require 'jao-shell) (defvar jao-org-notes-dir (expand-file-name "notes" org-directory)) (defun jao-org-notes-list () (directory-files-recursively jao-org-notes-dir "\\.org$")) (defun jao-org-notes--rg-cmd (rgx &rest args) `("rg" "--null" "--line-buffered" "--color=never" "--max-columns=250" "--type=org" "--line-number" "--no-heading" "--smart-case" ,@args ,default-directory "-e" ,rgx)) (defun jao-org-notes--rg-title-or-tags (str) (let* ((m (string-match "^\\([^/]+\\)/\\(.*\\)" str)) (d (or (and m (match-string 1 str)) "")) (str (if m (match-string 2 str) str)) (default-directory (if (file-directory-p d) (expand-file-name d) default-directory)) (ts (mapconcat #'identity (split-string str "[:,]+" t) ":|")) (rgx (format "^#.(title: .*%s|(tags:.*(%s:)))" str ts))) (jao-org-notes--rg-cmd rgx "-m" "2"))) (defun jao-org-notes--clean-match (m) (list (format "%s %s" (replace-regexp-in-string default-directory "" (car m) nil t) (replace-regexp-in-string "[0-9]+:#\\+\\(title\\|tags\\):" "" (cadr m))) (expand-file-name (car m) default-directory) (string-to-number (cadr m)))) (defun jao-org-notes--matches (lines) (mapcar (lambda (l) (jao-org-notes--clean-match (split-string l "\0" t))) lines)) (defun jao-org-notes--grep-rx (rx &rest rg-args) (let ((default-directory jao-org-notes-dir)) (jao-org-notes--matches (apply #'jao-shell-cmd-lines (apply #'jao-org-notes--rg-cmd rx rg-args))))) (defvar jao-org-notes--grep-history nil) (defun jao-org-notes--consult-group (m transform) (or (and transform m) (and (string-match-p "^[^:]+ + :" m) "tags") "titles")) (defun jao-org-notes--consult-rg (prompt &optional cat no-req cmd) (let ((default-directory (expand-file-name (or cat "") jao-org-notes-dir))) (consult--read (consult--async-command #'jao-org-notes--rg-title-or-tags (consult--async-transform jao-org-notes--matches)) :prompt prompt :initial (consult--async-split-initial "") :add-history (concat (consult--async-split-initial (thing-at-point 'symbol))) :require-match (not no-req) :category 'jao-org-notes-lookup :group 'jao-org-notes--consult-group :lookup (lambda (cand cands &rest _) (or (cadr (assoc cand cands)) (substring cand 1))) :history '(:input jao-org-notes--grep-history)))) (defun jao-org-notes-cats () (seq-difference (directory-files jao-org-notes-dir) '("." ".." "attic"))) (defun jao-org-notes--cat () (let* ((cat (completing-read "Top level category: " (jao-org-notes-cats)))) (cond ((file-exists-p (expand-file-name cat jao-org-notes-dir)) cat) ((yes-or-no-p "New category, create?") cat)))) (defun jao-org-notes--insert-title () (let* ((cat (jao-org-notes--cat)) (title (file-name-base (jao-org-notes--consult-rg "Title: " cat t))) (title (replace-regexp-in-string "^#" "" title))) (when (not (string-empty-p title)) (let* ((base (replace-regexp-in-string " +" "-" (downcase title))) (base (replace-regexp-in-string "[^-[:alnum:][:digit:]]" "" base)) (fname (expand-file-name (concat cat "/" base ".org") jao-org-notes-dir)) (exists? (file-exists-p fname))) (find-file fname) (when (not exists?) (insert "#+title: " title "\n") t))))) (defun jao-org-notes--find-tag (tag) (jao-org-notes--grep-rx (format "^#.tags:.*:%s:" tag) "-m" "1")) (defvar jao-org-notes--tags nil) (defvar jao-org-notes--tag-history nil) (defun jao-org-notes--read-tags () (let* ((tags (completing-read-multiple "Tags: " jao-org-notes--tags nil nil nil 'jao-org-notes--tag-history))) (setq jao-org-notes--tags (seq-union jao-org-notes--tags tags #'string=)) tags)) (defun jao-org-notes--template (k) `(,k "Note" plain (file jao-org-notes-create) "%(if %:url \"#+link: %:url\" \"\")\n\n- %a\n %i")) (defun jao-org-notes-all-tags () (let ((tags nil)) (dolist (m (jao-org-notes--find-tag ".*")) (setq tags (seq-union tags (cdr (split-string (car m) ":" t))))) (sort tags #'string<))) (defun jao-org-notes-find-for-pdf (&optional file-name) "Given a PDF file name, find its org notes counterpart." (let* ((file-name (or file-name buffer-file-name)) (bn (file-name-base file-name)) (rx (format "%s\\.org$" (regexp-quote bn))) (pred (lambda () (string-prefix-p jao-org-notes-dir buffer-file-name)))) (save-some-buffers nil pred) (or (car (directory-files-recursively jao-org-notes-dir rx)) (let* ((d (completing-read "Notes subdir: " (jao-org-notes-cats) nil t)) (d (file-name-as-directory d))) (expand-file-name (concat d bn ".org") jao-org-notes-dir))))) (defun jao-org-notes-open () "Search for a note file, matching tags and titles with completion." (interactive) (when-let (f (jao-org-notes--consult-rg "Search notes: ")) (find-file f))) (defun jao-org-notes-consult-tags () "Search for a note file, matching all tags with completion." (interactive) (let* ((tags (jao-org-notes--read-tags)) (init (concat "^..tags: " (mapconcat #'identity tags " ")))) (consult-ripgrep jao-org-notes-dir init))) (defun jao-org-notes-consult-ripgrep (&optional initial cat) (interactive) (consult-ripgrep (expand-file-name (or cat "") jao-org-notes-dir) initial)) (defun jao-org-notes-create () "Create a new note file, matching tags and titles with completion." (interactive) (when (jao-org-notes--insert-title) (org-insert-time-stamp (current-time) t t "#+date: " "\n") (insert "#+tags: :" (mapconcat #'identity (jao-org-notes--read-tags) ":") ":\n")) (save-buffer) (buffer-file-name)) (defun jao-org-notes-backlinks () "Show a list of note files linking to the current one." (interactive) (if-let* ((res (jao-org-notes--grep-rx (concat "\\[file:.*" (regexp-quote (buffer-name)) "\\]\\["))) (file (completing-read "File: " res nil t nil)) (entry (assoc file res))) (progn (find-file (cadr entry)) (when-let (line (caddr entry)) (goto-line line))) (message "Nobody links here!"))) (defun jao-org-notes-insert-tags () "Insert a list of tags at point, with completing read." (interactive) (insert ":" (mapconcat 'identity (jao-org-notes--read-tags) ":") ":")) (defun jao-org-notes-insert-link () "Select a note file (with completion) and insert a link to it." (interactive) (when-let (f (jao-org-notes--consult-rg "Notes file: ")) (let ((rel-path (file-relative-name f default-directory)) (title (with-current-buffer (find-file-noselect f) (save-excursion (goto-char (point-min)) (when (re-search-forward "^#\\+title: \\(.+\\)" nil t) (match-string 1)))))) (insert (format "[[file:%s][%s]]" rel-path title))))) (defun jao-org-notes-stats () (interactive) (message "%d notes, %d tags in %s" (length (jao-org-notes-list)) (length jao-org--notes-tags) jao-org-notes-dir)) ;;;###autoload (defun jao-org-notes-setup (mnemonic) "Set up the notes system, providing a mnemonic character for its org template." (setq org-capture-templates (add-to-list 'org-capture-templates (jao-org-notes--template mnemonic)) jao-org-notes--tags (jao-org-notes-all-tags)) (when (fboundp 'org-capture-upgrade-templates) (org-capture-upgrade-templates org-capture-templates))) (provide 'jao-org-notes) ;;; jao-org-notes.el ends here