;;; denote-project-notes.el --- Link Denote notes to a project  -*- lexical-binding: t; -*-

;; Copyright (C) 2025  Samuel W. Flint <swflint@samuelwflint.com>

;; Author: Samuel W. Flint <swflint@samuelwflint.com>
;; SPDX-License-Identifier: GPL-3.0-or-later
;; Homepage: https://git.sr.ht/~swflint/denote-project-notes
;; Package-Version: 20260622.1947
;; Package-Revision: e109c84071b3
;; Keywords: convenience
;; Package-Requires: ((emacs "28.1") (denote "3.0.0"))

;; 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:

;; This package provides some simple linkage between Denote notes and
;; projects.  It does so by providing two buffer/directory-local
;; variables that can be set through a provided command
;; (`denote-project-notes-set-identifier').  The notes can then be
;; displayed using `denote-project-notes-show'.  Additionally, a more
;; user-friendly DWIM command, `denote-project-notes-dwim' is
;; available: this will show project notes if the notes identifier is
;; set, or ask you to set the identifier; additionally, you can force
;; changing the notes with the prefix argument.  It is recomended to
;; bind one of these commands to a convenient key.
;;
;; The primary variable to select notes is
;; `denote-project-notes-identifier', whish should be set to a Denote
;; identifier that can be found in
;; `denote-project-notes-denote-directory' (a Denote silo).  An
;; additional option includes using a username-to-identifier alist
;; (this must be set using `customize-dirlocals' or by editing the
;; .dir-locals.el file manually).
;;
;; Display can be customized using either `display-buffer-alist' (the
;; default), or through `denote-project-notes-display-action' (a
;; `display-buffer' action).
;;
;;;; Errors and Patches
;;
;; If you find an error, or have a patch to improve this package,
;; please send an email to ~swflint/emacs-utilities@lists.sr.ht.


;;; Code:

(require 'denote)
(require 'project)


;;; Configuration

(defgroup denote-project-notes ()
  "Link to Denote notes from within a project (broadly defined)."
  :group 'denote
  :group 'project
  :link '(url-link :tag "Homepage" "https://git.sr.ht/~swflint/denote-project-notes")
  :link '(emacs-library-link :tag "Library Source" "denote-project-notes.el"))

(defcustom denote-project-notes-identifier nil
  "Denote identifier for project notes.

If nil, no notes will be associated with the project.

An Alist-derived form is avaliable as well, mapping from the value of
the variable `user-login-name' to a Denote identifier."
  :group 'denote-project-notes
  :type '(choice (const :tag "No notes." nil)
                 (string :tag "Denote Identifier")
                 (alist :tag "Username/Identifier mapping"
                        :key-type (string :tag "Username")
                        :value-type (string :tag "Denote Identifier")))
  :local t
  :safe #'denote-project-notes--identifier-safe-p)

(defcustom denote-project-notes-denote-directory denote-directory
  "Location of Denote silo for project notes.

This defaults to the load-time value of the variable `denote-directory',
but can be set otherwise."
  :group 'denote-project-notes
  :type 'directory
  :local t
  :safe #'directory-name-p)

(defcustom denote-project-notes-display-action nil
  "How project notes should be displayed.

If nil, `display-buffer-alist' will be used.  Otherwise, this value
should be a valid ACTION for `display-buffer', which see."
  :group 'denote-project-notes
  :type `(choice (const :tag  "Fall-back on display-buffer-alist" nil)
                 ,display-buffer--action-custom-type)
  :risky t)


;;; Utility Functions

(defun denote-project-notes--get-identifier ()
  "Get Denote Project Notes identifier."
  (or (and (stringp denote-project-notes-identifier)
           denote-project-notes-identifier)
      (and (listp denote-project-notes-identifier)
           (alist-get user-login-name denote-project-notes-identifier nil nil #'string=))))

(defun denote-project-notes--identifier-safe-p (object)
  "Determine if OBJECT is a safe identifier."
  (or (null object)
      (stringp object)
      (and (listp object)
           (reduce (lambda (a b) (and a b))
                   (mapcar (lambda (cons) (and (consp cons)
                                               (stringp (car cons))
                                               (stringp (cdr cons))))
                           object)))))

(defun denote-project-notes--save-variable (variable value)
  "Save VALUE for VARIABLE in dir-locals appropriately."
  (if (< emacs-major-version 30)
      (add-dir-local-variable nil variable value)
    (let ((dir-locals-path (or (and (project-current)
                                    (file-name-concat (project-root (project-current)) dir-locals-file))
                               (locate-dominating-file (or (buffer-file-name)
                                                           default-directory)
                                                       dir-locals-file))))
      (add-dir-local-variable nil variable value dir-locals-path))))


;;; User Interface

(defun denote-project-notes-set-identifier (identifier directory)
  "Set the current project's note IDENTIFIER, in DIRECTORY.

Prompt for DIRECTORY if the prefix argument is used."
  (interactive
   (let* ((denote-directory (if current-prefix-arg
                                (read-directory-name "Denote Silo: " denote-project-notes-denote-directory)
                              denote-project-notes-denote-directory))
          (filename (denote-file-prompt nil "Project notes file" nil))
          (identifier (denote-retrieve-filename-identifier filename)))
     (list identifier denote-directory)))
  (denote-project-notes--save-variable 'denote-project-notes-identifier identifier)
  (unless (string= (expand-file-name directory)
                   (expand-file-name denote-project-notes-denote-directory))
    (denote-project-notes--save-variable 'denote-project-notes-denote-directory directory)))

(defun denote-project-notes-show ()
  "Show the current project's notes.

The current project's notes are determined by:

 - `denote-project-notes-identifier', notes file identifier
 - `denote-project-notes-denote-directory', Denote silo

The notes are displayed using `display-buffer', following
`denote-project-notes-display-action' (if non-nil)."
  (interactive)
  (when-let* ((denote-directory denote-project-notes-denote-directory)
              (identifier (denote-project-notes--get-identifier))
              (filename (car (denote-directory-files identifier)))
              (buffer (or (get-file-buffer filename)
                          (find-file-noselect filename))))
    (if denote-project-notes-display-action
        (display-buffer buffer denote-project-notes-display-action)
      (display-buffer buffer))))

(defun denote-project-notes-dwim (arg)
  "Show or set project notes.

If one prefix ARG, force setting the project's Denote identifier.  If
two prefix arguments are used, pass one to
`denote-project-notes-set-identifier'."
  (interactive "p")
  (if (or (null (denote-project-notes--get-identifier)) (> arg 1))
      (let ((current-prefix-arg (if (> 4 arg) '(4) nil)))
        (call-interactively #'denote-project-notes-set-identifier))
    (call-interactively #'denote-project-notes-show)))

(provide 'denote-project-notes)
;;; denote-project-notes.el ends here
