-._protesilaos-s-___denote_.../denote-link.el
2022-06-24 06:52:30 +03:00

603 lines
24 KiB
EmacsLisp

;;; denote-link.el --- Link facility for Denote -*- lexical-binding: t -*-
;; Copyright (C) 2022 Free Software Foundation, Inc.
;; Author: Protesilaos Stavrou <info@protesilaos.com>
;; URL: https://git.sr.ht/~protesilaos/denote
;; Mailing list: https://lists.sr.ht/~protesilaos/denote
;; Version: 0.1.0
;; Package-Requires: ((emacs "27.2"))
;; This file is NOT part of GNU Emacs.
;; 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:
;;
;; The `denote-link' command inserts a link at point to an entry specified
;; at the minibuffer prompt. Links are formatted depending on the file
;; type of current note. In Org and plain text buffers, links are
;; formatted thus: `[[denote:IDENTIFIER][TITLE]]'. While in Markdown they
;; are expressed as `[TITLE](denote:IDENTIFIER)'.
;;
;; When `denote-link' is called with a prefix argument (`C-u' by default)
;; it formats links like `[[denote:IDENTIFIER]]'. The user might prefer
;; its simplicity.
;;
;; When the user option `denote-link-use-org-id' is set to non-nil (default
;; is nil), inserted links in Org notes that target other Org notes will
;; use the standard `id:' type so the format is `[[id:IDENTIFIER][TITLE]]'
;; (the title is omitted is `denote-link' is called with a prefix argument,
;; as explained above). When, however, an Org note links to a note in
;; another file, the link with use our own `denote:' type as there is no
;; standard for this case.
;;
;; Inserted links are automatically buttonized and remain active for as
;; long as the buffer is available. In Org this is handled automatically
;; as Denote either uses the standard `id:' link type or creates its own
;; custom hyperlink: the `denote:' type which works exactly like the
;; `file:'. In Markdown and plain text, Denote handles the buttonization
;; of those links.
;;
;; To buttonize links in existing files while visiting them, the user must
;; add this snippet to their setup:
;;
;; (add-hook 'find-file-hook #'denote-link-buttonize-buffer)
;;
;; Denote has a major-mode-agnostic mechanism to collect all linked file
;; references in the current buffer and return them as an appropriately
;; formatted list. This list can then be used in interactive commands.
;; The `denote-link-find-file' is such a command. It uses minibuffer
;; completion to visit a file that is linked to from the current note.
;; The candidates have the correct metadata, which is ideal for
;; integration with other standards-compliant tools (see the manual's
;; "Extending Denote"). For instance, a package such as `marginalia'
;; will display accurate annotations, while the `embark' package will be
;; able to work its magic such as in exporting the list into a filtered
;; Dired buffer (i.e. a familiar Dired listing with only the files of
;; the current minibuffer session).
;;
;; The command `denote-link-backlinks' produces a bespoke buffer which
;; displays the file name of all notes linking to the current one. Each
;; file name appears on its own line and is buttonized so that it performs
;; the action of visiting the referenced file. The backlinks' buffer looks
;; like this:
;;
;; Backlinks to "On being honest" (20220614T130812)
;; ------------------------------------------------
;;
;; 20220614T145606--let-this-glance-become-a-stare__journal.txt
;; 20220616T182958--not-feeling-butterflies-in-your-stomach__journal.txt
;;
;; The backlinks' buffer is fontified by default, though the user has
;; access to the `denote-link-fontify-backlinks' option to disable this
;; effect by setting its value to nil.
;;
;; The placement of the backlinks' buffer is subject to the user option
;; `denote-link-backlinks-display-buffer-action'. Due to the nature of the
;; underlying `display-buffer' mechanism, this inevitably is an advanced
;; feature. By default, the backlinks' buffer is displayed below the
;; current window. The doc string of our user option includes a
;; configuration that places the buffer in a left side window instead.
;; Reproducing it here for your convenience:
;;
;; (setq denote-link-backlinks-display-buffer-action
;; '((display-buffer-reuse-window
;; display-buffer-in-side-window)
;; (side . left)
;; (slot . 99)
;; (window-width . 0.3)))
;;
;; The command `denote-link-add-links' adds links at point matching a
;; regular expression or plain string. The links are inserted as a
;; typographic list, such as:
;;
;; - link1
;; - link2
;; - link3
;;
;; Each link is formatted according to the file type of the current note,
;; as explained further above about the `denote-link' command. The current
;; note is excluded from the matching entries (adding a link to itself is
;; pointless).
;;
;; When called with a prefix argument (`C-u') `denote-link-add-links' will
;; format all links as `[[TYPE:IDENTIFIER]]', hence a typographic list:
;;
;; - [[TYPE:IDENTIFIER-1]]
;; - [[TYPE:IDENTIFIER-2]]
;; - [[TYPE:IDENTIFIER-3]]
;;
;; The `TYPE' is either `denote:' or `id:', exactly as we explained above
;; for the `denote-link' command.
;;
;; Same examples of a regular expression that can be used with this
;; command:
;;
;; - `journal' match all files which include `journal' anywhere in their
;; name.
;;
;; - `_journal' match all files which include `journal' as a keyword.
;;
;; - `^2022.*_journal' match all file names starting with `2022' and
;; including the keyword `journal'.
;;
;; - `\.txt' match all files including `.txt'. In practical terms, this
;; only applies to the file extension, as Denote automatically removes
;; dots (and other characters) from the base file name.
;;
;; If files are created with `denote-sort-keywords' as non-nil (the
;; default), then it is easy to write a regexp that includes multiple
;; keywords in alphabetic order:
;;
;; - `_denote.*_package' match all files that include both the `denote' and
;; `package' keywords, in this order.
;;
;; - `\(.*denote.*package.*\)\|\(.*package.*denote.*\)' is the same as
;; above, but out-of-order.
;;
;; Remember that regexp constructs only need to be escaped once (like `\|')
;; when done interactively but twice when called from Lisp. What we show
;; above is for interactive usage.
;;
;; For convenience, the `denote-link' command has an alias called
;; `denote-link-insert-link'. The `denote-link-backlinks' can also be used
;; as `denote-link-show-backlinks-buffer'. While `denote-link-add-links'
;; is aliased `denote-link-insert-links-matching-regexp'. The purpose of
;; these aliases is to offer alternative, more descriptive names of select
;; commands.
;;; Code:
(require 'denote-retrieve)
(defgroup denote-link ()
"Link facility for Denote."
:group 'denote)
;;;; User options
(defcustom denote-link-fontify-backlinks t
"When non-nil, apply faces to files in the backlinks' buffer."
:type 'boolean
:group 'denote-link)
(defcustom denote-link-backlinks-display-buffer-action
'((display-buffer-reuse-window display-buffer-below-selected)
(window-height . fit-window-to-buffer))
"The action used to display the current file's backlinks buffer.
The value has the form (FUNCTION . ALIST), where FUNCTION is
either an \"action function\", a list thereof, or possibly an
empty list. ALIST is a list of \"action alist\" which may be
omitted (or be empty).
Sample configuration to display the buffer in a side window on
the left of the Emacs frame:
(setq denote-link-backlinks-display-buffer-action
(quote ((display-buffer-reuse-window
display-buffer-in-side-window)
(side . left)
(slot . 99)
(window-width . 0.3))))
See Info node `(elisp) Displaying Buffers' for more details
and/or the documentation string of `display-buffer'."
:type '(cons (choice (function :tag "Display Function")
(repeat :tag "Display Functions" function))
alist)
:group 'denote-link)
(defcustom denote-link-use-org-id nil
"When non-nil use the ID link type in Org files if appropriate.
Newly created links from Org notes which target other Org notes
will use the standard `id:' hyperlink type instead of the custom
`denote:' type. If the target's file type is not Org, our own
`denote:' type is used.
In practical terms, the ID ensures maximum compatibility with
other tools in the Org ecosystem.
When the value is nil, Denote links rely on the custom `denote:'
hyperlink type (which should behave the same as the standard
`file:' type).
Other files types beside Org always use the `denote:' links."
:type 'boolean
:group 'denote-link)
;;;###autoload (put 'denote-link-use-org-id 'safe-local-variable 'booleanp)
;;;; Link to note
;; Arguments are: FILE-ID FILE-TITLE
(defconst denote-link--format-org "[[denote:%s][%s]]"
"Format of Org link to note.")
(defconst denote-link--format-org-with-id "[[id:%s][%s]]"
"Format of Org link to note for `denote-link-use-org-id'.")
(defconst denote-link--format-markdown "[%2$s](denote:%1$s)"
"Format of Markdown link to note.")
(defconst denote-link--format-id-only "[[denote:%s]]"
"Format of identifier-only link to note.")
(defconst denote-link--format-id-only-with-org-id "[[id:%s]]"
"Format of identifier-only link to note with Org id link type.")
(defconst denote-link--regexp-org
(concat "\\[\\[" "\\(denote\\|[Ii][Dd]\\):" "\\(?1:" denote--id-regexp "\\)" "]" "\\[.*?]]"))
(defconst denote-link--regexp-markdown
(concat "\\[.*?]" "(denote:" "\\(?1:" denote--id-regexp "\\)" ")"))
(defconst denote-link--regexp-plain
(concat "\\[\\[" "denote:" "\\(?1:" denote--id-regexp "\\)" "]]"))
(defun denote-link--file-type-format (current-file target-file id-only)
"Return link format based on CURRENT-FILE format.
Account for TARGET-FILE format when choosing the format.
With non-nil ID-ONLY, use the generic link format without a
title."
;; Includes backup files. Maybe we can remove them?
(let* ((current-file-ext (file-name-extension current-file))
(target-file-ext (file-name-extension target-file))
(use-org-id (and denote-link-use-org-id (string= target-file-ext "org"))))
(cond
(id-only
(if use-org-id
denote-link--format-id-only-with-org-id
denote-link--format-id-only))
((string= current-file-ext "md")
denote-link--format-markdown)
((string= current-file-ext "txt")
denote-link--format-org) ; Plain text uses [[denote:ID][TITLE]]
(t (if use-org-id
denote-link--format-org-with-id
denote-link--format-org)))))
(defun denote-link--file-type-regexp (file)
"Return link regexp based on FILE format."
(pcase (file-name-extension file)
("md" denote-link--regexp-markdown)
(_ denote-link--regexp-org)))
(defun denote-link--format-link (file pattern)
"Prepare link to FILE using PATTERN."
(let ((file-id (denote-retrieve--filename-identifier file))
(file-title (unless (string= pattern denote-link--format-id-only)
(denote-retrieve--value-title file))))
(format pattern file-id file-title)))
;;;###autoload
(defun denote-link (target &optional id-only)
"Create link to TARGET note in variable `denote-directory'.
With optional ID-ONLY, such as a universal prefix
argument (\\[universal-argument]), insert links with just the
identifier and no further description. In this case, the link
format is always [[denote:IDENTIFIER]]."
(interactive (list (denote-retrieve--read-file-prompt) current-prefix-arg))
(let ((beg (point)))
(insert
(denote-link--format-link
target
(denote-link--file-type-format (buffer-file-name) target id-only)))
(unless (derived-mode-p 'org-mode)
(make-button beg (point) 'type 'denote-link-button))))
(defalias 'denote-link-insert-link (symbol-function 'denote-link))
(defun denote-link--collect-identifiers (regexp)
"Return collection of identifiers in buffer matching REGEXP."
(let (matches)
(save-excursion
(goto-char (point-min))
(while (re-search-forward regexp nil t)
(push (match-string-no-properties 1) matches)))
matches))
(defun denote-link--expand-identifiers (regexp)
"Expend identifiers matching REGEXP into file paths."
(delq nil (mapcar (lambda (i)
(file-name-completion i (denote-directory)))
(denote-link--collect-identifiers regexp))))
(defvar denote-link--find-file-history nil
"History for `denote-link-find-file'.")
(defun denote-link--find-file-prompt (files)
"Prompt for linked file among FILES."
(completing-read "Find linked file "
(denote--completion-table 'file files)
nil t
nil 'denote-link--find-file-history))
;; TODO 2022-06-14: Do we need to add any sort of extension to better
;; integrate with Embark? For the minibuffer interaction it is not
;; necessary, but maybe it can be done to immediately recognise the
;; identifiers are links to files?
;;;###autoload
(defun denote-link-find-file ()
"Use minibuffer completion to visit linked file."
(interactive)
(if-let* ((regexp (denote-link--file-type-regexp (buffer-file-name)))
(files (denote-link--expand-identifiers regexp)))
(find-file (denote-link--find-file-prompt files))
(user-error "No links found in the current buffer")))
;;;; Link buttons
;; Evaluate: (info "(elisp) Button Properties")
;;
;; Button can provide a help-echo function as well, but I think we might
;; not need it.
(define-button-type 'denote-link-button
'follow-link t
'action #'denote-link--find-file-at-button)
(autoload 'thing-at-point-looking-at "thingatpt")
(defun denote-link--link-at-point-string ()
"Return identifier at point."
(when (or (thing-at-point-looking-at denote-link--regexp-plain)
(thing-at-point-looking-at denote-link--regexp-markdown)
(thing-at-point-looking-at denote-link--regexp-org)
;; Meant to handle the case where a link is broken by
;; `fill-paragraph' into two lines, in which case it
;; buttonizes only the "denote:ID" part. Example:
;;
;; [[denote:20220619T175212][This is a
;; test]]
;;
;; Maybe there is a better way?
(thing-at-point-looking-at "\\[\\(denote:.*\\)]"))
(match-string-no-properties 0)))
(defun denote-link--id-from-string (string)
"Extract identifier from STRING."
(replace-regexp-in-string
(concat ".*denote:" "\\(" denote--id-regexp "\\)" ".*")
"\\1" string))
;; NOTE 2022-06-15: I add this as a variable for advanced users who may
;; prefer something else. If there is demand for it, we can make it a
;; defcustom, but I think it would be premature at this stage.
(defvar denote-link-buton-action #'find-file-other-window
"Action for Denote buttons.")
(defun denote-link--find-file-at-button (button)
"Visit file referenced by BUTTON."
(let ((id (denote-link--id-from-string
(buffer-substring-no-properties
(button-start button)
(button-end button)))))
(funcall denote-link-buton-action (file-name-completion id (denote-directory)))))
;;;###autoload
(defun denote-link-buttonize-buffer (&optional beg end)
"Make denote: links actionable buttons in the current buffer.
Add this to `find-file-hook'. It will only work with Denote
notes and will not do anything in `org-mode' buffers, as buttons
already work there. If you do not use Markdown or plain text,
then you do not need this.
When called from Lisp, with optional BEG and END as buffer
positions, limit the process to the region in-between."
(when (and (not (derived-mode-p 'org-mode)) (denote--current-file-is-note-p))
(save-excursion
(goto-char (or beg (point-min)))
(while (re-search-forward denote--id-regexp end t)
(when-let ((string (denote-link--link-at-point-string))
(beg (match-beginning 0))
(end (match-end 0)))
(make-button beg end 'type 'denote-link-button))))))
;;;; Backlinks' buffer
(define-button-type 'denote-link-backlink-button
'follow-link t
'action #'denote-link--backlink-find-file
'face 'unspecified) ; we use this face attribute to style it later
(defun denote-link--backlink-find-file (button)
"Action for BUTTON to `find-file'."
(funcall denote-link-buton-action (buffer-substring (button-start button) (button-end button))))
(declare-function denote-dired-mode "denote-dired")
(defun denote-link--display-buffer (buf)
"Run `display-buffer' on BUF.
Expand `denote-link-backlinks-display-buffer-action'."
(display-buffer
buf
`(,@denote-link-backlinks-display-buffer-action)))
(defun denote-link--prepare-backlinks (id files &optional title)
"Create backlinks' buffer for ID including FILES.
Use optional TITLE for a prettier heading."
(let ((inhibit-read-only t)
(buf (format "*denote-backlinks to %s*" id)))
(with-current-buffer (get-buffer-create buf)
(erase-buffer)
(special-mode)
(goto-char (point-min))
(when-let* ((title)
(heading (format "Backlinks to %S (%s)" title id))
(l (length heading)))
(insert (format "%s\n%s\n\n" heading (make-string l ?-))))
(mapc (lambda (f)
(insert (file-name-nondirectory f))
(make-button (point-at-bol) (point-at-eol) :type 'denote-link-backlink-button)
(newline))
files)
(goto-char (point-min))
;; NOTE 2022-06-15: Technically this is not Dired. Maybe we
;; should abstract the fontification into a general purpose
;; minor-mode.
(when denote-link-fontify-backlinks
(denote-dired-mode 1)))
(denote-link--display-buffer buf)))
;;;###autoload
(defun denote-link-backlinks ()
"Produce a buffer with files linking to current note.
Each file is a clickable/actionable button that visits the
referenced entry. Files are fontified if the user option
`denote-link-fontify-backlinks' is non-nil.
The placement of the backlinks' buffer is controlled by the user
option `denote-link-backlinks-display-buffer-action'. By
default, it will show up below the current window."
(interactive)
(let* ((default-directory (denote-directory))
(file (file-name-nondirectory (buffer-file-name)))
(id (denote-retrieve--filename-identifier file))
(title (denote-retrieve--value-title file)))
(if-let ((files (denote-retrieve--proces-grep id)))
(denote-link--prepare-backlinks id files title)
(user-error "No links to the current note"))))
(defalias 'denote-link-show-backlinks-buffer (symbol-function 'denote-link-backlinks))
;;;; Add links matching regexp
(defvar denote-link--links-to-files nil
"String of `denote-link-add-links-matching-keyword'.")
(defvar denote-link--prepare-links-format "- %s\n"
"Format specifiers for `denote-link-add-links'.")
;; NOTE 2022-06-16: There is no need to overwhelm the user with options,
;; though I expect someone to want to change the sort order.
(defvar denote-link-add-links-sort nil
"Add REVERSE to `sort-lines' of `denote-link-add-links' when t.")
(defun denote-link--prepare-links (files current-file id-only)
"Prepare links to FILES from CURRENT-FILE.
When ID-ONLY is non-nil, use a generic link format. See
`denote-link--file-type-format'."
(setq denote-link--links-to-files
(with-temp-buffer
(mapc (lambda (file)
(insert
(format
denote-link--prepare-links-format
(denote-link--format-link
file
(denote-link--file-type-format current-file file id-only)))))
files)
(sort-lines denote-link-add-links-sort (point-min) (point-max))
(buffer-string))))
(defvar denote-link--add-links-history nil
"Minibuffer history for `denote-link-add-links'.")
;;;###autoload
(defun denote-link-add-links (regexp &optional id-only)
"Insert links to all notes matching REGEXP.
Use this command to reference multiple files at once.
Particularly useful for the creation of metanotes (read the
manual for more on the matter).
Optional ID-ONLY has the same meaning as in `denote-link': it
inserts links with just the identifier."
(interactive
(list
(read-regexp "Insert links matching REGEX: " nil 'denote-link--add-links-history)
current-prefix-arg))
(let* ((default-directory (denote-directory))
(current-file (buffer-file-name)))
(if-let ((files (denote--directory-files-matching-regexp regexp)))
(let ((beg (point)))
(insert (denote-link--prepare-links files current-file id-only))
(unless (derived-mode-p 'org-mode)
(denote-link-buttonize-buffer beg (point))))
(user-error "No links matching `%s'" regexp))))
(defalias 'denote-link-insert-links-matching-regexp (symbol-function 'denote-link-add-links))
;;;; Register `denote:' custom Org hyperlink
(declare-function org-link-set-parameters "ol.el" (type &rest parameters))
(org-link-set-parameters
"denote"
:follow #'denote-link-ol-follow
:complete #'denote-link-ol-complete
:export #'denote-link-ol-export)
(declare-function org-link-open-as-file "ol" (path arg))
(defun denote-link--ol-resolve-link-to-target (link &optional path-id)
"Resolve LINK into the appropriate target.
With optional PATH-ID return a cons cell consisting of the path
and the identifier."
(let* ((search (and (string-match "::\\(.*\\)\\'" link)
(match-string 1 link)))
(id (if (and (stringp search) (not (string-empty-p search)))
(substring link 0 (match-beginning 0))
link))
(path (expand-file-name (file-name-completion id (denote-directory)))))
(cond
(path-id
(cons (format "%s" path) (format "%s" id)))
((and (stringp search) (not (string-empty-p search)))
(concat path "::" search))
(path))))
(defun denote-link-ol-follow (link)
"Find file of type `denote:' matching LINK.
LINK is the identifier of the note, optionally followed by a
search option akin to that of standard Org `file:' link types.
Read Info node `(org) Search Options'.
Uses the function `denote-directory' to establish the path to the
file."
(org-link-open-as-file
(denote-link--ol-resolve-link-to-target link)
nil))
(defun denote-link-ol-complete ()
"Like `denote-link' but for Org integration.
This lets the user complete a link through the `org-insert-link'
interface by first selecting the `denote:' hyperlink type."
(concat
"denote:"
(denote-retrieve--filename-identifier (denote-retrieve--read-file-prompt))))
(defun denote-link-ol-export (link description format)
"Export a `denote:' link from Org files.
The LINK, DESCRIPTION, and FORMAT are handled by the export
backend."
(let* ((path-id (denote-link--ol-resolve-link-to-target link :path-id))
(path (file-name-nondirectory (car path-id)))
(p (file-name-sans-extension path))
(id (cdr path-id))
(desc (or description (concat "denote:" id))))
(cond
((eq format 'html) (format "<a target=\"_blank\" href=\"%s.html\">%s</a>" p desc))
((eq format 'latex) (format "\\href{%s}{%s}" (replace-regexp-in-string "[\\{}$%&_#~^]" "\\\\\\&" path) desc))
((eq format 'texinfo) (format "@uref{%s,%s}" path desc))
((eq format 'ascii) (format "[%s] <denote:%s>" desc path)) ; NOTE 2022-06-16: May be tweaked further
((eq format 'md) (format "[%s](%s.md)" desc p))
(t path))))
(provide 'denote-link)
;;; denote-link.el ends here