362 lines
14 KiB
EmacsLisp
362 lines
14 KiB
EmacsLisp
;;; org-roam-project.el --- Per-project org-roam databases via project.el -*- lexical-binding: t; -*-
|
|
|
|
;; Copyright (C) 2026
|
|
|
|
;; Author: Luis
|
|
;; Version: 0.1.0
|
|
;; Package-Requires: ((emacs "28.1") (org-roam "2.2.0"))
|
|
;; Keywords: org-roam, project, notes
|
|
;; URL: https://github.com/example/org-roam-project
|
|
|
|
;; 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:
|
|
|
|
;; This package extends org-roam to support per-project databases using
|
|
;; project.el for project detection. Each project.el-recognized project
|
|
;; can have its own isolated org-roam notes directory and SQLite database.
|
|
;;
|
|
;; When you are inside a project, org-roam-project commands scope to that
|
|
;; project's notes and database. Your personal zettelkasten (the global
|
|
;; org-roam setup) is completely unaffected.
|
|
;;
|
|
;; Quick start:
|
|
;;
|
|
;; (use-package org-roam-project
|
|
;; :after (org-roam)
|
|
;; :config
|
|
;; (org-roam-project-mode))
|
|
;;
|
|
;; Then, inside any project.el project:
|
|
;;
|
|
;; M-x org-roam-project-init — set up notes dir and DB for the project
|
|
;; M-x org-roam-project-node-find — find/create a note in the project
|
|
;; M-x org-roam-project-capture — capture a new project note
|
|
;;
|
|
;; The commands are also available via C-x p p (project-switch-project)
|
|
;; as "n" (node-find) and "N" (capture) when org-roam-project-mode is on.
|
|
;;
|
|
;; Per-project configuration via .dir-locals.el:
|
|
;;
|
|
;; ((nil . ((org-roam-project-notes-subdir . "docs/notes")
|
|
;; (org-roam-project-db-filename . ".org-roam.db"))))
|
|
|
|
;;; Code:
|
|
|
|
(require 'project)
|
|
(require 'org-roam)
|
|
|
|
;;; Customization
|
|
|
|
(defgroup org-roam-project nil
|
|
"Per-project org-roam databases."
|
|
:group 'org-roam
|
|
:prefix "org-roam-project-")
|
|
|
|
(defcustom org-roam-project-notes-subdir "notes"
|
|
"Subdirectory within the project root used for org-roam files.
|
|
This can be overridden per-project via .dir-locals.el."
|
|
:type 'string
|
|
:group 'org-roam-project)
|
|
;;;###autoload
|
|
(put 'org-roam-project-notes-subdir 'safe-local-variable #'stringp)
|
|
|
|
(defcustom org-roam-project-db-filename ".org-roam.db"
|
|
"Filename for the per-project org-roam SQLite database.
|
|
The file is placed inside the notes subdirectory.
|
|
This can be overridden per-project via .dir-locals.el."
|
|
:type 'string
|
|
:group 'org-roam-project)
|
|
;;;###autoload
|
|
(put 'org-roam-project-db-filename 'safe-local-variable #'stringp)
|
|
|
|
(defcustom org-roam-project-auto-create nil
|
|
"If non-nil, automatically create the notes directory when it is missing.
|
|
If nil (the default), commands will signal an error and suggest running
|
|
`org-roam-project-init' instead."
|
|
:type 'boolean
|
|
:group 'org-roam-project)
|
|
|
|
;;; Internal utilities
|
|
|
|
(defun org-roam-project--value-in-dir (var dir)
|
|
"Return the value of dir-local variable VAR as seen from directory DIR.
|
|
Falls back to the global value of VAR if no dir-local value is set."
|
|
(let ((dir (file-name-as-directory (expand-file-name dir))))
|
|
(with-temp-buffer
|
|
(setq default-directory dir)
|
|
(let ((enable-local-variables :all)
|
|
(inhibit-message t))
|
|
(hack-dir-local-variables))
|
|
(alist-get var file-local-variables-alist (symbol-value var)))))
|
|
|
|
(defun org-roam-project--context (&optional dir)
|
|
"Return the org-roam context for the project containing DIR.
|
|
When DIR is nil, the project is detected via `project-current', which
|
|
respects `project-current-directory-override' (set by
|
|
`project-switch-project' on Emacs 29+) before falling back to
|
|
`default-directory'. Pass an explicit DIR only when the caller already
|
|
has a concrete filesystem path (e.g. the autosync advice).
|
|
|
|
Returns a cons cell (NOTES-DIR . DB-PATH) where NOTES-DIR is the
|
|
absolute path to the project's org-roam notes directory and DB-PATH
|
|
is the absolute path to the SQLite database file.
|
|
|
|
Returns nil if:
|
|
- DIR is not inside a project.el project.
|
|
- The notes directory does not exist and `org-roam-project-auto-create'
|
|
is nil."
|
|
(let* ((project (if dir
|
|
(project-current nil (expand-file-name dir))
|
|
(project-current nil))))
|
|
(when project
|
|
(let* ((root (project-root project))
|
|
(notes-subdir (org-roam-project--value-in-dir
|
|
'org-roam-project-notes-subdir root))
|
|
(db-filename (org-roam-project--value-in-dir
|
|
'org-roam-project-db-filename root))
|
|
(notes-dir (expand-file-name notes-subdir root))
|
|
(db-path (expand-file-name db-filename notes-dir)))
|
|
(cond
|
|
((file-directory-p notes-dir)
|
|
(cons notes-dir db-path))
|
|
(org-roam-project-auto-create
|
|
(make-directory notes-dir t)
|
|
(message "org-roam-project: created notes directory %s" notes-dir)
|
|
(cons notes-dir db-path))
|
|
(t nil))))))
|
|
|
|
(defmacro org-roam-project--with-context (&rest body)
|
|
"Execute BODY with org-roam scoped to the current project.
|
|
If the current buffer/directory belongs to a project with an initialized
|
|
notes directory, `org-roam-directory' and `org-roam-db-location' are
|
|
let-bound to the project's values for the duration of BODY.
|
|
Otherwise BODY executes with the global org-roam settings."
|
|
(declare (indent 0) (debug t))
|
|
`(let ((ctx (org-roam-project--context)))
|
|
(if ctx
|
|
(let ((org-roam-directory (car ctx))
|
|
(org-roam-db-location (cdr ctx)))
|
|
,@body)
|
|
,@body)))
|
|
|
|
(defun org-roam-project--require-context ()
|
|
"Return the project context or signal a helpful error.
|
|
Respects `project-current-directory-override' (set by
|
|
`project-switch-project' on Emacs 29+) before falling back to
|
|
`default-directory'.
|
|
Signals `user-error' if there is no current project or the project's
|
|
notes directory has not been initialized."
|
|
(let* ((project (project-current nil)))
|
|
(unless project
|
|
(user-error "Not inside a project.el project"))
|
|
(let* ((root (project-root project))
|
|
(notes-subdir (org-roam-project--value-in-dir
|
|
'org-roam-project-notes-subdir root))
|
|
(notes-dir (expand-file-name notes-subdir root)))
|
|
(unless (file-directory-p notes-dir)
|
|
(user-error "Project notes directory %s does not exist. Run M-x org-roam-project-init to set it up"
|
|
notes-dir))
|
|
(let* ((db-filename (org-roam-project--value-in-dir
|
|
'org-roam-project-db-filename root))
|
|
(db-path (expand-file-name db-filename notes-dir)))
|
|
(cons notes-dir db-path)))))
|
|
|
|
;;; Interactive commands
|
|
|
|
;;;###autoload
|
|
(defun org-roam-project-node-find ()
|
|
"Find or create an org-roam node in the current project.
|
|
Scopes `org-roam-node-find' to the project's notes directory and database.
|
|
Signals an error if the project is not initialized; run
|
|
`org-roam-project-init' first."
|
|
(interactive)
|
|
(let ((ctx (org-roam-project--require-context)))
|
|
(let ((org-roam-directory (car ctx))
|
|
(org-roam-db-location (cdr ctx)))
|
|
(org-roam-node-find))))
|
|
|
|
;;;###autoload
|
|
(defun org-roam-project-node-insert ()
|
|
"Insert a link to an org-roam node from the current project.
|
|
Scopes `org-roam-node-insert' to the project's notes directory and database."
|
|
(interactive)
|
|
(let ((ctx (org-roam-project--require-context)))
|
|
(let ((org-roam-directory (car ctx))
|
|
(org-roam-db-location (cdr ctx)))
|
|
(org-roam-node-insert))))
|
|
|
|
;;;###autoload
|
|
(defun org-roam-project-capture ()
|
|
"Capture a new org-roam note in the current project.
|
|
Scopes `org-roam-capture' to the project's notes directory and database."
|
|
(interactive)
|
|
(let ((ctx (org-roam-project--require-context)))
|
|
(let ((org-roam-directory (car ctx))
|
|
(org-roam-db-location (cdr ctx)))
|
|
(org-roam-capture))))
|
|
|
|
;;;###autoload
|
|
(defun org-roam-project-buffer-toggle ()
|
|
"Toggle the org-roam backlinks buffer scoped to the current project."
|
|
(interactive)
|
|
(let ((ctx (org-roam-project--require-context)))
|
|
(let ((org-roam-directory (car ctx))
|
|
(org-roam-db-location (cdr ctx)))
|
|
(org-roam-buffer-toggle))))
|
|
|
|
;;;###autoload
|
|
(defun org-roam-project-db-sync ()
|
|
"Sync the org-roam database for the current project."
|
|
(interactive)
|
|
(let ((ctx (org-roam-project--require-context)))
|
|
(let ((org-roam-directory (car ctx))
|
|
(org-roam-db-location (cdr ctx)))
|
|
(org-roam-db-sync)
|
|
(message "org-roam-project: database synced for %s" (car ctx)))))
|
|
|
|
;;; Init command
|
|
|
|
(defun org-roam-project--add-to-gitignore (project-root db-filename)
|
|
"Add DB-FILENAME to .gitignore in PROJECT-ROOT if not already present.
|
|
Does nothing if there is no .gitignore file in PROJECT-ROOT."
|
|
(let ((gitignore (expand-file-name ".gitignore" project-root)))
|
|
(when (file-exists-p gitignore)
|
|
(with-temp-buffer
|
|
(insert-file-contents gitignore)
|
|
(goto-char (point-min))
|
|
;; Check if the db filename is already present as a whole line
|
|
(unless (re-search-forward (concat "^" (regexp-quote db-filename) "$") nil t)
|
|
(goto-char (point-max))
|
|
;; Ensure there is a trailing newline before appending
|
|
(unless (or (bobp) (= (char-before) ?\n))
|
|
(insert "\n"))
|
|
(insert db-filename "\n")
|
|
(write-region (point-min) (point-max) gitignore)
|
|
(message "org-roam-project: added %s to %s" db-filename gitignore))))))
|
|
|
|
;;;###autoload
|
|
(defun org-roam-project-init ()
|
|
"Initialize org-roam for the current project.
|
|
|
|
This command:
|
|
1. Detects the current project via project.el (prompts if none active).
|
|
2. Creates the notes subdirectory if it does not exist.
|
|
3. Adds the database filename to .gitignore if a .gitignore is found
|
|
in the project root and the entry is not already present.
|
|
4. Runs `org-roam-db-sync' to build the initial database.
|
|
|
|
The notes subdirectory and database filename are controlled by
|
|
`org-roam-project-notes-subdir' and `org-roam-project-db-filename',
|
|
which can be set per-project via .dir-locals.el."
|
|
(interactive)
|
|
(let* ((project (project-current t))
|
|
(root (project-root project))
|
|
(notes-subdir (org-roam-project--value-in-dir
|
|
'org-roam-project-notes-subdir root))
|
|
(db-filename (org-roam-project--value-in-dir
|
|
'org-roam-project-db-filename root))
|
|
(notes-dir (expand-file-name notes-subdir root))
|
|
(db-path (expand-file-name db-filename notes-dir)))
|
|
;; Step 1: Create notes directory
|
|
(unless (file-directory-p notes-dir)
|
|
(make-directory notes-dir t)
|
|
(message "org-roam-project: created notes directory %s" notes-dir))
|
|
;; Step 2: Update .gitignore
|
|
(org-roam-project--add-to-gitignore root db-filename)
|
|
;; Step 3: Sync database
|
|
(let ((org-roam-directory notes-dir)
|
|
(org-roam-db-location db-path))
|
|
(org-roam-db-sync))
|
|
(message "org-roam-project: initialized project at %s (notes: %s)"
|
|
root notes-dir)))
|
|
|
|
;;; Autosync advice
|
|
|
|
(defun org-roam-project--update-file-advice (orig-fn &optional file-path no-require)
|
|
"Around advice for `org-roam-db-update-file'.
|
|
Detects if FILE-PATH belongs to a project with an initialized notes
|
|
directory, and if so temporarily binds `org-roam-directory' and
|
|
`org-roam-db-location' to the project's values before calling ORIG-FN."
|
|
(let* ((file (or file-path (buffer-file-name) (buffer-name)))
|
|
(file-dir (when (stringp file)
|
|
(file-name-directory (expand-file-name file))))
|
|
(ctx (when file-dir
|
|
(org-roam-project--context file-dir))))
|
|
(if ctx
|
|
(let ((org-roam-directory (car ctx))
|
|
(org-roam-db-location (cdr ctx)))
|
|
(funcall orig-fn file-path no-require))
|
|
(funcall orig-fn file-path no-require))))
|
|
|
|
;;; project-switch-commands integration
|
|
|
|
(defvar org-roam-project--switch-commands
|
|
'((org-roam-project-node-find "Roam find" ?n)
|
|
(org-roam-project-capture "Roam capture" ?N))
|
|
"Commands to add to `project-switch-commands' when the mode is active.")
|
|
|
|
(defun org-roam-project--register-switch-commands ()
|
|
"Add org-roam-project commands to `project-switch-commands'."
|
|
(dolist (entry org-roam-project--switch-commands)
|
|
(add-to-list 'project-switch-commands entry t)))
|
|
|
|
(defun org-roam-project--unregister-switch-commands ()
|
|
"Remove org-roam-project commands from `project-switch-commands'."
|
|
(dolist (entry org-roam-project--switch-commands)
|
|
(setq project-switch-commands
|
|
(delete entry project-switch-commands))))
|
|
|
|
;;; Global minor mode
|
|
|
|
;;;###autoload
|
|
(define-minor-mode org-roam-project-mode
|
|
"Global minor mode for per-project org-roam databases.
|
|
|
|
When enabled:
|
|
- Installs an advice on `org-roam-db-update-file' so that saving an
|
|
org file inside a project's notes directory updates the project's
|
|
own database rather than the global one.
|
|
- Registers `org-roam-project-node-find' (key: n) and
|
|
`org-roam-project-capture' (key: N) in `project-switch-commands',
|
|
making them available via \\[project-switch-project].
|
|
|
|
When disabled, both the advice and the project-switch-commands entries
|
|
are removed.
|
|
|
|
Usage:
|
|
(org-roam-project-mode 1) ; enable
|
|
(org-roam-project-mode -1) ; disable"
|
|
:global t
|
|
:group 'org-roam-project
|
|
:lighter " ORP"
|
|
(if org-roam-project-mode
|
|
(progn
|
|
(advice-add 'org-roam-db-update-file
|
|
:around #'org-roam-project--update-file-advice)
|
|
(with-eval-after-load 'project
|
|
(org-roam-project--register-switch-commands))
|
|
;; If project is already loaded, register immediately.
|
|
(when (featurep 'project)
|
|
(org-roam-project--register-switch-commands)))
|
|
(advice-remove 'org-roam-db-update-file
|
|
#'org-roam-project--update-file-advice)
|
|
(org-roam-project--unregister-switch-commands)))
|
|
|
|
(provide 'org-roam-project)
|
|
|
|
;;; org-roam-project.el ends here
|