Files
org-roam-project/org-roam-project.el

355 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.
DIR defaults to `default-directory'.
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* ((dir (expand-file-name (or dir default-directory)))
(project (project-current nil dir)))
(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.
Signals `user-error' if there is no current project or the project's
notes directory has not been initialized."
(let* ((dir default-directory)
(project (project-current nil dir)))
(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