Skip to content
Open
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
6 changes: 6 additions & 0 deletions README.org
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,12 @@ Use =+= / =-= to zoom in/out and =0= to fit to window.
;; Required: set your vault directory
(setq grove-directory "~/notes/")

;; Optional: set profiles for separation of note vaults
(setq grove-profiles
'((personal :directory "~/remoteFolder/personal")
(work :directory "~/localFolder/work"))
(other :directory "~/otherNotes")))

;; Optional: customize subdirectories (defaults shown)
(setq grove-inbox-directory "inbox")
(setq grove-daily-directory "daily")
Expand Down
17 changes: 12 additions & 5 deletions grove-backlink.el
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
(require 'grove-core)
(require 'subr-x)

(declare-function grove--profile-for-file "grove-core")

(defconst grove-backlink-buffer-name "*grove-backlinks*"
"Name of the backlinks buffer.")

Expand All @@ -42,7 +44,7 @@
(defun grove-backlink--title-for-file (file)
"Return the grove title for FILE.
Uses the cache if available, otherwise the filename."
(let ((meta (gethash file grove--cache)))
(let ((meta (gethash file (grove--active-cache))))
(if meta
(plist-get meta :title)
(file-name-sans-extension (file-name-nondirectory file)))))
Expand All @@ -56,7 +58,7 @@ Each result is a plist (:file :line :context) found via ripgrep."
grove-backlink-ripgrep-executable))
(let* ((pattern (format "\\[\\[%s\\]\\]" (regexp-quote title)))
(args (list "--no-heading" "--line-number" "--context" "1"
"--glob=*.org" pattern grove-directory))
"--glob=*.org" pattern (grove--active-directory)))
results current-file)
(with-temp-buffer
(let ((exit-code (apply #'process-file grove-backlink-ripgrep-executable
Expand Down Expand Up @@ -162,10 +164,15 @@ Each result is a plist (:file :line :context) found via ripgrep."
(defun grove-backlinks ()
"Show backlinks for the current note."
(interactive)
(unless (and (buffer-file-name) (grove-file-p (buffer-file-name)))
(user-error "Not visiting a grove note"))
(unless (buffer-file-name)
(user-error "Not visiting a file"))
(unless (grove-file-p (buffer-file-name))
(let ((other (grove--profile-for-file (buffer-file-name))))
(if other
(user-error "Note is in profile [%s] — switch profiles first" other)
(user-error "Not visiting a grove note"))))
(grove--refresh-cache)
(let* ((meta (gethash (buffer-file-name) grove--cache))
(let* ((meta (gethash (buffer-file-name) (grove--active-cache)))
(title (or (plist-get meta :title)
(file-name-sans-extension
(file-name-nondirectory (buffer-file-name)))))
Expand Down
122 changes: 95 additions & 27 deletions grove-core.el
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,24 @@
(defcustom grove-directory nil
"Root directory for grove notes.
All org files in this directory and its subdirectories are
considered part of the vault."
considered part of the vault.
When `grove-profiles' is non-nil, this is used only as a fallback
for configs that have not yet migrated to profiles."
:type '(choice (const nil) directory)
:group 'grove)

(defcustom grove-profiles nil
"Alist of grove profiles, each of the form (NAME :directory DIR).
NAME is a symbol identifying the profile. DIR is the root directory.
Example:
(setq grove-profiles
\\='((personal :directory \"~/notes\")
(work :directory \"~/work/notes\")))
Use `grove-switch-profile' to select the active profile."
:type '(alist :key-type symbol
:value-type (plist :key-type keyword :value-type sexp))
:group 'grove)

(defcustom grove-inbox-directory "inbox"
"Subdirectory of `grove-directory' for captured notes.
Relative to `grove-directory'."
Expand All @@ -61,10 +75,52 @@ Passed to `format-time-string'."
:type 'string
:group 'grove)

;;;; Profile state

(defvar grove--active-profile nil
"Symbol naming the currently active profile, or nil to use `grove-directory'.")

(defvar grove--profile-caches (make-hash-table :test #'eq)
"Hash table mapping profile name symbols to their vault cache hash tables.")

(defun grove--active-directory ()
"Return the root directory for the active profile or `grove-directory'.
Returns nil if neither is configured."
(if grove--active-profile
(let ((profile (assq grove--active-profile grove-profiles)))
(unless profile
(user-error "Unknown grove profile: %s" grove--active-profile))
(file-name-as-directory
(expand-file-name (plist-get (cdr profile) :directory))))
(when grove-directory
(file-name-as-directory (expand-file-name grove-directory)))))

(defun grove--active-cache ()
"Return the cache hash table for the active profile.
Creates a new empty cache lazily on first access per profile."
(if grove--active-profile
(or (gethash grove--active-profile grove--profile-caches)
(let ((cache (make-hash-table :test #'equal)))
(puthash grove--active-profile cache grove--profile-caches)
cache))
grove--cache))

(defun grove--profile-for-file (file)
"Return the profile name symbol whose directory contains FILE, or nil."
(when grove-profiles
(catch 'found
(dolist (profile grove-profiles)
(let* ((name (car profile))
(dir (file-name-as-directory
(expand-file-name (plist-get (cdr profile) :directory)))))
(when (string-prefix-p dir (expand-file-name file))
(throw 'found name)))))))

;;;; Vault cache

(defvar grove--cache (make-hash-table :test #'equal)
"Hash table mapping absolute file paths to note metadata plists.
Used when no profile is active.
Each value is a plist with keys :title :tags :links :mtime.")

(defconst grove--hashtag-regexp
Expand Down Expand Up @@ -105,30 +161,42 @@ Matches #tag-style markers while ignoring Org keyword lines such as
(nreverse result)))

(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))))
"Ensure the active grove directory is set and exists, or prompt the user."
(when (and grove-profiles (not grove--active-profile))
(setq grove--active-profile
(intern (completing-read "Grove profile: "
(mapcar (lambda (p) (symbol-name (car p)))
grove-profiles)
nil t))))
(if grove--active-profile
(let ((dir (grove--active-directory)))
(unless (file-directory-p dir)
(if (y-or-n-p (format "Directory %s does not exist. Create it? " dir))
(make-directory dir t)
(user-error "Grove requires a vault directory"))))
(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)))
(let ((path (expand-file-name grove-inbox-directory (grove--active-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)))
(let ((path (expand-file-name grove-daily-directory (grove--active-directory))))
(unless (file-directory-p path)
(make-directory path t))
(file-name-as-directory path)))
Expand Down Expand Up @@ -179,33 +247,35 @@ Returns (:title TITLE :tags TAGS :links LINKS :mtime MTIME)."
:mtime mtime)))

(defun grove--refresh-cache ()
"Refresh the vault cache by scanning `grove-directory'.
"Refresh the vault cache by scanning the active 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)))
(let* ((dir (grove--active-directory))
(cache (grove--active-cache))
(files (directory-files-recursively dir "\\.org\\'"))
(seen (make-hash-table :test #'equal)))
;; Update or add entries
(dolist (file files)
(puthash file t seen)
(let* ((cached (gethash file grove--cache))
(let* ((cached (gethash file 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))))
(puthash file (grove--parse-note file) cache))))
;; Remove deleted files
(maphash (lambda (key _val)
(unless (gethash key seen)
(remhash key grove--cache)))
grove--cache)))
(remhash key cache)))
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)
(grove--active-cache))
(sort result (lambda (a b) (string< (car a) (car b))))))

(defun grove--sanitize-filename (title)
Expand All @@ -218,11 +288,9 @@ Downcases, replaces spaces with hyphens, strips non-alphanumeric characters."
(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))))
"Return non-nil if FILE is inside the active grove directory."
(when-let ((dir (grove--active-directory)))
(and file (string-prefix-p dir (expand-file-name file)))))

(provide 'grove-core)
;;; grove-core.el ends here
4 changes: 2 additions & 2 deletions grove-graph.el
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,15 @@ Returns an alist of (SOURCE-TITLE . (TARGET-TITLE ...))."
;; Collect all note titles
(maphash (lambda (_path meta)
(puthash (plist-get meta :title) t all-titles))
grove--cache)
(grove--active-cache))
;; Build edges from cached link data
(maphash (lambda (_path meta)
(let ((source (plist-get meta :title))
(links (plist-get meta :links)))
(dolist (target links)
(when (gethash target all-titles)
(push target (gethash source adjacency))))))
grove--cache)
(grove--active-cache))
;; Convert to alist and include isolated nodes
(let (result)
(maphash (lambda (title _)
Expand Down
6 changes: 3 additions & 3 deletions grove-inbox.el
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
(lambda (path meta)
(when (null (plist-get meta :tags))
(push (cons (plist-get meta :title) path) result)))
grove--cache)
(grove--active-cache))
(sort result (lambda (a b) (string< (car a) (car b))))))

(defun grove-inbox--unlinked-notes ()
Expand All @@ -55,7 +55,7 @@ Checks each note for backlinks via ripgrep."
(backlinks (grove-backlink--find title)))
(when (null backlinks)
(push (cons title path) result))))
grove--cache)
(grove--active-cache))
(sort result (lambda (a b) (string< (car a) (car b))))))

;;;; Display
Expand Down Expand Up @@ -110,7 +110,7 @@ NOTES is a list of (TITLE . PATH)."
(erase-buffer)
(insert (propertize "Grove Inbox Review" 'face 'bold) "\n")
(insert (propertize (format "%d notes in vault"
(hash-table-count grove--cache))
(hash-table-count (grove--active-cache)))
'face 'shadow)
"\n\n")
(grove-inbox--insert-section
Expand Down
27 changes: 23 additions & 4 deletions grove-link.el
Original file line number Diff line number Diff line change
Expand Up @@ -118,18 +118,37 @@ prompt the user to choose."
(unless target (user-error "No grove link at point"))
(grove-link-follow target)))

(defun grove-link--profile-with-title (title)
"Return the profile name that has a cached note matching TITLE, or nil.
Only searches profiles other than the currently active one."
(catch 'found
(maphash
(lambda (profile-name cache)
(unless (eq profile-name grove--active-profile)
(maphash
(lambda (_file meta)
(when (string-equal-ignore-case (plist-get meta :title) title)
(throw 'found profile-name)))
cache)))
grove--profile-caches)))

(defun grove-link-follow (title)
"Follow a grove wikilink to TITLE.
If the note doesn't exist, offer to create it."
(let ((path (grove-link--resolve title)))
(if path
(find-file path)
(if (y-or-n-p (format "Note \"%s\" not found. Create it? " title))
(let ((other-profile (grove-link--profile-with-title title)))
(cond
(other-profile
(message "Note \"%s\" is in profile [%s] — switch profiles to follow"
title other-profile))
((y-or-n-p (format "Note \"%s\" not found. Create it? " title))
(let* ((filename (concat (grove--sanitize-filename title) ".org"))
(path (grove--unique-path grove-directory filename)))
(path (grove--unique-path (grove--active-directory) filename)))
(find-file path)
(insert "#+title: " title "\n\n"))
(message "Link not followed")))))
(insert "#+title: " title "\n\n")))
(t (message "Link not followed")))))))

;;;; Insert

Expand Down
8 changes: 4 additions & 4 deletions grove-search.el
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,14 @@ available, otherwise falls back to `grep'."
(let ((consult-ripgrep-args
(concat consult-ripgrep-args " --glob=*.org")))
(consult--grep "Grove search" #'consult--grep-make-builder
grove-directory initial)))
(grove--active-directory) initial)))

(defun grove-search--grep (&optional initial)
"Search vault with grep. INITIAL is the initial input."
(let ((pattern (read-string "Grove search: " initial)))
(grep (format "rg --no-heading --line-number --glob='*.org' %s %s"
(shell-quote-argument pattern)
(shell-quote-argument grove-directory)))))
(shell-quote-argument (grove--active-directory))))))

;;;; Find note by title

Expand Down Expand Up @@ -108,10 +108,10 @@ With optional INITIAL input string. Searches for both org-style
(let ((consult-ripgrep-args
(concat consult-ripgrep-args " --glob=*.org")))
(consult--grep "Grove tags" #'consult--grep-make-builder
grove-directory pattern))
(grove--active-directory) pattern))
(grep (format "rg --no-heading --line-number --glob='*.org' %s %s"
(shell-quote-argument pattern)
(shell-quote-argument grove-directory))))))
(shell-quote-argument (grove--active-directory)))))))

(provide 'grove-search)
;;; grove-search.el ends here
8 changes: 7 additions & 1 deletion grove-tree.el
Original file line number Diff line number Diff line change
Expand Up @@ -306,9 +306,15 @@ Directories come first, then files. Hidden files are excluded."
(with-current-buffer buf
(let ((inhibit-read-only t))
(erase-buffer)
(setq header-line-format
(propertize
(if grove--active-profile
(format " Grove [%s]" grove--active-profile)
" Grove")
'face 'bold))
(setq grove-tree--ewoc
(ewoc-create #'grove-tree--print "" ""))
(grove-tree--populate grove-directory 0 nil))))))
(grove-tree--populate (grove--active-directory) 0 nil))))))

(defun grove-tree--populate (directory depth parent-node)
"Insert DIRECTORY entries at DEPTH after PARENT-NODE.
Expand Down
6 changes: 5 additions & 1 deletion grove-ui.el
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,11 @@ and displays a note in the main area."
(when-let ((file (buffer-file-name (window-buffer main-win))))
(grove-tree--set-current-file file)))
(setq grove--active-p t)
(message "Grove opened: %s" (abbreviate-file-name grove-directory)))
(message "Grove opened: %s%s"
(if grove--active-profile
(format "[%s] " grove--active-profile)
"")
(abbreviate-file-name (grove--active-directory))))

(defun grove--open-initial-note ()
"Open an initial note in the current window.
Expand Down
Loading