Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion grove-backlink.el
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

;;; Code:

(require 'grove)
(require 'grove-core)

(defconst grove-backlink-buffer-name "*grove-backlinks*"
"Name of the backlinks buffer.")
Expand Down
13 changes: 2 additions & 11 deletions grove-capture.el
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

;;; Code:

(require 'grove)
(require 'grove-core)

(defvar grove-capture-mode-map
(let ((map (make-sparse-keymap)))
Expand All @@ -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."
Expand Down Expand Up @@ -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")
Expand Down
174 changes: 174 additions & 0 deletions grove-core.el
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
;;; grove-core.el --- Core definitions for grove -*- lexical-binding: t -*-

;; Copyright 2026 Jonathan Chu

;; Author: Jonathan Chu <me@jonathanchu.is>
;; 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 <http://www.gnu.org/licenses/>.

;;; 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--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
file
(string-prefix-p (expand-file-name grove-directory)
(expand-file-name file))))

(provide 'grove-core)
;;; grove-core.el ends here
2 changes: 1 addition & 1 deletion grove-daily.el
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

;;; Code:

(require 'grove)
(require 'grove-core)

;;;###autoload
(defun grove-daily (&optional time)
Expand Down
2 changes: 1 addition & 1 deletion grove-graph.el
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

;;; Code:

(require 'grove)
(require 'grove-core)
(require 'image)
(require 'image-mode)

Expand Down
2 changes: 1 addition & 1 deletion grove-inbox.el
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

;;; Code:

(require 'grove)
(require 'grove-core)
(require 'grove-backlink)

(defconst grove-inbox-buffer-name "*grove-inbox*"
Expand Down
4 changes: 2 additions & 2 deletions grove-link.el
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

;;; Code:

(require 'grove)
(require 'grove-core)

;;;; Faces

Expand Down Expand Up @@ -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"))
Expand Down
2 changes: 1 addition & 1 deletion grove-search.el
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

;;; Code:

(require 'grove)
(require 'grove-core)

(declare-function consult--grep "consult")
(declare-function consult--grep-make-builder "consult")
Expand Down
2 changes: 1 addition & 1 deletion grove-tree.el
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
(require 'cl-lib)
(require 'ewoc)
(require 'hl-line)
(require 'grove)
(require 'grove-core)

;;;; Customization

Expand Down
2 changes: 1 addition & 1 deletion grove-ui.el
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

;;; Code:

(require 'grove)
(require 'grove-core)
(require 'grove-tree)

;;;; Window configuration
Expand Down
Loading