-._protesilaos-s-___denote_.../denote-link.el
Protesilaos Stavrou 90875d5419
Sort backlinks from oldest to newest
I think we do not need a user option for this, as too many options make
it difficult to understand what is really needed to get started.  Though
the 'denote-link-backlinks-sort' variable can be set to t to reverse the
order.
2022-06-17 07:16:46 +03:00

394 lines
15 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
;; 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 linking facility is subject to review and there will likely be
;; breaking changes. This is the only area that needs to be fixed
;; before we release the first stable version of the package.
;;; 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-register-ol-hyperlink t
"When non-nil, register the `denote:' custom Org hyperlink type.
This practically means that the links Denote creates will behave
link ordinary links in Org files. They can be followed with a
mouse click or the `org-open-at-point' command, and they can be
insterted with completion via the `org-insert-link' command after
selecting the `denote:' hyperlink type.
When this option is nil, Denote links will not work properly in
Org files. All commands that Denote defines, such as
`denote-link-backlinks' and `denote-link-find-file' will work as
intended.
Note that if you do not want to `require' ol.el, you must set
this option to nil BEFORE loading denote-link.el."
: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)
;;;; 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-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--regexp-org
(concat "\\[\\[" "denote:" "\\(?1:" denote--id-regexp "\\)" "]" "\\[.*?]]"))
(defconst denote-link--regexp-markdown
(concat "\\[.*?]" "(denote:" "\\(?1:" denote--id-regexp "\\)" ")"))
(defun denote-link--file-type-format (file)
"Return link format based on FILE format."
(pcase (file-name-extension file)
("md" denote-link--format-markdown)
(_ denote-link--format-org))) ; Includes backup files. Maybe we can remove them?
(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)))
(defun denote-link--extension-format-or-id (id-only)
"Determine format for link.
If ID-ONLY is non-nil, use `denote-link--format-id-only', else
delegate to `denote-link--file-type-format'."
(if id-only
denote-link--format-id-only
(denote-link--file-type-format (buffer-file-name))))
;;;###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))
(insert
(denote-link--format-link
target
(denote-link--extension-format-or-id id-only))))
(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: We should document the use of Embark for
;; `denote-link-find-file'. Users are gonna love it!
;; 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")))
;;;; Backlinks' buffer
(define-button-type 'denote-link-find-file
'follow-link t
'action #'denote-link--find-file
'face 'unspecified)
;; 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-link--find-file'.")
(defun denote-link--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."
(display-buffer
buf
`(,@denote-link-backlinks-display-buffer-action)))
;; NOTE 2022-06-17: This is a `defvar' on purpose, like
;; `denote-link-add-links'. Read its comment.
(defvar denote-link-backlinks-sort nil
"Add REVERSE to `sort-lines' of `denote-link-backlinks' when t.")
(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))
start)
(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 ?-))))
(setq start (point))
(mapc (lambda (f)
(insert (file-name-nondirectory f))
(make-button (point-at-bol) (point-at-eol) :type 'denote-link-find-file)
(newline))
files)
(sort-lines denote-link-backlinks-sort start (point))
(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))
(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 ext)
"Prepare links to FILES using format of EXT."
(setq denote-link--links-to-files
(with-temp-buffer
(mapc (lambda (f)
(insert
(format denote-link--prepare-links-format
(denote-link--format-link f ext))))
files)
(sort-lines denote-link-add-links-sort (point-min) (point-max))
(let ((min (point-min))
(max (point-max)))
(buffer-substring-no-properties min max)))))
(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))
(ext (denote-link--extension-format-or-id id-only)))
(if-let ((files (denote--directory-files-matching-regexp regexp)))
(insert (denote-link--prepare-links files ext))
(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))
;; REVIEW 2022-06-15: Maybe there is a better way to make this optional.
(when denote-link-register-ol-hyperlink
(require 'ol)
(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."
(insert
(denote-link--format-link
(denote-retrieve--read-file-prompt)
(denote-link--file-type-format (buffer-file-name)))))
(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