branch: externals/denote
commit 2a3436e1112540868c388c577c6b88d6c1caac66
Merge: 90372d9627 27ef655bc8
Author: Protesilaos Stavrou <i...@protesilaos.com>
Commit: Protesilaos Stavrou <i...@protesilaos.com>

    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 <i...@protesilaos.com>
+;; Maintainer: Protesilaos Stavrou <i...@protesilaos.com>
+;; 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

Reply via email to