;;; jao-org-notes.el --- A simple system for org note taking  -*- lexical-binding: t; -*-

;; Copyright (C) 2020, 2021, 2022, 2024, 2025  jao

;; Author: jao <mail@jao.io>
;; 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 <https://www.gnu.org/licenses/>.

;;; 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" "--follow"
    ,@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) ":|"))
         (s (mapconcat #'identity (cons str (split-string str nil t)) "|"))
         (rgx (format "^#.(title: .*(%s)|(tags:.*(%s:)))" s 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)
  (seq-keep (lambda (l)
              (let ((m (split-string (or l "") "\0" t)))
                (when (and (car m) (cadr m))
                  (jao-org-notes--clean-match m))))
            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 initial)
  (let ((default-directory (expand-file-name (or cat "") jao-org-notes-dir)))
    (consult--read
     (consult--async-pipeline
      (consult--process-collection #'jao-org-notes--rg-title-or-tags)
      (consult--async-transform #'jao-org-notes--matches))
     :prompt prompt
     :initial (or initial "")
     :add-history (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 ()
  (directory-files jao-org-notes-dir nil "^[^.]"))

(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 (&optional title)
  (let* ((cat (jao-org-notes--cat))
         (note (jao-org-notes--consult-rg "Title: " cat t nil title))
         (title (file-name-base note))
         (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.

If the search string starts with a folder name followed by a
slash (e.g. `emacs/foo'), the search is performed only in the given
notes folder (when it exists).

Search terms that start with a colon are interpreted as tags, and can
appear in any order in the tags list.
"
  (interactive)
  (when-let* ((f (jao-org-notes--consult-rg "Search tags and titles: ")))
    (find-file f)))

(defvar jao-org-notes-consult-ripgrep-args
  (format "%s -L" consult-ripgrep-args))

(defun jao-org-notes-consult-ripgrep (&optional ask initial)
  "Search over notes in given notes categories (or all of them), using rg."
  (interactive "P")
  (let ((cat (when ask (completing-read "Category: " (jao-org-notes-cats))))
        (consult-ripgrep-args jao-org-notes-consult-ripgrep-args))
    (consult-ripgrep (expand-file-name (or cat "") jao-org-notes-dir) initial)))

(defun jao-org-notes-consult-ripgrep-category (&optional initial)
  "Search over notes in a given notes category."
  (interactive "P")
  (jao-org-notes-consult-ripgrep t initial))

(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 " "))))
    (jao-org-notes-consult-ripgrep init)))

(defun jao-org-notes-create (&optional title ret-buffer)
  "Create a new note file, matching tags and titles with completion."
  (interactive)
  (when (jao-org-notes--insert-title title)
    (insert "#+tags: :"
            (mapconcat #'identity (jao-org-notes--read-tags) ":")
            ":\n")
    (org-insert-time-stamp (current-time) t t "#+date: " "\n"))
  (save-buffer)
  (if ret-buffer (current-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 char for its org template."
  (require 'org-capture)
  (add-to-list 'org-capture-templates (jao-org-notes--template mnemonic))
  (setq 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
