Files
nursery/lisp/org-roam-rewrite.el
2025-02-15 17:45:49 +13:00

530 lines
21 KiB
EmacsLisp
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

;;; org-roam-rewrite.el --- Commands for rewriting org-roam nodes and their links -*- lexical-binding: t; -*-
;; Copyright (C) 2022 Chris Barrett
;; Homepage: https://github.com/chrisbarrett/nursery
;; Author: Chris Barrett <chris+emacs@walrus.cool>
;; 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 <http://www.gnu.org/licenses/>.
;;; Commentary:
;; Provides commands for rewriting links and removing nodes in a structured way,
;; to reduce the likelihood of leaving broken links in your org-roam files.
;;; Code:
(require 'dash)
(require 'org)
(require 'org-roam)
(require 'plisty)
(require 'org-transclusion nil t)
(defgroup org-roam-rewrite nil
"Commands for rewriting org-roam nodes and their links."
:group 'productivity
:prefix "org-roam-rewrite-")
(defcustom org-roam-rewrite-extract-excluded-tags '("ATTACH")
"Tags that should not be propagated when extracting nodes."
:group 'org-roam-rewrite
:type '(repeat string))
(defcustom org-roam-rewrite-confirm-extraction-path-p nil
"Whether to confirm the path to extract to with `org-roam-rewrite-extract'."
:group 'org-roam-rewrite
:type 'boolean)
(defcustom org-roam-rewrite-insert-link-after-extraction-p t
"Whether to insert a link to nodes extracted with `org-roam-rewrite-extract'."
:group 'org-roam-rewrite
:type 'boolean)
(defcustom org-roam-rewrite-rename-without-confirmation-p nil
"Whether to ask for confirmation before updating links to renamed nodes."
:group 'org-roam-rewrite
:type 'boolean)
(defcustom org-roam-rewrite-backlink-transformer #'org-roam-rewrite-backlink-transformer
"Function to transform link data used to populate links in rewrites.
It should take a single argument, a plist conforming to
`org-roam-rewrite-backlink-transformer-args', and return a plist
conforming to `org-roam-rewrite-backlink-transformer-result'."
:group 'org-roam-rewrite
:type 'function)
(defcustom org-roam-rewrite-backlink-modified-functions nil
"Hook function run after a backlink is modified.
It is called with the renamed link at point, and is passed two arguments:
1. a plist containing information about the link before and after
the rename, but before the link transformer has possibly made
modifications. It conforms to
`org-roam-rewrite-backlink-transformer-args'.
2. a plist containing the data used to construct the new link. It
conforms to `org-roam-rewrite-backlink-transformer-result'."
:group 'org-roam-rewrite
:type 'hook)
(defcustom org-roam-rewrite-backlinks-modified-in-file-functions nil
"Hook run after processing backlinks in a file caused modifications.
It is called with a single argument: the file name that was modified."
:group 'org-roam-rewrite
:type 'hook)
(defcustom org-roam-rewrite-node-extracted-hook nil
"Hook run after a node has been extracted successfully to a new file.
It is called with the new node as the current buffer."
:group 'org-roam-rewrite
:type 'hook)
(defcustom org-roam-rewrite-node-removed-functions nil
"Hook functions run after a node has been removed.
It is called with a plist, containing the following attributes
from the original node: :title, :level, :file, :id"
:group 'org-roam-rewrite
:type 'hook)
(defcustom org-roam-rewrite-node-renamed-hook nil
"Hook executed after renaming a node.
It is called with the renamed node as the current buffer."
:group 'org-roam-rewrite
:type 'hook)
(defun org-roam-rewrite--set-title-keyword (text)
(org-with-wide-buffer
(goto-char (point-min))
(save-match-data
(search-forward-regexp (rx bol "#+title:" (* space) (group (+ any)) eol))
(replace-match text t nil nil 1))))
(defun org-roam-rewrite--set-file-tags (tags)
(org-with-wide-buffer
(goto-char (point-min))
(unless (search-forward-regexp (rx bol "#+filetags:" (group (* nonl))) nil t)
(cond ((search-forward-regexp (rx bol "#+title:"))
(goto-char (line-end-position))
(insert "\n#+filetags:"))
(t
(insert "#+filetags:\n"))))
(let ((formatted (if tags
(format ":%s:" (string-join tags ":"))
"")))
(save-match-data
(goto-char (point-min))
(when (search-forward-regexp (rx bol "#+filetags:" (group (* nonl))))
(replace-region-contents (match-beginning 1) (match-end 1)
(lambda ()
(concat " " formatted))))))))
(defun org-roam-rewrite--file-tags ()
(save-match-data
(org-with-wide-buffer
(goto-char (point-min))
(when (search-forward-regexp (rx bol "#+filetags:" (group (+ nonl)))
nil
t)
(split-string (string-trim (substring-no-properties (match-string 1))) ":" t)))))
(plisty-define org-roam-rewrite-backlink-transformer-args
:required (:prev-node :new-node
:prev-id :prev-desc
:new-id :new-desc))
(plisty-define org-roam-rewrite-backlink-transformer-result
:required (:id :desc))
(defun org-roam-rewrite--normalise-title (title)
(replace-regexp-in-string (rx (+ (any space "\n"))) ""
(downcase title)))
(defun org-roam-rewrite-backlink-transformer (args-plist)
(-let* (((&plist :prev-node :new-id :new-desc :prev-desc)
args-plist)
(norm-titles (cons (org-roam-rewrite--normalise-title (org-roam-node-title prev-node))
(seq-map #'org-roam-rewrite--normalise-title (org-roam-node-aliases prev-node))))
(desc-customised-p
(not (seq-contains-p norm-titles (org-roam-rewrite--normalise-title prev-desc))))
(updated-desc
(if desc-customised-p prev-desc new-desc)))
(list :id new-id :desc updated-desc)))
(defun org-roam-rewrite--parse-link-at-point ()
(save-match-data
(when (looking-at org-link-any-re)
(-let* ((beg (match-beginning 0))
(end (match-end 0))
(str (buffer-substring-no-properties beg end))
(((_link (&plist :path id) desc)) (org-element-parse-secondary-string str '(link))))
(list :beg beg :end end :id id :desc (substring-no-properties desc))))))
(defun org-roam-rewrite--edit-backlinks (prev-node new-node new-desc)
(let* ((backlinks-by-file
(seq-group-by (-compose #'org-roam-node-file #'org-roam-backlink-source-node)
(org-roam-backlinks-get prev-node))))
(pcase-dolist (`(,file . ,backlinks) backlinks-by-file)
(with-temp-buffer
(let ((modified-p))
(insert-file-contents file)
(dolist (backlink (seq-sort-by #'org-roam-backlink-point #'> backlinks))
(goto-char (org-roam-backlink-point backlink))
(-when-let* (((&plist :beg :end :id prev-id :desc prev-desc)
(org-roam-rewrite--parse-link-at-point))
(transformer-args
(org-roam-rewrite-backlink-transformer-args-create
:prev-node prev-node
:new-node new-node
:prev-id prev-id
:prev-desc prev-desc
:new-id (org-roam-node-id new-node)
:new-desc new-desc))
(transformed
(org-roam-rewrite-backlink-transformer-result-assert
(funcall org-roam-rewrite-backlink-transformer transformer-args)))
((&plist :desc new-desc :id new-id) transformed))
(replace-region-contents beg end (lambda ()
(org-link-make-string (concat "id:" new-id) new-desc)))
(setq modified-p t)
(run-hook-with-args 'org-roam-rewrite-backlink-modified-functions transformer-args transformed)))
(write-region (point-min) (point-max) file)
(when modified-p
(run-hook-with-args 'org-roam-rewrite-backlinks-modified-in-file-functions file)))))
(pcase-dolist (`(,file . ,_) backlinks-by-file)
(when-let* ((buf (find-buffer-visiting file)))
(with-current-buffer buf
(revert-buffer t t)))))
;; Tell org-roam that files changed behind its back.
(org-roam-db-sync))
(defun org-roam-rewrite--update-node-title (node new-title)
(org-id-goto (org-roam-node-id node))
(cond ((equal 0 (org-roam-node-level node))
(org-roam-rewrite--set-title-keyword new-title))
((looking-at org-complex-heading-regexp)
(replace-match new-title t t nil 4)))
(save-buffer))
(defun org-roam-rewrite--delete-node-kill-buffer (node)
(let ((level (org-roam-node-level node))
(file (org-roam-node-file node))
(id (org-roam-node-id node)))
(cond
((zerop level)
(when-let* ((buf (find-buffer-visiting file)))
(kill-buffer buf))
(delete-file file))
(t
(let ((buffer-visiting-p (find-buffer-visiting file)))
(org-with-point-at (org-roam-node-marker node)
(goto-char (point-min))
(when (search-forward-regexp (rx-to-string `(and
bol
(* space) ":ID:"
(* space)
,id)))
(let ((message-log-max))
(org-cut-subtree)))
(save-buffer)
(unless buffer-visiting-p
(kill-buffer))))))
(run-hook-with-args 'org-roam-rewrite-node-removed-functions
(list :title (org-roam-node-title node) :id id :file file :level level))))
(defun org-roam-rewrite--node-formatted-title (node &optional default)
(if org-roam-node-formatter
(funcall org-roam-node-formatter node)
(or default
(org-roam-node-title node))))
;;;###autoload
(defun org-roam-rewrite-rename (node new-title)
"Change the title of a node and update links to match.
NODE is the node to update.
NEW-TITLE is the new title to use. All backlinks will have their
descriptions updated according to the behaviour of the function
bound to variable `org-roam-rewrite-backlink-transformer'."
(interactive (let ((node (or (org-roam-node-at-point) (org-roam-node-read))))
(list node (read-string "New title: " (org-roam-node-title node)))))
(org-roam-node-visit node)
(org-save-all-org-buffers)
(org-roam-rewrite--update-node-title node new-title)
(let* ((node-id (org-roam-node-id node))
;; Get an updated node with the new title.
(updated-node (org-roam-node-from-id node-id))
(backlinks (org-roam-backlinks-get node)))
(cond
((null backlinks)
(message "Renamed. No backlinks to update."))
(t
(cond ((or org-roam-rewrite-rename-without-confirmation-p
(y-or-n-p (format "Modify %s backlink description%s? "
(length backlinks)
(if (= 1 (length backlinks)) "" "s"))))
(let ((new-desc (org-roam-rewrite--node-formatted-title updated-node new-title)))
(org-roam-rewrite--edit-backlinks node updated-node new-desc))
(message "Rewrote %s link%s to node."
(length backlinks)
(if (= 1 (length backlinks)) "" "s")))
(t
(message "Rename completed.")))))
(run-hooks 'org-roam-rewrite-node-renamed-hook)))
;;;###autoload
(defun org-roam-rewrite-remove (from to link-desc)
"Redirect links from one node to a replacement node.
Optionally, delete the original node after all links are
redirected.
FROM is the node which will be unlinked.
TO is the node to change those references to point to.
LINK-DESC is the description to use for the updated links."
(interactive (let* ((from (org-roam-node-at-point t))
(backlinks (progn
(org-save-all-org-buffers)
(org-roam-backlinks-get from))))
(if (zerop (length backlinks))
(list from nil nil)
(let* ((to (org-roam-node-read nil (lambda (it) (not (equal from it))) nil t "Rewrite to: "))
(desc (read-string "Link description: " (org-roam-rewrite--node-formatted-title to))))
(list from to desc)))))
(let ((backlinks (org-roam-backlinks-get from)))
(cond
((null backlinks)
(when (y-or-n-p "No links found. Delete node? ")
(org-roam-rewrite--delete-node-kill-buffer from)))
((or (null to) (null link-desc))
(user-error "Must provide a node to redirect existing links to"))
((y-or-n-p (format "Rewriting %s link%s from \"%s\" -> \"%s\". Continue? "
(length backlinks)
(if (= 1 (length backlinks)) "" "s")
(org-roam-node-title from)
link-desc))
(org-roam-rewrite--edit-backlinks from to link-desc)
(when (y-or-n-p "Rewrite completed. Delete node? ")
(org-roam-rewrite--delete-node-kill-buffer from)))
(t
(user-error "Rewrite aborted")))))
(defmacro org-roam-rewrite--when-transclusions (&rest body)
(declare (indent 0))
`(when (bound-and-true-p org-transclusion-mode)
,@body))
;;;###autoload
(defun org-roam-rewrite-inline (src-node dest-node)
"Inline the contents of one org-roam node into another, removing the original.
SRC-NODE is the node to be removed.
DEST-NODE is the node that will be added to."
(interactive
(let* ((suggested-title (-some->> (org-roam-node-at-point) (org-roam-node-title)))
(src (org-roam-node-read suggested-title nil nil t "Source: "))
(dest (org-roam-node-read nil (lambda (node)
(and
(not (equal (org-roam-node-id node) (org-roam-node-id src)))
(zerop (org-roam-node-level node))
(not (seq-contains-p (org-roam-node-tags node) "dailies"))))
nil t "Destination: ")))
(list src dest)))
(let* ((org-inhibit-startup t)
(src-buffer (find-file-noselect (org-roam-node-file src-node)))
(content
(with-current-buffer src-buffer
(org-with-wide-buffer
(org-roam-rewrite--when-transclusions
(org-transclusion-remove-all))
(goto-char (point-min))
(org-roam-end-of-meta-data t)
(buffer-substring (point) (point-max))))))
(find-file (org-roam-node-file dest-node))
(org-with-wide-buffer
(org-roam-rewrite--when-transclusions
(org-transclusion-remove-all))
(goto-char (point-max))
(delete-blank-lines)
(insert "\n\n")
(insert (format "* %s\n" (org-roam-node-title src-node)))
(org-set-property "ID" (org-roam-node-id src-node))
(save-restriction
(narrow-to-region (point) (point-max))
(insert content)
(org-map-entries 'org-do-demote)
(goto-char (point-min))
(while (search-forward-regexp (rx bol "#+transclude:") nil t)
(org-roam-rewrite--when-transclusions
(org-transclusion-add))
(org-roam-rewrite--when-transclusions
(org-transclusion-promote-subtree)))))
(delete-file (org-roam-node-file src-node))
(save-buffer)
(org-roam-rewrite--when-transclusions
(org-transclusion-add-all))
(when (buffer-live-p src-buffer)
(kill-buffer src-buffer)))
(org-roam-node-visit dest-node)
(message "Inlined node successfully"))
(defun org-roam-rewrite--ensure-node-for-headline-at-point ()
(save-excursion
(org-back-to-heading-or-point-min t)
(when (bobp) (user-error "Already a top-level node"))
(org-id-get-create)
(save-buffer)
(org-roam-db-update-file)
(org-roam-node-at-point t)))
(defun org-roam-rewrite--new-filename-from-capture-template (node)
(unwind-protect
(progn
(setq org-capture-plist nil)
(org-roam-format-template
(string-trim (org-capture-fill-template org-roam-extract-new-file-path))
(lambda (key default-val)
(let ((fn (intern key))
(node-fn (intern (concat "org-roam-node-" key))))
(cond
((fboundp fn)
(funcall fn node))
((fboundp node-fn)
(funcall node-fn node))
(t
(read-from-minibuffer (format "%s: " key) default-val)))))))
(setq org-capture-plist nil)))
;;;###autoload
(defun org-roam-rewrite-extract (node dest)
"Extract NODE to a new file at DEST.
Note that NODE must be a headline, not at the top-level of the
file. If NODE is at the top-level an error is signalled.
If called interactively, ensure the headline at point has an ID
before extracting.
This is a rough reimplementation of `org-roam-extract-subtree',
but it handles file titles, tags and transclusions better."
(interactive (let* ((node (org-roam-rewrite--ensure-node-for-headline-at-point))
(template (org-roam-rewrite--new-filename-from-capture-template node))
(relpath (file-name-as-directory org-roam-directory))
(dest (expand-file-name
(if org-roam-rewrite-confirm-extraction-path-p
(read-file-name "Extract node to: " relpath template nil template)
template)
org-roam-directory)))
(list node dest)))
(cl-assert (org-roam-node-level node) t)
(cl-assert (not (zerop (org-roam-node-level node))) t)
(with-current-buffer (find-file-noselect (org-roam-node-file node))
(org-roam-rewrite--when-transclusions
(org-transclusion-remove-all))
;; Use underlying org-mode machinery to go to the ID in the buffer. We can't
;; use org-roam-node-marker because updates aren't reliable.
(org-with-point-at (org-id-find (org-roam-node-id node) t)
(let ((tags (org-get-tags))
(save-silently t)
(dest-buf (find-file-noselect dest))
extraction-succeeded-p)
(unwind-protect
(atomic-change-group
;; Extract from source buffer
(org-cut-subtree)
(save-buffer)
(org-roam-db-update-file)
(when org-roam-rewrite-insert-link-after-extraction-p
(insert (org-link-make-string (format "id:%s" (org-roam-node-id node))
(org-link-display-format (org-roam-rewrite--node-formatted-title node))))
(newline))
(org-roam-rewrite--when-transclusions
(org-transclusion-add-all))
;; Insert into dest buffer
(with-current-buffer dest-buf
(org-paste-subtree)
(while (> (org-current-level) 1) (org-promote-subtree))
;; `org-roam-promote-entire-buffer' expects an indexed node to
;; exist, and the file must exist on-disk for indexing to succeed.
(let ((before-save-hook)
(after-save-hook
(lambda ()
(org-id-add-location (org-roam-node-id node) dest))))
(save-buffer))
(org-roam-promote-entire-buffer)
(let ((tags (-difference (-union (org-roam-rewrite--file-tags) tags)
org-roam-rewrite-extract-excluded-tags)))
(org-roam-rewrite--set-file-tags tags)
(org-roam-rewrite--when-transclusions
(org-transclusion-add-all)))
(save-buffer))
(setq extraction-succeeded-p t))
(unless extraction-succeeded-p
(message "Extraction failed")
(with-current-buffer dest-buf
(let ((kill-buffer-query-functions))
(set-buffer-modified-p nil)
(kill-buffer dest-buf))
(when (file-exists-p dest)
(delete-file dest)))))
(save-buffer)
(with-current-buffer dest-buf
(run-hooks 'org-roam-capture-new-node-hook 'org-roam-rewrite-node-extracted-hook))))))
(provide 'org-roam-rewrite)
;;; org-roam-rewrite.el ends here