;;; 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 . ;;; 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