From 13d983b75193b772c25da30d8b1c87293621d9f1 Mon Sep 17 00:00:00 2001 From: Jonathan Chu Date: Tue, 28 Apr 2026 23:48:46 -0400 Subject: [PATCH 1/4] Extract shared definitions into grove-core.el Move the customization group, defcustom variables, vault cache, and utility functions out of grove.el into a new grove-core.el that has no dependencies on other grove modules. This is the foundation for eliminating recursive requires between grove files. --- grove-core.el | 165 ++++++++++++++++++++++++++++++++++++++++++++++++++ grove.el | 134 +--------------------------------------- 2 files changed, 166 insertions(+), 133 deletions(-) create mode 100644 grove-core.el diff --git a/grove-core.el b/grove-core.el new file mode 100644 index 0000000..95e41e1 --- /dev/null +++ b/grove-core.el @@ -0,0 +1,165 @@ +;;; grove-core.el --- Core definitions for grove -*- lexical-binding: t -*- + +;; Copyright 2026 Jonathan Chu + +;; Author: Jonathan Chu +;; URL: https://github.com/jonathanchu/grove + +;; This file is not part of GNU Emacs. + +;; This file 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, or (at your option) +;; any later version. + +;; This file 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 . + +;;; Commentary: + +;; Shared customization options, vault cache, and utility functions +;; used across all grove modules. + +;;; Code: + +(require 'cl-lib) + +;;;; Customization + +(defgroup grove nil + "Obsidian-like note-taking for org files." + :group 'org + :prefix "grove-") + +(defcustom grove-directory nil + "Root directory for grove notes. +All org files in this directory and its subdirectories are +considered part of the vault." + :type '(choice (const nil) directory) + :group 'grove) + +(defcustom grove-inbox-directory "inbox" + "Subdirectory of `grove-directory' for captured notes. +Relative to `grove-directory'." + :type 'string + :group 'grove) + +(defcustom grove-daily-directory "daily" + "Subdirectory of `grove-directory' for daily notes. +Relative to `grove-directory'." + :type 'string + :group 'grove) + +(defcustom grove-daily-format "%Y-%m-%d" + "Format string for daily note filenames. +Passed to `format-time-string'." + :type 'string + :group 'grove) + +;;;; Vault cache + +(defvar grove--cache (make-hash-table :test #'equal) + "Hash table mapping absolute file paths to note metadata plists. +Each value is a plist with keys :title :tags :links :mtime.") + +(defun grove--ensure-directory () + "Ensure `grove-directory' is set and exists, or prompt the user." + (unless grove-directory + (setq grove-directory + (read-directory-name "Grove vault directory: "))) + (unless (file-directory-p grove-directory) + (if (y-or-n-p (format "Directory %s does not exist. Create it? " + grove-directory)) + (make-directory grove-directory t) + (user-error "Grove requires a vault directory"))) + (setq grove-directory (file-name-as-directory + (expand-file-name grove-directory)))) + +(defun grove--inbox-path () + "Return the absolute path to the inbox directory, creating it if needed." + (grove--ensure-directory) + (let ((path (expand-file-name grove-inbox-directory grove-directory))) + (unless (file-directory-p path) + (make-directory path t)) + (file-name-as-directory path))) + +(defun grove--daily-path () + "Return the absolute path to the daily notes directory, creating it if needed." + (grove--ensure-directory) + (let ((path (expand-file-name grove-daily-directory grove-directory))) + (unless (file-directory-p path) + (make-directory path t)) + (file-name-as-directory path))) + +(defun grove--parse-note (file) + "Parse an org FILE and return a metadata plist. +Returns (:title TITLE :tags TAGS :links LINKS :mtime MTIME)." + (let ((mtime (file-attribute-modification-time (file-attributes file))) + title tags links) + (with-temp-buffer + (insert-file-contents file nil 0 4096) + ;; Extract #+title: + (goto-char (point-min)) + (when (re-search-forward "^#\\+title:\\s-*\\(.+\\)" nil t) + (setq title (string-trim (match-string 1)))) + ;; Extract #+filetags: or inline #hashtags + (goto-char (point-min)) + (when (re-search-forward "^#\\+filetags:\\s-*\\(.+\\)" nil t) + (setq tags (split-string (match-string 1) ":" t "\\s-*"))) + ;; Extract [[wikilinks]] + (goto-char (point-min)) + (while (re-search-forward "\\[\\[\\([^]]+\\)\\]\\]" nil t) + (let ((link (match-string 1))) + ;; Skip standard org links (contain a colon protocol) + (unless (string-match-p ":" link) + (push link links))))) + (list :title (or title (file-name-sans-extension + (file-name-nondirectory file))) + :tags tags + :links (nreverse links) + :mtime mtime))) + +(defun grove--refresh-cache () + "Refresh the vault cache by scanning `grove-directory'. +Only re-parses files whose mtime has changed." + (grove--ensure-directory) + (let ((files (directory-files-recursively grove-directory "\\.org\\'")) + (seen (make-hash-table :test #'equal))) + ;; Update or add entries + (dolist (file files) + (puthash file t seen) + (let* ((cached (gethash file grove--cache)) + (current-mtime (file-attribute-modification-time + (file-attributes file))) + (cached-mtime (plist-get cached :mtime))) + (when (or (null cached) + (not (equal current-mtime cached-mtime))) + (puthash file (grove--parse-note file) grove--cache)))) + ;; Remove deleted files + (maphash (lambda (key _val) + (unless (gethash key seen) + (remhash key grove--cache))) + grove--cache))) + +(defun grove--note-titles () + "Return an alist of (TITLE . PATH) for all cached notes." + (let (result) + (maphash (lambda (path meta) + (push (cons (plist-get meta :title) path) result)) + grove--cache) + (sort result (lambda (a b) (string< (car a) (car b)))))) + +(defun grove-file-p (file) + "Return non-nil if FILE is inside `grove-directory'." + (and grove-directory + file + (string-prefix-p (expand-file-name grove-directory) + (expand-file-name file)))) + +(provide 'grove-core) +;;; grove-core.el ends here diff --git a/grove.el b/grove.el index b405736..ead3c04 100644 --- a/grove.el +++ b/grove.el @@ -45,139 +45,7 @@ ;;; Code: -(require 'cl-lib) - -;;;; Customization - -(defgroup grove nil - "Obsidian-like note-taking for org files." - :group 'org - :prefix "grove-") - -(defcustom grove-directory nil - "Root directory for grove notes. -All org files in this directory and its subdirectories are -considered part of the vault." - :type '(choice (const nil) directory) - :group 'grove) - -(defcustom grove-inbox-directory "inbox" - "Subdirectory of `grove-directory' for captured notes. -Relative to `grove-directory'." - :type 'string - :group 'grove) - -(defcustom grove-daily-directory "daily" - "Subdirectory of `grove-directory' for daily notes. -Relative to `grove-directory'." - :type 'string - :group 'grove) - -(defcustom grove-daily-format "%Y-%m-%d" - "Format string for daily note filenames. -Passed to `format-time-string'." - :type 'string - :group 'grove) - -;;;; Vault cache - -(defvar grove--cache (make-hash-table :test #'equal) - "Hash table mapping absolute file paths to note metadata plists. -Each value is a plist with keys :title :tags :links :mtime.") - -(defun grove--ensure-directory () - "Ensure `grove-directory' is set and exists, or prompt the user." - (unless grove-directory - (setq grove-directory - (read-directory-name "Grove vault directory: "))) - (unless (file-directory-p grove-directory) - (if (y-or-n-p (format "Directory %s does not exist. Create it? " - grove-directory)) - (make-directory grove-directory t) - (user-error "Grove requires a vault directory"))) - (setq grove-directory (file-name-as-directory - (expand-file-name grove-directory)))) - -(defun grove--inbox-path () - "Return the absolute path to the inbox directory, creating it if needed." - (grove--ensure-directory) - (let ((path (expand-file-name grove-inbox-directory grove-directory))) - (unless (file-directory-p path) - (make-directory path t)) - (file-name-as-directory path))) - -(defun grove--daily-path () - "Return the absolute path to the daily notes directory, creating it if needed." - (grove--ensure-directory) - (let ((path (expand-file-name grove-daily-directory grove-directory))) - (unless (file-directory-p path) - (make-directory path t)) - (file-name-as-directory path))) - -(defun grove--parse-note (file) - "Parse an org FILE and return a metadata plist. -Returns (:title TITLE :tags TAGS :links LINKS :mtime MTIME)." - (let ((mtime (file-attribute-modification-time (file-attributes file))) - title tags links) - (with-temp-buffer - (insert-file-contents file nil 0 4096) - ;; Extract #+title: - (goto-char (point-min)) - (when (re-search-forward "^#\\+title:\\s-*\\(.+\\)" nil t) - (setq title (string-trim (match-string 1)))) - ;; Extract #+filetags: or inline #hashtags - (goto-char (point-min)) - (when (re-search-forward "^#\\+filetags:\\s-*\\(.+\\)" nil t) - (setq tags (split-string (match-string 1) ":" t "\\s-*"))) - ;; Extract [[wikilinks]] - (goto-char (point-min)) - (while (re-search-forward "\\[\\[\\([^]]+\\)\\]\\]" nil t) - (let ((link (match-string 1))) - ;; Skip standard org links (contain a colon protocol) - (unless (string-match-p ":" link) - (push link links))))) - (list :title (or title (file-name-sans-extension - (file-name-nondirectory file))) - :tags tags - :links (nreverse links) - :mtime mtime))) - -(defun grove--refresh-cache () - "Refresh the vault cache by scanning `grove-directory'. -Only re-parses files whose mtime has changed." - (grove--ensure-directory) - (let ((files (directory-files-recursively grove-directory "\\.org\\'")) - (seen (make-hash-table :test #'equal))) - ;; Update or add entries - (dolist (file files) - (puthash file t seen) - (let* ((cached (gethash file grove--cache)) - (current-mtime (file-attribute-modification-time - (file-attributes file))) - (cached-mtime (plist-get cached :mtime))) - (when (or (null cached) - (not (equal current-mtime cached-mtime))) - (puthash file (grove--parse-note file) grove--cache)))) - ;; Remove deleted files - (maphash (lambda (key _val) - (unless (gethash key seen) - (remhash key grove--cache))) - grove--cache))) - -(defun grove--note-titles () - "Return an alist of (TITLE . PATH) for all cached notes." - (let (result) - (maphash (lambda (path meta) - (push (cons (plist-get meta :title) path) result)) - grove--cache) - (sort result (lambda (a b) (string< (car a) (car b)))))) - -(defun grove-file-p (file) - "Return non-nil if FILE is inside `grove-directory'." - (and grove-directory - file - (string-prefix-p (expand-file-name grove-directory) - (expand-file-name file)))) +(require 'grove-core) ;;;; Minor mode From 3b778be0f50218caa6e4b9fb32f309550f6feed6 Mon Sep 17 00:00:00 2001 From: Jonathan Chu Date: Tue, 28 Apr 2026 23:49:04 -0400 Subject: [PATCH 2/4] Switch all sub-modules to require grove-core instead of grove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No sub-module needs the minor mode or command map from grove.el — they only use the shared variables and utilities now in grove-core. This breaks the circular require chains (e.g. grove -> grove-ui -> grove) that the byte-compiler flags as recursive requires. --- grove-backlink.el | 2 +- grove-capture.el | 2 +- grove-daily.el | 2 +- grove-graph.el | 2 +- grove-inbox.el | 2 +- grove-link.el | 2 +- grove-search.el | 2 +- grove-tree.el | 2 +- grove-ui.el | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/grove-backlink.el b/grove-backlink.el index 58a428c..22ec8b8 100644 --- a/grove-backlink.el +++ b/grove-backlink.el @@ -28,7 +28,7 @@ ;;; Code: -(require 'grove) +(require 'grove-core) (defconst grove-backlink-buffer-name "*grove-backlinks*" "Name of the backlinks buffer.") diff --git a/grove-capture.el b/grove-capture.el index b18a68c..28b999b 100644 --- a/grove-capture.el +++ b/grove-capture.el @@ -28,7 +28,7 @@ ;;; Code: -(require 'grove) +(require 'grove-core) (defvar grove-capture-mode-map (let ((map (make-sparse-keymap))) diff --git a/grove-daily.el b/grove-daily.el index 3207019..e0a8dc2 100644 --- a/grove-daily.el +++ b/grove-daily.el @@ -27,7 +27,7 @@ ;;; Code: -(require 'grove) +(require 'grove-core) ;;;###autoload (defun grove-daily (&optional time) diff --git a/grove-graph.el b/grove-graph.el index 1ff56e6..b15e519 100644 --- a/grove-graph.el +++ b/grove-graph.el @@ -27,7 +27,7 @@ ;;; Code: -(require 'grove) +(require 'grove-core) (require 'image) (require 'image-mode) diff --git a/grove-inbox.el b/grove-inbox.el index 35ef049..abde606 100644 --- a/grove-inbox.el +++ b/grove-inbox.el @@ -27,7 +27,7 @@ ;;; Code: -(require 'grove) +(require 'grove-core) (require 'grove-backlink) (defconst grove-inbox-buffer-name "*grove-inbox*" diff --git a/grove-link.el b/grove-link.el index c45cacc..92fb07b 100644 --- a/grove-link.el +++ b/grove-link.el @@ -28,7 +28,7 @@ ;;; Code: -(require 'grove) +(require 'grove-core) ;;;; Faces diff --git a/grove-search.el b/grove-search.el index 5621d4e..79a7fa9 100644 --- a/grove-search.el +++ b/grove-search.el @@ -28,7 +28,7 @@ ;;; Code: -(require 'grove) +(require 'grove-core) (declare-function consult--grep "consult") (declare-function consult--grep-make-builder "consult") diff --git a/grove-tree.el b/grove-tree.el index b5b3e9a..7b4cfac 100644 --- a/grove-tree.el +++ b/grove-tree.el @@ -30,7 +30,7 @@ (require 'cl-lib) (require 'ewoc) (require 'hl-line) -(require 'grove) +(require 'grove-core) ;;;; Customization diff --git a/grove-ui.el b/grove-ui.el index 0a65e1f..5c26dcb 100644 --- a/grove-ui.el +++ b/grove-ui.el @@ -27,7 +27,7 @@ ;;; Code: -(require 'grove) +(require 'grove-core) (require 'grove-tree) ;;;; Window configuration From 6c1f0de8af6fd193be6f46416b17c03831550816 Mon Sep 17 00:00:00 2001 From: Jonathan Chu Date: Tue, 28 Apr 2026 23:49:28 -0400 Subject: [PATCH 3/4] Move require statements to top of grove.el Now that no sub-module requires grove.el (they all require grove-core), the requires can sit at the standard position at the top of the file with no risk of recursive loading. The declare-function forms and runtime require inside grove-mode are also removed since grove-link is now loaded at file level. --- grove.el | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/grove.el b/grove.el index ead3c04..44e4f24 100644 --- a/grove.el +++ b/grove.el @@ -46,6 +46,14 @@ ;;; Code: (require 'grove-core) +(require 'grove-ui) +(require 'grove-capture) +(require 'grove-search) +(require 'grove-daily) +(require 'grove-backlink) +(require 'grove-inbox) +(require 'grove-link) +(require 'grove-graph) ;;;; Minor mode @@ -55,17 +63,12 @@ map) "Keymap for `grove-mode'.") -(declare-function grove-link-setup-font-lock "grove-link") -(declare-function grove-link-remove-font-lock "grove-link") - (define-minor-mode grove-mode "Minor mode active in org buffers that are part of a grove vault." :lighter " Grove" :keymap grove-mode-map (if grove-mode - (progn - (require 'grove-link) - (grove-link-setup-font-lock)) + (grove-link-setup-font-lock) (grove-link-remove-font-lock))) (defun grove--turn-on () @@ -102,14 +105,4 @@ Bind this to a prefix key in your init file, e.g.: (global-set-key (kbd \"C-c v\") grove-command-map)") (provide 'grove) - -(require 'grove-ui) -(require 'grove-capture) -(require 'grove-search) -(require 'grove-daily) -(require 'grove-backlink) -(require 'grove-inbox) -(require 'grove-link) -(require 'grove-graph) - ;;; grove.el ends here From 9a98a6fda5f181e4b6d43922044eedcb72e2bb7f Mon Sep 17 00:00:00 2001 From: Jonathan Chu Date: Tue, 28 Apr 2026 23:49:55 -0400 Subject: [PATCH 4/4] Move sanitize-filename to grove-core as grove--sanitize-filename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This function was defined in grove-capture.el but also called by grove-link.el without an explicit require — a hidden cross-file dependency. Since it is a general-purpose utility, it belongs in grove-core where both callers can reach it through their existing require. --- grove-capture.el | 11 +---------- grove-core.el | 9 +++++++++ grove-link.el | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/grove-capture.el b/grove-capture.el index 28b999b..65a3b72 100644 --- a/grove-capture.el +++ b/grove-capture.el @@ -42,15 +42,6 @@ :lighter " Grove-Capture" :keymap grove-capture-mode-map) -(defun grove-capture--sanitize-filename (title) - "Convert TITLE into a safe filename. -Downcases, replaces spaces with hyphens, strips non-alphanumeric characters." - (let ((name (downcase (string-trim title)))) - (setq name (replace-regexp-in-string "[^a-z0-9 -]" "" name)) - (setq name (replace-regexp-in-string "\\s-+" "-" name)) - (setq name (replace-regexp-in-string "^-+\\|-+$" "" name)) - (if (string-empty-p name) "untitled" name))) - (defun grove-capture--unique-path (directory filename) "Return a unique file path in DIRECTORY for FILENAME. Appends a numeric suffix if the file already exists." @@ -96,7 +87,7 @@ Type freely, then press \\[grove-capture-finalize] to save or (let* ((lines (split-string content "\n")) (title (string-trim (car lines))) (body (string-join (cdr lines) "\n")) - (filename (concat (grove-capture--sanitize-filename title) ".org")) + (filename (concat (grove--sanitize-filename title) ".org")) (path (grove-capture--unique-path (grove--inbox-path) filename))) (with-temp-file path (insert "#+title: " title "\n") diff --git a/grove-core.el b/grove-core.el index 95e41e1..77136db 100644 --- a/grove-core.el +++ b/grove-core.el @@ -154,6 +154,15 @@ Only re-parses files whose mtime has changed." grove--cache) (sort result (lambda (a b) (string< (car a) (car b)))))) +(defun grove--sanitize-filename (title) + "Convert TITLE into a safe filename. +Downcases, replaces spaces with hyphens, strips non-alphanumeric characters." + (let ((name (downcase (string-trim title)))) + (setq name (replace-regexp-in-string "[^a-z0-9 -]" "" name)) + (setq name (replace-regexp-in-string "\\s-+" "-" name)) + (setq name (replace-regexp-in-string "^-+\\|-+$" "" name)) + (if (string-empty-p name) "untitled" name))) + (defun grove-file-p (file) "Return non-nil if FILE is inside `grove-directory'." (and grove-directory diff --git a/grove-link.el b/grove-link.el index 92fb07b..3efd515 100644 --- a/grove-link.el +++ b/grove-link.el @@ -121,7 +121,7 @@ If the note doesn't exist, offer to create it." (if path (find-file path) (if (y-or-n-p (format "Note \"%s\" not found. Create it? " title)) - (let* ((filename (concat (grove-capture--sanitize-filename title) ".org")) + (let* ((filename (concat (grove--sanitize-filename title) ".org")) (path (expand-file-name filename grove-directory))) (find-file path) (insert "#+title: " title "\n\n"))