~protesilaos/denote

6bb6da088211324cdd1ad35396c2988ccb2eccb7 — Noboru Ota 1 year, 10 months ago e20511b
feat: project.el integration & backlinks refactoring with xref

The main intent of the patch is as follows:

- To simplify the implementations of functions related to backlinks that
  rely on the Xref library

- To add minimal implementations to integrate Denote with project.el

  This is necessary for simplification of Xref/backlinks mentioned
  above.

  It has given an opportunity to refactor 'denote-file-prompt' to let
  you search notes under all sub-directories of 'denote-directory'.  The
  original 'read-file-name' could only search within only single
  'denote-directory'.

  This enhances the following commands: 'denote-open-or-create',
  'denote-link', 'denote-link-or-create', and 'denote-link-ol-complete'.

  The project.el integration also lets users set something like this
  below in their configuration.  This way, they can generically use
  project.el facilities for Denote such as 'project-find-file' and
  'project-find-regexp' without a version management tool (e.g. Git)

      (add-hook 'project-find-functions #'denote-project-find)

Following is more detail of changes for each function:

* denote.el

(denote-file-prompt): Refactored

- To use the same completion function as 'project-find-file' does.

- The main benefit is that it can let you search notes under all
  sub-directories of 'denote-directory'.  The original 'read-file-name'
  could only search within only single 'denote-directory'

- Note the impact on commands that use 'denote-file-prompt';
  i.e. 'denote-open-or-create', 'denote-link', 'denote-link-or-create',
  and 'denote-link-ol-complete'

- One minor annoyance may be that the prompt now requires a confirmation
  if the user enters text that does not match any of the candidate and
  tries to exit

- 'denote--title-history' is directly updated from the minibuffer
  completion function.  This eliminates the need for
  'denote--push-extracted-title-to-history'.  The original used
  'file-name-history' as the intermediate storage of titles, which are
  not really file names, thus resulted in polluting the history for file
  names

(denote--retrieve-xrefs): Removed

- No longer used. It was only used by 'denote--retrieve-process-grep',
  which has now been removed.

(denote--retrieve-files-in-xrefs): Refactored

- To take IDENTIFIER as the argument.  No change to the returned
  values. This is for this function to be compatible with the removal of
  'denote--retrieve-xrefs'.  Retrieving files do not need to use the
  intermediate xref-alist, which is a duplicate of work.  This change
  lets this function directly retrieve file name (group) from xrefs (not
  xref-alist) with using Xref public methods

(denote--retrieve-process-grep): Removed

- No longer used. It was only used by 'denote-link--prepare-backlinks'
  to retrieve xref-alist for the purpose of creating the backlinks
  buffer.  Creation of the backlinks buffer has now been refactored to
  get closer to the built-in Xref.  See 'denote-link-find-backlink' and
  'denote-link--prepare-backlinks'

(denote--push-extracted-title-to-history): Removed

- No longer used. See commit message fro 'denote-file-prompt' above

(denote-open-or-create): Refactored

- To not use 'denote--push-extracted-title-to-history', which has been
  removed

(denote-link-find-backlink): Refactored

- To not use 'denote--retrieve-xrefs', which has been removed

(denote-link-or-create): Refactored

- To not use 'denote--push-extracted-title-to-history', which has been
  removed

(denote-backlinks-mode): Refactored

- To locally set 'denote-project-find'. This is be compatible with the
  new project.el integration.  The change is necessary for the backlinks
  buffer to correctly revert by identifying the denote project root

(denote-link--prepare-backlinks): Refactored

- To set the correct revert function with using the new way

(denote-link-backlinks): Refactored

- To use 'xref--show-xrefs' to create and show backlinks buffer.  The
  double-hyphen in the name 'xref--show-xrefs' suggests it is a private
  function; however, in the current development branch of Xref, there is
  a new public function 'xref-show-xrefs'.  It is a wrapper function
  with the same arguments used in this patch, thus we are further
  aligning Denote with the direction of Xref development

(project-root): (project-files): (denote-project-find): Added

- These are Denote specific implementations of generic functions defined
  in project.el and a function for a hook to let Denote use them.  This
  patch contains many built-in Xref functions, which rely on project.el
  to identify the "root" of the project.  Denote explicitly defines its
  root with 'denote-directory'. These newly created methods and function
  connect Denote and project.el generic.  This way, users without a
  version management tool (e.g. Git) for their notes can benefit from
  project.el.  See one of such major benefits with 'denote-file-prompt'
  and the related Denote commands above.

- This change also let users set something like this below in their
  configuration to generically use project.el facilities such as
  'project-find-file'.

   (add-hook 'project-find-functions #'denote-project-find)

* denote-org-dblock.el

(org-dblock-write:denote-backlinks): Refactored

-  To align with the change in 'denote--retrieve-files-in-xrefs' as
  above
2 files changed, 80 insertions(+), 78 deletions(-)

M denote-org-dblock.el
M denote.el
M denote-org-dblock.el => denote-org-dblock.el +1 -2
@@ 139,8 139,7 @@ Used by `org-dblock-update' with PARAMS provided by the dynamic block."
Used by `org-dblock-update' with PARAMS provided by the dynamic block."
  (when-let* ((file (buffer-file-name))
              (id (denote-retrieve-filename-identifier file))
              (files (denote--retrieve-files-in-xrefs
                      (denote--retrieve-process-grep id))))
              (files (delete file (denote--retrieve-files-in-xrefs id)))))
    (insert (denote-link--prepare-links files file nil))
    (join-line))) ;; remove trailing empty line


M denote.el => denote.el +79 -76
@@ 719,10 719,13 @@ whatever matches `denote-excluded-directories-regexp'."
(defun denote-file-prompt (&optional initial-text)
  "Prompt for file with identifier in variable `denote-directory'.
With optional INITIAL-TEXT, use it to prepopulate the minibuffer."
  (read-file-name "Select note: " (denote-directory) nil nil initial-text
                  (lambda (f)
                    (or (denote-file-has-identifier-p f)
                        (denote-file-directory-p f)))))
  (let* ((project-find-functions #'denote-project-find)
         (project (project-current nil (denote-directory)))
         (dirs (list (project-root project)))
         (all-files (project-files project dirs))
         (completion-ignore-case read-file-name-completion-ignore-case))
    (funcall project-read-file-name-function
             "Select note: " all-files nil 'denote--title-history initial-text)))

(define-obsolete-function-alias
  'denote--retrieve-read-file-prompt


@@ 1245,50 1248,16 @@ Run `denote-desluggify' on title if the extraction is sucessful."
      title
    (denote-retrieve-filename-title file)))

(defun denote--retrieve-xrefs (identifier &optional file)
  "Return xrefs of IDENTIFIER in variable `denote-directory'.
The xrefs are returned as an alist of the form:

    ((GROUP . (XREF ...)) ...)

GROUP is an absolute file name as retrieved by Xref facility.

When FILE is present, remove its GROUP from the alist."
  (let ((alist
         (xref--alistify
          (xref-matches-in-files identifier (denote-directory-text-only-files))
          (lambda (x)
            (xref-location-group (xref-item-location x))))))
    (if file (assoc-delete-all file alist) alist)))

(defun denote--retrieve-files-in-xrefs (xref-alist)
  "Return sorted, deduplicated file names from XREF-ALIST."
(defun denote--retrieve-files-in-xrefs (identifier)
  "Return sorted, deduplicated file names from IDENTIFIER."
  (sort
   (delete-dups (mapcar #'car xref-alist))
   (delete-dups
    (mapcar #'xref-location-group
            (mapcar #'xref-match-item-location
                    (xref-matches-in-files identifier
                                           (denote-directory-text-only-files)))))
   #'string-lessp))

(defun denote--retrieve-process-grep (identifier)
  "Process lines matching IDENTIFIER and return list of xref-alist.

The alist is of the form ((GROUP . (XREF ...)) ...).

The alist excludes GROUP for the file that current buffer is
visiting so that only its backlinks are colleced.

In addition, GROUP is a transformed to filename relative to
variable `denote-directory', which is the string displayed in the
backlinks' buffer."
  ;;; This `mapcar' form is doing what function `xref--analyze' would
  ;;; do.  `xref--analyze' can be flexibly configured but is not used
  ;;; directly here because it assumes that the current directory is in
  ;;; a "project" as defined in project.el.  For Denote, this is not the
  ;;; case (at least as at the time of this writing).
  (mapcar
   (lambda (xref)
     (cons (denote-get-file-name-relative-to-denote-directory (car xref))
           (cdr xref)))
   (denote--retrieve-xrefs identifier (buffer-file-name))))

;;;; New note

;;;;; Common helpers for new notes


@@ 1726,13 1695,6 @@ set to \\='(template title keywords)."
    (string-match (denote-directory) title)
    (substring title (match-end 0))))

(defun denote--push-extracted-title-to-history ()
  "Add `denote--extract-title-from-file-history' to `denote--title-history'."
  (when-let* ((last-input (denote--extract-title-from-file-history))
              ((not (string-empty-p last-input)))
              ((not (string-blank-p last-input))))
    (push last-input denote--title-history)))

;;;###autoload
(defun denote-open-or-create (target)
  "Visit TARGET file in variable `denote-directory'.


@@ 1747,7 1709,6 @@ note's actual title.  At the `denote-title-prompt' type
  (interactive (list (denote-file-prompt)))
  (if (file-exists-p target)
      (find-file target)
    (denote--push-extracted-title-to-history)
    (call-interactively #'denote)))

;;;###autoload


@@ 2635,9 2596,7 @@ Like `denote-link-find-file', but select backlink to follow."
  (interactive)
  (if-let* ((file (buffer-file-name))
            (id (denote-retrieve-filename-identifier file))
            (files
             (denote--retrieve-files-in-xrefs
              (denote--retrieve-xrefs id (buffer-file-name)))))
            (files (delete file (denote--retrieve-files-in-xrefs id))))
      (find-file
       (denote-get-path-by-id
        (denote-extract-id-from-string


@@ 2694,7 2653,6 @@ file's title.  This has the same meaning as in `denote-link'."
  (interactive (list (denote-file-prompt) current-prefix-arg))
  (if (file-exists-p target)
      (denote-link target id-only)
    (denote--push-extracted-title-to-history)
    (call-interactively #'denote-link-after-creating)))

(defalias 'denote-link-to-existing-or-new-note (symbol-function 'denote-link-or-create))


@@ 2823,24 2781,37 @@ nil)."
(define-derived-mode denote-backlinks-mode xref--xref-buffer-mode "Backlinks"
  "Major mode for backlinks buffers."
  (unless denote-backlinks-show-context
    (font-lock-add-keywords nil denote-faces-file-name-keywords-for-backlinks t)))
    (font-lock-add-keywords nil denote-faces-file-name-keywords-for-backlinks t))
  (add-hook 'project-find-functions #'denote-project-find nil t))

(make-obsolete-variable 'denote-backlink-mode 'denote-backlinks-mode "0.6.0")

(defun denote-link--prepare-backlinks (id xref-alist &optional title)
  "Create backlinks' buffer for ID using XREF-ALIST.
Use optional TITLE for a prettier heading."
  (let ((inhibit-read-only t)
        (buf (format "*denote-backlinks to %s*" id))
        (file (buffer-file-name)))
(defun denote-link--prepare-backlinks (fetcher _alist)
  "Create backlinks' buffer for the current note.
FETCHER is a function that fetches a list of xrefs.  It is called
with `funcall' with no argument like `xref--fetcher'.

In the case of `denote', `apply-partially' is used to create a
function that has already applied another function to multiple
arguments.

ALIST is not used in favour of using
`denote-link-backlinks-display-buffer-action'."
  (let* ((inhibit-read-only t)
         (file (buffer-file-name))
         (file-type (denote-filetype-heuristics file))
         (id (denote-retrieve-filename-identifier file))
         (buf (format "*denote-backlinks to %s*" id))
         (xref-alist (xref--analyze (funcall fetcher))))
    (with-current-buffer (get-buffer-create buf)
      (setq-local default-directory (denote-directory))
      (erase-buffer)
      (setq overlay-arrow-position nil)
      (denote-backlinks-mode)
      (goto-char (point-min))
      (when-let* ((title)
                  (heading (format "Backlinks to %S (%s)" title id))
                  (l (length heading)))
      (when-let*  ((title (denote-retrieve-title-value file file-type))
                   (heading (format "Backlinks to %S (%s)" title id))
                   (l (length heading)))
        (insert (format "%s\n%s\n\n" heading (make-string l ?-))))
      (if denote-backlinks-show-context
          (xref--insert-xrefs xref-alist)


@@ 2852,9 2823,11 @@ Use optional TITLE for a prettier heading."
      (goto-char (point-min))
      (setq-local revert-buffer-function
                  (lambda (_ignore-auto _noconfirm)
                    (when-let ((buffer-file-name file)
                               (xref-alist (denote--retrieve-process-grep id)))
                      (denote-link--prepare-backlinks id xref-alist title)))))
                    (when-let ((buffer-file-name file))
                      (denote-link--prepare-backlinks
                       (apply-partially #'xref-matches-in-files id
                                        (delete file (denote-directory-text-only-files)))
                       nil)))))
    (denote-link--display-buffer buf)))

;;;###autoload


@@ 2875,12 2848,14 @@ default, it will show up below the current window."
  (let ((file (buffer-file-name)))
    (when (denote-file-is-writable-and-supported-p file)
      (let* ((id (denote-retrieve-filename-identifier file))
             (file-type (denote-filetype-heuristics file))
             (title (denote-retrieve-title-value file file-type)))
        (if-let ((xref-alist (denote--retrieve-process-grep id)))
            (progn (xref--push-markers)
                   (denote-link--prepare-backlinks id xref-alist title))
          (user-error "No links to the current note"))))))
             (xref-show-xrefs-function #'denote-link--prepare-backlinks)
             (project-find-functions #'denote-project-find))
        (xref--show-xrefs
         (apply-partially #'xref-matches-in-files id
                          ;; remove the current buffer file from the
                          ;; backlinks
                          (delete file (denote-directory-text-only-files)))
         nil)))))

(defalias 'denote-link-show-backlinks-buffer (symbol-function 'denote-link-backlinks))



@@ 3181,5 3156,33 @@ Consult the manual for template samples."
(make-obsolete 'denote-migrate-old-org-filetags nil "1.1.0")
(make-obsolete 'denote-migrate-old-markdown-yaml-tags nil "1.1.0")


;;;; project.el integration
;;   This is also used by xref integration

(cl-defmethod project-root ((project (head denote)))
  "Denote's implementation of `project-root' method from `project'.
Return current variable `denote-directory' as the root of the
current denote PROJECT."
  (cdr project))

(cl-defmethod project-files ((_project (head denote)) &optional _dirs)
  "Denote's implementation of `project-files' method from `project'.
Return all files that have an identifier for the current denote
PROJECT.  The return value may thus include file types that are
not implied by `denote-file-type'.  To limit the return value to
text files, use the function `denote-directory-text-only-files'."
  (denote-directory-files))

(defun denote-project-find (dir)
  "Return project instance if DIR is part of variable `denote-directory'.
The format of project instance is aligned with `project-try-vc'
defined in `project'."
  (let ((dir (expand-file-name dir)) ; canonicalize current directory name
        (root (denote-directory)))
    (when (or (file-equal-p dir root) ; currently at `denote-directory'
              (string-prefix-p root dir)) ; or its subdirectory
      (cons 'denote root))))

(provide 'denote)
;;; denote.el ends here