branch: externals/denote
commit 2a3436e1112540868c388c577c6b88d6c1caac66
Merge: 90372d9627 27ef655bc8
Author: Protesilaos Stavrou <[email protected]>
Commit: Protesilaos Stavrou <[email protected]>
Merge branch 'sequence-notes-extension'
---
denote-sequence.el | 325 +++++++++++++++++++++++++++++++++++++++++++++++++++
tests/denote-test.el | 69 +++++++++++
2 files changed, 394 insertions(+)
diff --git a/denote-sequence.el b/denote-sequence.el
new file mode 100644
index 0000000000..c719706acc
--- /dev/null
+++ b/denote-sequence.el
@@ -0,0 +1,325 @@
+;;; denote-sequence.el --- Sequence notes extension for Denote -*-
lexical-binding: t -*-
+
+;; Copyright (C) 2024-2025 Free Software Foundation, Inc.
+
+;; Author: Protesilaos Stavrou <[email protected]>
+;; Maintainer: Protesilaos Stavrou <[email protected]>
+;; URL: https://github.com/protesilaos/denote
+
+;; 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:
+
+;; WORK-IN-PROGRESS. Sequence notes extension for Denote.
+
+;;; Code:
+
+;; FIXME 2024-12-25: Right now I am hardcoding the = as a field
+;; separator inside of the Denote signature. This is the default
+;; behaviour, though we provide the `denote-file-name-slug-functions'
+;; which, in principle, make the separator anything the user wants.
+;; If we can accommodate such open-endedness, then I am happy to make
+;; the relevant changes, but I prefer to keep it restricted at this
+;; early stage.
+;;
+;; Similarly, I am not giving the option for Luhmann-style sequences
+;; that include numbers and letters. Ours consist only of numbers,
+;; since (i) it is simpler and (ii) we already have the field
+;; separator to give a sufficient sense of place.
+
+(require 'denote)
+
+(defgroup denote-sequence ()
+ "Sequence notes extension for Denote."
+ :group 'denote
+ :link '(info-link "(denote) top")
+ :link '(url-link :tag "homepage" "https://protesilaos.com/emacs/denote"))
+
+(defconst denote-sequence-regexp "=?[0-9]+"
+ "Pattern of a sequence.")
+
+(defconst denote-sequence-types '(parent child sibling)
+ "Types of sequence.")
+
+(defun denote-sequence-p (sequence)
+ "Return SEQUENCE string if it matches `denote-sequence-regexp'."
+ (when (and (string-match-p denote-sequence-regexp sequence)
+ (not (string-match-p "[a-zA-Z]" sequence))
+ (not (string-suffix-p "=" sequence)))
+ sequence))
+
+(defun denote-sequence-file-p (file)
+ "Return non-nil if Denote signature of FILE is a sequence.
+A sequence is string that matches `denote-sequence-regexp'."
+ (when-let* ((signature (denote-retrieve-filename-signature file)))
+ (denote-sequence-p signature)))
+
+(defun denote-sequence-split (sequence)
+ "Split the SEQUENCE string into a list.
+SEQUENCE conforms with `denote-sequence-p'."
+ (if (denote-sequence-p sequence)
+ (split-string sequence "=" t)
+ (error "The sequence `%s' does not pass `denote-sequence-p'" sequence)))
+
+(defun denote-sequence-depth (sequence)
+ "Get the depth of SEQUENCE.
+For example, 1=2=1 is three levels deep."
+ (length (denote-sequence-split sequence)))
+
+(defun denote-sequence-get-all-files ()
+ "Return all files in variable `denote-directory' with a sequence.
+A sequence is a Denote signature that conforms with `denote-sequence-p'."
+ (seq-filter #'denote-sequence-file-p (denote-directory-files)))
+
+(defun denote-sequence-get-all-sequences (&optional files)
+ "Return all sequences in `denote-directory-files'.
+A sequence is a Denote signature that conforms with `denote-sequence-p'.
+
+With optional FILES return all sequences among them instead."
+ (delq nil (mapcar #'denote-sequence-file-p (or files
(denote-directory-files)))))
+
+(defun denote-sequence-get-all-sequences-with-prefix (sequence &optional
sequences)
+ "Get all sequences which extend SEQUENCE.
+A sequence is a Denote signature that conforms with `denote-sequence-p'.
+
+With optional SEQUENCES operate on those, else use the return value of
+`denote-sequence-get-all-sequences'."
+ (seq-filter
+ (lambda (string)
+ (string-prefix-p sequence string))
+ (or sequences (denote-sequence-get-all-sequences))))
+
+(defun denote-sequence-get-sequences-with-max-depth (depth &optional sequences)
+ "Get sequences up to DEPTH (inclusive).
+With optional SEQUENCES operate on those, else use the return value of
+`denote-sequence-get-all-sequences'."
+ (let* ((strings (or sequences (denote-sequence-get-all-sequences)))
+ (lists-all (mapcar #'denote-sequence-split strings))
+ (lists (seq-filter (lambda (element) (>= (length element) depth))
lists-all)))
+ (delete-dups
+ (mapcar
+ (lambda (sequence)
+ (mapconcat #'identity (seq-take sequence depth) "="))
+ lists))))
+
+(defun denote-sequence--pad (sequence type)
+ "Create a new SEQUENCE with padded spaces for TYPE.
+TYPE is a symbol among `denote-sequence-types'."
+ (let* ((sequence-separator-p (string-match-p "=" sequence))
+ (split (denote-sequence-split sequence))
+ (s (if sequence-separator-p
+ (pcase type
+ ('parent (car split))
+ ('sibling split)
+ ('child (car (nreverse split)))
+ (_ (error "The type `%s' is not among
`denote-sequence-types'" type)))
+ sequence)))
+ (if (listp s)
+ (combine-and-quote-strings
+ (mapcar
+ (lambda (part)
+ (string-pad part 5 32 :pad-from-start))
+ s)
+ "=")
+ (string-pad s 32 32 :pad-from-start))))
+
+(defun denote-sequence--get-largest (sequences type)
+ "Return largest sequence in SEQUENCES given TYPE.
+TYPE is a symbol among `denote-sequence-types'."
+ (car (sort sequences
+ :lessp (lambda (s1 s2)
+ (string<
+ (denote-sequence--pad s1 type)
+ (denote-sequence--pad s2 type)))
+ :reverse t)))
+
+(defun denote-sequence--get-new-parent (&optional sequences)
+ "Return a new to increment largest among sequences.
+With optional SEQUENCES consider only those, otherwise operate on the
+return value of `denote-sequence-get-all-sequences'."
+ (if-let* ((all (or sequences (denote-sequence-get-all-sequences))))
+ (let* ((largest (denote-sequence--get-largest all 'parent))
+ (first-component (car (denote-sequence-split largest)))
+ (current-number (string-to-number first-component)))
+ (number-to-string (+ current-number 1)))
+ "1"))
+
+(defun denote-sequence--get-new-child (sequence &optional sequences)
+ "Return a new child of SEQUENCE.
+Optional SEQUENCES has the same meaning as that specified in the
+function `denote-sequence-get-all-sequences-with-prefix'."
+ (if-let* ((depth (+ (denote-sequence-depth sequence) 1))
+ (all-unfiltered (denote-sequence-get-all-sequences-with-prefix
sequence sequences)))
+ (if (= (length all-unfiltered) 1)
+ (format "%s=1" (car all-unfiltered))
+ (let* ((all (cond
+ ((= (length all-unfiltered) 1)
+ all-unfiltered)
+ ((denote-sequence-get-sequences-with-max-depth depth
all-unfiltered))
+ (t all-unfiltered)))
+ (largest (denote-sequence--get-largest all 'child)))
+ (if (string-match-p "=" largest)
+ (let* ((components (denote-sequence-split largest))
+ (butlast (butlast components))
+ (last-component (car (nreverse components)))
+ (current-number (string-to-number last-component))
+ (new-number (number-to-string (+ current-number 1))))
+ (if butlast
+ (mapconcat #'identity (append butlast (list new-number))
"=")
+ (mapconcat #'identity (list largest new-number) "=")))
+ (format "%s=1" largest))))
+ (error "Cannot find sequences given sequence `%s'" sequence)))
+
+(defun denote-sequence--get-prefix-for-siblings (sequence)
+ "Get the prefix of SEQUENCE such that it is possible to find its siblings."
+ (when (string-match-p "=" sequence)
+ (mapconcat #'identity (butlast (denote-sequence-split sequence)) "=")))
+
+(defun denote-sequence--get-new-sibling (sequence &optional sequences)
+ "Return a new sibling SEQUENCE.
+Optional SEQUENCES has the same meaning as that specified in the
+function `denote-sequence-get-all-sequences-with-prefix'."
+ (let* ((children-p (string-match-p "=" sequence)))
+ (if-let* ((depth (denote-sequence-depth sequence))
+ (all-unfiltered (if children-p
+
(denote-sequence-get-all-sequences-with-prefix
+ (denote-sequence--get-prefix-for-siblings
sequence)
+ sequences)
+ (denote-sequence-get-all-sequences)))
+ (all (denote-sequence-get-sequences-with-max-depth depth
all-unfiltered))
+ ((member sequence all))
+ (largest (if children-p
+ (denote-sequence--get-largest all 'sibling)
+ (denote-sequence--get-largest all 'parent))))
+ (if children-p
+ (let* ((components (denote-sequence-split largest))
+ (butlast (butlast components))
+ (last-component (car (nreverse components)))
+ (current-number (string-to-number last-component))
+ (new-number (number-to-string (+ current-number 1))))
+ (mapconcat #'identity (append butlast (list new-number)) "="))
+ (number-to-string (+ (string-to-number largest) 1)))
+ (error "Cannot find sequences given sequence `%s'" sequence))))
+
+(defun denote-sequence-get (type &optional sequence)
+ "Return a sequence given TYPE among `denote-sequence-types'.
+If TYPE is either `child' or `sibling', then optional SEQUENCE must be
+non-nil and conform with `denote-sequence-p'."
+ (pcase type
+ ('parent (denote-sequence--get-new-parent))
+ ('child (denote-sequence--get-new-child sequence))
+ ('sibling (denote-sequence--get-new-sibling sequence))
+ (_ (error "The type `%s' is not among `denote-sequence-types'" type))))
+
+(defvar denote-sequence-type-history nil
+ "Minibuffer history of `denote-sequence-type-prompt'.")
+
+(defun denote-sequence-type-prompt ()
+ "Prompt for sequence type among `denote-sequence-types'.
+Return selected type as a symbol."
+ (let ((default (car denote-sequence-type-history)))
+ (intern
+ (completing-read
+ (format-prompt "Select sequence type" default)
+ denote-sequence-types nil :require-match nil
+ 'denote-sequence-type-history default))))
+
+(defvar denote-sequence-file-history nil
+ "Minibuffer history for `denote-sequence-file-prompt'.")
+
+(defun denote-sequence-file-prompt ()
+ "Prompt for file with sequence in variable `denote-directory'.
+A sequence is a Denote signature that conforms with `denote-sequence-p'."
+ (if-let* ((relative-files (mapcar
#'denote-get-file-name-relative-to-denote-directory
+ (denote-sequence-get-all-files)))
+ (prompt "Select FILE with sequence: ")
+ (input (completing-read
+ prompt
+ (denote--completion-table 'file relative-files)
+ nil :require-match
+ nil 'denote-sequence-file-history)))
+ (concat (denote-directory) input)
+ (error "There are no sequence notes in the `denote-directory'")))
+
+;;;###autoload
+(defun denote-sequence (type &optional file-with-sequence)
+ "Create a new sequence note of TYPE among `denote-sequence-types'.
+If TYPE is either `child' or `sibling', then it is an extension of SEQUENCE.
+
+When called interactively, prompt for TYPE and, when necessary, for
+FILE-WITH-SEQUENCE whose sequence will be used to derive a new sequence.
+Files available at the minibuffer prompt are those returned by
+`denote-sequence-get-all-files'."
+ (interactive
+ (let ((selected-type (denote-sequence-type-prompt)))
+ (list
+ selected-type
+ (when (memq selected-type (delq 'parent denote-sequence-types))
+ (denote-sequence-file-prompt)))))
+ ;; TODO 2024-12-30: Do we need to wrap this in the following?
+ ;;
+ ;; (cl-letf (((alist-get 'signature denote-file-name-slug-functions)
#'denote-sluggify-signature))
+ (let* ((sequence (denote-retrieve-filename-signature file-with-sequence))
+ (new-sequence (denote-sequence-get type sequence))
+ (denote-use-signature new-sequence))
+ (call-interactively 'denote)))
+
+;;;###autoload
+(defun denote-sequence-new-parent ()
+ "Like `denote-sequence' to directly create new parent."
+ (interactive)
+ (let* ((new-sequence (denote-sequence-get 'parent))
+ (denote-use-signature new-sequence))
+ (call-interactively 'denote)))
+
+;;;###autoload
+(defun denote-sequence-new-sibling (sequence)
+ "Like `denote-sequence' to directly create new sibling of SEQUENCE.
+When called from Lisp, SEQUENCE is a string that conforms with
+`denote-sequence-p'."
+ (interactive (list (denote-retrieve-filename-signature
(denote-sequence-file-prompt))))
+ (let* ((new-sequence (denote-sequence-get 'sibling sequence))
+ (denote-use-signature new-sequence))
+ (call-interactively 'denote)))
+
+;;;###autoload
+(defun denote-sequence-new-child (sequence)
+ "Like `denote-sequence' to directly create new child of SEQUENCE.
+When called from Lisp, SEQUENCE is a string that conforms with
+`denote-sequence-p'."
+ (interactive (list (denote-retrieve-filename-signature
(denote-sequence-file-prompt))))
+ (let* ((new-sequence (denote-sequence-get 'child sequence))
+ (denote-use-signature new-sequence))
+ (call-interactively 'denote)))
+
+;;;###autoload
+(defun denote-sequence-link (file &optional id-only)
+ "Link to FILE with sequence.
+This is like the `denote-link' command but only accepts to link to a
+file that conforms with `denote-sequence-file-p'. When called
+interactively, only relevant files are shown for minibuffer completion
+from the variable `denote-directory'.
+
+Optional ID-ONLY has the same meaning as the `denote-link' command."
+ (interactive (list (denote-sequence-file-prompt)))
+ (unless (denote-sequence-file-p file)
+ (error "Can only link to file with a sequence; else use `denote-link' and
related"))
+ (let* ((type (denote-filetype-heuristics buffer-file-name))
+ (description (denote-get-link-description file)))
+ (denote-link file type description id-only)))
+
+(provide 'denote-sequence)
+;;; denote-sequence.el ends here
diff --git a/tests/denote-test.el b/tests/denote-test.el
index 0c6028b1b7..a0e1f158bd 100644
--- a/tests/denote-test.el
+++ b/tests/denote-test.el
@@ -581,5 +581,74 @@ does not involve the time zone."
(let ((denote-journal-extras-title-format
'day-date-month-year-24h))
(denote-journal-extras-daily--title-format))))))
+;;;; denote-sequence.el
+
+;; TODO 2024-12-31: Maybe we can share some state between tests? It
+;; is expensive to create those files over and over.
+(ert-deftest denote-test--denote-sequence--get-new-child ()
+ "Make sure `denote-sequence--get-new-child' gets the child of a sequence."
+ (let* ((denote-directory (expand-file-name "denote-test"
temporary-file-directory))
+ (files
+ (mapcar
+ (lambda (file)
+ (let ((path (expand-file-name file (denote-directory))))
+ (if (file-exists-p path)
+ path
+ (with-current-buffer (find-file-noselect path)
+ (save-buffer)
+ (kill-buffer (current-buffer)))
+ path)))
+ '("20241230T075004==1--some-new-title__testing.txt"
+ "20241230T075023==1=1--child-of-note__testing.txt"
+ "20241230T075023==1=1=1--test__testing.txt"
+ "20241230T075023==1=1=2--test__testing.txt"
+ "20241230T075023==1=2--test__testing.txt"
+ "20241230T075023==1=2=1--test__testing.txt"
+ "20241230T075023==2--test__testing.txt")))
+ (sequences (denote-sequence-get-all-sequences files)))
+ (should
+ (and
+ (equal (denote-sequence--get-new-child "1" sequences) "1=3")
+ (equal (denote-sequence--get-new-child "1=1" sequences) "1=1=3")
+ (equal (denote-sequence--get-new-child "1=1=2" sequences) "1=1=2=1")
+ (equal (denote-sequence--get-new-child "1=2" sequences) "1=2=2")
+ (equal (denote-sequence--get-new-child "1=2=1" sequences) "1=2=1=1")
+ (equal (denote-sequence--get-new-child "2" sequences) "2=1")))
+ (should-error (denote-sequence--get-new-child "3" sequences))
+ (delete-directory denote-directory :delete-contents-as-well)))
+
+(ert-deftest denote-test--denote-sequence--get-new-sibling ()
+ "Make sure `denote-sequence--get-new-sibling' gets the sibling of a
sequence."
+ (let* ((denote-directory (expand-file-name "denote-test"
temporary-file-directory))
+ (files
+ (mapcar
+ (lambda (file)
+ (let ((path (expand-file-name file (denote-directory))))
+ (if (file-exists-p path)
+ path
+ (with-current-buffer (find-file-noselect path)
+ (save-buffer)
+ (kill-buffer (current-buffer)))
+ path)))
+ '("20241230T075004==1--some-new-title__testing.txt"
+ "20241230T075023==1=1--sibling-of-note__testing.txt"
+ "20241230T075023==1=1=1--test__testing.txt"
+ "20241230T075023==1=1=2--test__testing.txt"
+ "20241230T075023==1=2--test__testing.txt"
+ "20241230T075023==1=2=1--test__testing.txt"
+ "20241230T075023==2--test__testing.txt")))
+ (sequences (denote-sequence-get-all-sequences files)))
+ (should
+ (and
+ (equal (denote-sequence--get-new-sibling "1" sequences) "3")
+ (equal (denote-sequence--get-new-sibling "1=1" sequences) "1=3")
+ (equal (denote-sequence--get-new-sibling "1=1=1" sequences) "1=1=3")
+ (equal (denote-sequence--get-new-sibling "1=1=2" sequences) "1=1=3")
+ (equal (denote-sequence--get-new-sibling "1=2" sequences) "1=3")
+ (equal (denote-sequence--get-new-sibling "1=2=1" sequences) "1=2=2")
+ (equal (denote-sequence--get-new-sibling "2" sequences) "3")))
+ (should-error (denote-sequence--get-new-sibling "4" sequences))
+ (delete-directory denote-directory :delete-contents-as-well)))
+
(provide 'denote-test)
;;; denote-test.el ends here