initial commit

This commit is contained in:
2026-02-18 10:26:44 +01:00
commit a8bccc3dea
8 changed files with 1597 additions and 0 deletions

24
Makefile Normal file
View File

@@ -0,0 +1,24 @@
EMACS ?= emacs
SRCS = org-roam-project.el
ELCS = $(SRCS:.el=.elc)
.PHONY: all compile test clean
all: compile test
compile: $(ELCS)
%.elc: %.el
$(EMACS) --batch -l org-roam -f batch-byte-compile $<
test:
$(EMACS) --batch \
-l ert \
-l org-roam \
--eval "(add-to-list 'load-path (file-name-directory (expand-file-name \"org-roam-project.el\")))" \
-l org-roam-project \
-l org-roam-project-test \
-f ert-run-tests-batch-and-exit
clean:
rm -f $(ELCS)

218
README.org Normal file
View File

@@ -0,0 +1,218 @@
#+title: org-roam-project
#+author: Luis
#+language: en
Per-project [[https://www.orgroam.com/][org-roam]] databases via [[https://www.gnu.org/software/emacs/manual/html_node/emacs/Projects.html][project.el]].
* Overview
=org-roam-project= extends org-roam so that each =project.el=-recognised project
(a Git repository, or any directory with a VCS root) can have its own isolated
notes directory and SQLite database.
When you work inside a project, the =org-roam-project-*= commands scope
automatically to that project's notes and database. Your personal zettelkasten
— the global =org-roam-directory= — is completely unaffected.
#+begin_example
Personal zettelkasten Project notes
┌──────────────────┐ ┌──────────────────────────┐
│ ~/org-roam/ │ │ ~/code/my-project/ │
│ │ │ notes/ ← notes │
│ ~/.emacs.d/ │ │ notes/.org-roam.db ← DB │
│ org-roam.db │ └──────────────────────────┘
└──────────────────┘
▲ ▲
(not in a project) (inside a project)
#+end_example
* Requirements
- Emacs 28.1 or later (=project.el= is built in)
- [[https://github.com/org-roam/org-roam][org-roam]] 2.2.0 or later
- A working org-roam setup (=org-roam-directory= already configured)
* Installation
** Manual
Clone or copy =org-roam-project.el= somewhere on your =load-path=, then:
#+begin_src emacs-lisp
(require 'org-roam-project)
(org-roam-project-mode)
#+end_src
** use-package
#+begin_src emacs-lisp
(use-package org-roam-project
:load-path "path/to/org-roam-project"
:after org-roam
:config
(org-roam-project-mode))
#+end_src
* Quick start
1. Open any file inside a =project.el= project (e.g. a Git repository).
2. Initialise org-roam for that project:
#+begin_example
M-x org-roam-project-init
#+end_example
This creates the notes subdirectory (=notes/= by default), adds the database
file to =.gitignore= if one is present, and runs an initial database sync.
3. Start taking notes:
#+begin_example
M-x org-roam-project-node-find ; find or create a note
M-x org-roam-project-capture ; capture a new note
#+end_example
4. These commands are also available from the project dispatch menu:
#+begin_example
C-x p p ; project-switch-project
n ; Roam find (org-roam-project-node-find)
N ; Roam capture (org-roam-project-capture)
#+end_example
* Commands
| Command | Description |
|-----------------------------------+------------------------------------------------------------------|
| =org-roam-project-init= | Initialise the current project (create dir, update .gitignore, sync DB) |
| =org-roam-project-node-find= | Find or create a node in the project (like =org-roam-node-find=) |
| =org-roam-project-node-insert= | Insert a link to a project node (like =org-roam-node-insert=) |
| =org-roam-project-capture= | Capture a new project note (like =org-roam-capture=) |
| =org-roam-project-buffer-toggle= | Toggle the backlinks buffer scoped to the project |
| =org-roam-project-db-sync= | Manually re-sync the project's org-roam database |
* Configuration
** Customisation variables
| Variable | Default | Description |
|-------------------------------------+------------------+------------------------------------------------------|
| =org-roam-project-notes-subdir= | ="notes"= | Subdirectory within the project root for notes |
| =org-roam-project-db-filename= | =".org-roam.db"= | Filename of the per-project SQLite database |
| =org-roam-project-auto-create= | =nil= | Automatically create the notes dir if it is missing |
These can be changed globally:
#+begin_src emacs-lisp
(setq org-roam-project-notes-subdir "docs/notes"
org-roam-project-db-filename ".roam.db")
#+end_src
** Per-project configuration via =.dir-locals.el=
All three variables are declared =safe-local-variable=, so they can be
overridden in a project's =.dir-locals.el= without Emacs prompting you:
#+begin_src emacs-lisp
;; ~/code/my-project/.dir-locals.el
((nil . ((org-roam-project-notes-subdir . "docs/notes")
(org-roam-project-db-filename . ".org-roam.db"))))
#+end_src
This lets different projects store their notes in different places without
changing the global defaults.
** Automatic notes directory creation
By default, commands signal an error when the project's notes directory does
not exist, reminding you to run =org-roam-project-init= first. If you prefer
the directory to be created automatically on first use, set:
#+begin_src emacs-lisp
(setq org-roam-project-auto-create t)
#+end_src
* How it works
** Context detection
Every command calls =org-roam-project--context= which:
1. Calls =(project-current)= to detect the project from the current
=default-directory=.
2. Reads =org-roam-project-notes-subdir= and =org-roam-project-db-filename=,
respecting any =.dir-locals.el= values for the project root.
3. Returns the absolute paths to the notes directory and database file, or
=nil= if the notes directory does not exist.
** Scoping via =let=-binding
Rather than patching org-roam internals, the package simply =let=-binds
=org-roam-directory= and =org-roam-db-location= around every call:
#+begin_src emacs-lisp
(let ((org-roam-directory "/path/to/project/notes")
(org-roam-db-location "/path/to/project/notes/.org-roam.db"))
(org-roam-node-find))
#+end_src
Because org-roam's internal connection cache is keyed by =org-roam-directory=,
multiple project databases can be open simultaneously with no conflicts.
** Autosync on save
When =org-roam-project-mode= is active, an =:around= advice on
=org-roam-db-update-file= intercepts every save. If the file being saved lives
inside a project's initialised notes directory, the advice temporarily binds
the project context so the update goes to the right database. Files saved
outside any project's notes directory continue to update the global database as
normal.
** project-switch-commands integration
With =org-roam-project-mode= enabled, two entries are added to
=project-switch-commands=:
#+begin_src emacs-lisp
(org-roam-project-node-find "Roam find" ?n)
(org-roam-project-capture "Roam capture" ?N)
#+end_src
These appear in the =C-x p p= dispatch menu and work correctly with
=project-current-directory-override=, so switching to a project with
=C-x p p= and then pressing =n= opens that project's notes, not the global
zettelkasten.
* File layout
After running =org-roam-project-init= in a project, the layout looks like:
#+begin_example
~/code/my-project/
├── .git/
├── .gitignore ← .org-roam.db appended automatically
├── src/
│ └── ...
└── notes/ ← org-roam-project-notes-subdir
├── .org-roam.db ← org-roam-project-db-filename (not committed)
├── 20260101T120000--my-first-note.org
└── 20260215T090000--another-note.org
#+end_example
* Limitations
- *No cross-project linking.* Each project database is isolated. Links between
a project note and your personal zettelkasten (or another project) will not
appear as backlinks.
- *Global capture templates.* Per-project capture templates are not yet
supported. The global =org-roam-capture-templates= are used in all projects.
As a workaround, you can set =org-roam-capture-templates= via =.dir-locals.el=.
- *No graph visualisation scoping.* =org-roam-ui= (if you use it) is not
automatically scoped to the project database.
* License
GPLv3 or later. See the header of =org-roam-project.el= for details.

496
flake.lock generated Normal file
View File

@@ -0,0 +1,496 @@
{
"nodes": {
"blueprint": {
"inputs": {
"nixpkgs": [
"jailed-agents",
"llm-agents",
"nixpkgs"
],
"systems": "systems_3"
},
"locked": {
"lastModified": 1769353768,
"narHash": "sha256-zI+7cbMI4wMIR57jMjDSEsVb3grapTnURDxxJPYFIW0=",
"owner": "numtide",
"repo": "blueprint",
"rev": "c7da5c70ad1c9b60b6f5d4f674fbe205d48d8f6c",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "blueprint",
"type": "github"
}
},
"blueprint_2": {
"inputs": {
"nixpkgs": [
"mcp-servers",
"jailed-agents",
"llm-agents",
"nixpkgs"
],
"systems": "systems_6"
},
"locked": {
"lastModified": 1769353768,
"narHash": "sha256-zI+7cbMI4wMIR57jMjDSEsVb3grapTnURDxxJPYFIW0=",
"owner": "numtide",
"repo": "blueprint",
"rev": "c7da5c70ad1c9b60b6f5d4f674fbe205d48d8f6c",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "blueprint",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_3": {
"inputs": {
"systems": "systems_4"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_4": {
"inputs": {
"systems": "systems_5"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"jail-nix": {
"locked": {
"lastModified": 1765903853,
"narHash": "sha256-buoPpx7moJzAXbLuHAImn6x9fGRdk3x0T57goPv1vnc=",
"owner": "~alexdavid",
"repo": "jail.nix",
"rev": "bf9f49c8118e7a77b68a675dbe26e93e91412066",
"type": "sourcehut"
},
"original": {
"owner": "~alexdavid",
"repo": "jail.nix",
"type": "sourcehut"
}
},
"jail-nix_2": {
"locked": {
"lastModified": 1765903853,
"narHash": "sha256-buoPpx7moJzAXbLuHAImn6x9fGRdk3x0T57goPv1vnc=",
"owner": "~alexdavid",
"repo": "jail.nix",
"rev": "bf9f49c8118e7a77b68a675dbe26e93e91412066",
"type": "sourcehut"
},
"original": {
"owner": "~alexdavid",
"repo": "jail.nix",
"type": "sourcehut"
}
},
"jailed-agents": {
"inputs": {
"flake-utils": "flake-utils_2",
"jail-nix": "jail-nix",
"llm-agents": "llm-agents",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1770246631,
"narHash": "sha256-a44ePXknnnWC7B3r8D4XgvypAdJ0hSfkBXuvRC/3j7M=",
"ref": "refs/heads/main",
"rev": "fc658b4112f5d924a038d5a3699eae3917371654",
"revCount": 6,
"type": "git",
"url": "https://gitea@gitea.bueso.eu/luis/jailed-agents"
},
"original": {
"type": "git",
"url": "https://gitea@gitea.bueso.eu/luis/jailed-agents"
}
},
"jailed-agents_2": {
"inputs": {
"flake-utils": "flake-utils_4",
"jail-nix": "jail-nix_2",
"llm-agents": "llm-agents_2",
"nixpkgs": "nixpkgs_4"
},
"locked": {
"lastModified": 1770246631,
"narHash": "sha256-a44ePXknnnWC7B3r8D4XgvypAdJ0hSfkBXuvRC/3j7M=",
"ref": "refs/heads/main",
"rev": "fc658b4112f5d924a038d5a3699eae3917371654",
"revCount": 6,
"type": "git",
"url": "https://gitea@gitea.bueso.eu/luis/jailed-agents"
},
"original": {
"type": "git",
"url": "https://gitea@gitea.bueso.eu/luis/jailed-agents"
}
},
"llm-agents": {
"inputs": {
"blueprint": "blueprint",
"nixpkgs": "nixpkgs",
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1769813338,
"narHash": "sha256-IlRKon8+bfoi/uOa8CUPAAWW0Pv6AHBSF1jVSD4QO8U=",
"owner": "numtide",
"repo": "llm-agents.nix",
"rev": "58939415e56d01c30d429cf0c49a9d8e2a6a07c3",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "llm-agents.nix",
"type": "github"
}
},
"llm-agents_2": {
"inputs": {
"blueprint": "blueprint_2",
"nixpkgs": "nixpkgs_3",
"treefmt-nix": "treefmt-nix_2"
},
"locked": {
"lastModified": 1769813338,
"narHash": "sha256-IlRKon8+bfoi/uOa8CUPAAWW0Pv6AHBSF1jVSD4QO8U=",
"owner": "numtide",
"repo": "llm-agents.nix",
"rev": "58939415e56d01c30d429cf0c49a9d8e2a6a07c3",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "llm-agents.nix",
"type": "github"
}
},
"mcp-servers": {
"inputs": {
"flake-utils": "flake-utils_3",
"jailed-agents": "jailed-agents_2",
"nixpkgs": "nixpkgs_5"
},
"locked": {
"lastModified": 1770667209,
"narHash": "sha256-axquYikqCHHYvkV7M22IrAv/8rEBma7OkjWL4/DLRR4=",
"ref": "refs/heads/main",
"rev": "46b2d3d1fe1d4fbf8c290398eeab801fe2076be3",
"revCount": 21,
"type": "git",
"url": "https://gitea@gitea.bueso.eu/luis/mcp-servers"
},
"original": {
"type": "git",
"url": "https://gitea@gitea.bueso.eu/luis/mcp-servers"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1769740369,
"narHash": "sha256-xKPyJoMoXfXpDM5DFDZDsi9PHArf2k5BJjvReYXoFpM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6308c3b21396534d8aaeac46179c14c439a89b8a",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1769461804,
"narHash": "sha256-msG8SU5WsBUfVVa/9RPLaymvi5bI8edTavbIq3vRlhI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "bfc1b8a4574108ceef22f02bafcf6611380c100d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1769740369,
"narHash": "sha256-xKPyJoMoXfXpDM5DFDZDsi9PHArf2k5BJjvReYXoFpM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6308c3b21396534d8aaeac46179c14c439a89b8a",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_4": {
"locked": {
"lastModified": 1769461804,
"narHash": "sha256-msG8SU5WsBUfVVa/9RPLaymvi5bI8edTavbIq3vRlhI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "bfc1b8a4574108ceef22f02bafcf6611380c100d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_5": {
"locked": {
"lastModified": 1770181073,
"narHash": "sha256-ksTL7P9QC1WfZasNlaAdLOzqD8x5EPyods69YBqxSfk=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "bf922a59c5c9998a6584645f7d0de689512e444c",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_6": {
"locked": {
"lastModified": 1771008912,
"narHash": "sha256-gf2AmWVTs8lEq7z/3ZAsgnZDhWIckkb+ZnAo5RzSxJg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a82ccc39b39b621151d6732718e3e250109076fa",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"jailed-agents": "jailed-agents",
"mcp-servers": "mcp-servers",
"nixpkgs": "nixpkgs_6"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_3": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_4": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_5": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_6": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"jailed-agents",
"llm-agents",
"nixpkgs"
]
},
"locked": {
"lastModified": 1769691507,
"narHash": "sha256-8aAYwyVzSSwIhP2glDhw/G0i5+wOrren3v6WmxkVonM=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "28b19c5844cc6e2257801d43f2772a4b4c050a1b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
},
"treefmt-nix_2": {
"inputs": {
"nixpkgs": [
"mcp-servers",
"jailed-agents",
"llm-agents",
"nixpkgs"
]
},
"locked": {
"lastModified": 1769691507,
"narHash": "sha256-8aAYwyVzSSwIhP2glDhw/G0i5+wOrren3v6WmxkVonM=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "28b19c5844cc6e2257801d43f2772a4b4c050a1b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

53
flake.nix Normal file
View File

@@ -0,0 +1,53 @@
{
nixConfig = {
extra-substituters = [
"https://nix-community.cachix.org"
];
extra-trusted-public-keys = [
"nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
];
};
description = "An emacs elisp project";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
jailed-agents.url = "git+https://gitea@gitea.bueso.eu/luis/jailed-agents";
mcp-servers.url = "git+https://gitea@gitea.bueso.eu/luis/mcp-servers";
};
outputs =
{
nixpkgs,
flake-utils,
jailed-agents,
mcp-servers,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
baseShell = import ./shell.nix { inherit pkgs; };
mcpServers =
with pkgs;
[ mcp-nixos ]
++ (with mcp-servers.packages.${system}; [ duckduckgo-mcp-server ]);
emacs = pkgs.emacs.pkgs.withPackages (epkgs: [ epkgs.org-roam ]);
in
{
devShells.default = pkgs.mkShell {
inputsFrom = [ baseShell ];
packages =
mcpServers
++ [
(jailed-agents.lib.${system}.makeJailed system {
agentTool = "opencode";
extraPkgs = mcpServers ++ [emacs];
})
];
};
}
);
}

15
opencode.json Normal file
View File

@@ -0,0 +1,15 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"nixos": {
"type": "local",
"command": ["mcp-nixos"],
"enabled": true
},
"duckduckgo": {
"type": "local",
"command": ["duckduckgo-mcp-server"],
"enabled": true
}
}
}

426
org-roam-project-test.el Normal file
View File

@@ -0,0 +1,426 @@
;;; org-roam-project-test.el --- ERT tests for org-roam-project -*- lexical-binding: t; -*-
;; Copyright (C) 2026
;; 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.
;;; Commentary:
;; ERT test suite for org-roam-project.el.
;;
;; Tests use real org-roam (not mocked) to catch compatibility issues.
;; Each test that needs a project creates a temporary git-initialized
;; directory so that project.el's VC backend recognises it.
;;
;; Run from the command line:
;;
;; make test
;;; Code:
(require 'ert)
(require 'cl-lib)
(require 'org-roam)
(require 'org-roam-project)
;;; ─── Test helpers ────────────────────────────────────────────────────────────
(defun orp-test--make-temp-project ()
"Create a temporary directory and initialise it as a git repository.
Returns the absolute path to the project root (with trailing slash)."
(let ((dir (file-name-as-directory
(make-temp-file "orp-test-project-" t))))
;; Git init so project.el recognises it via `project-try-vc'.
(let ((default-directory dir))
(call-process "git" nil nil nil "init" "--quiet")
(call-process "git" nil nil nil "config" "user.email" "test@test.com")
(call-process "git" nil nil nil "config" "user.name" "Test"))
dir))
(defun orp-test--cleanup (dir)
"Recursively delete DIR and remove it from `project--list' if present."
(when (file-directory-p dir)
(delete-directory dir t))
;; Forget the project so it doesn't pollute other tests.
;; Guard against Emacs 30's `unset' sentinel value for project--list.
(when (and (boundp 'project--list)
(listp project--list))
(setq project--list
(cl-remove-if (lambda (entry)
(string-prefix-p dir (car entry)))
project--list))))
(defmacro orp-test--with-project (&rest body)
"Execute BODY inside a fresh temporary git project.
Binds `test-root' (project root with trailing slash) and
`test-notes-dir' (notes subdirectory path, not yet created).
Cleans up unconditionally after BODY."
(declare (indent 0) (debug t))
`(let* ((test-root (orp-test--make-temp-project))
(test-notes-dir (expand-file-name org-roam-project-notes-subdir test-root))
(default-directory test-root))
(unwind-protect
(progn ,@body)
(orp-test--cleanup test-root))))
(defmacro orp-test--with-initialized-project (&rest body)
"Execute BODY inside a fresh project whose notes directory already exists.
Also isolates `org-roam-directory' and `org-roam-db-location' so that no
real org-roam database is opened globally."
(declare (indent 0) (debug t))
`(orp-test--with-project
(make-directory test-notes-dir t)
(let* ((org-roam-directory test-notes-dir)
(org-roam-db-location
(expand-file-name org-roam-project-db-filename test-notes-dir)))
,@body)))
;;; ─── 1. Customisation defaults ───────────────────────────────────────────────
(ert-deftest orp-test-default-notes-subdir ()
"Default value of `org-roam-project-notes-subdir' is \"notes\"."
(should (equal (default-value 'org-roam-project-notes-subdir) "notes")))
(ert-deftest orp-test-default-db-filename ()
"Default value of `org-roam-project-db-filename' is \".org-roam.db\"."
(should (equal (default-value 'org-roam-project-db-filename) ".org-roam.db")))
(ert-deftest orp-test-default-auto-create ()
"Default value of `org-roam-project-auto-create' is nil."
(should (null (default-value 'org-roam-project-auto-create))))
;;; ─── 2. Safe-local-variable predicates ──────────────────────────────────────
(ert-deftest orp-test-safe-local-notes-subdir ()
"`org-roam-project-notes-subdir' is safe as a dir-local when it is a string."
(should (equal (get 'org-roam-project-notes-subdir 'safe-local-variable)
#'stringp)))
(ert-deftest orp-test-safe-local-db-filename ()
"`org-roam-project-db-filename' is safe as a dir-local when it is a string."
(should (equal (get 'org-roam-project-db-filename 'safe-local-variable)
#'stringp)))
;;; ─── 3. org-roam-project--value-in-dir ──────────────────────────────────────
(ert-deftest orp-test-value-in-dir-fallback ()
"Falls back to the global value when no dir-locals override is present."
(orp-test--with-project
;; No .dir-locals.el exists, so the global default should be returned.
(let ((org-roam-project-notes-subdir "notes"))
(should (equal (org-roam-project--value-in-dir
'org-roam-project-notes-subdir test-root)
"notes")))))
(ert-deftest orp-test-value-in-dir-override ()
"Returns the dir-local value when .dir-locals.el overrides the variable."
(orp-test--with-project
;; Write a .dir-locals.el that sets the notes subdir to "docs/notes".
(let ((dir-locals-file (expand-file-name ".dir-locals.el" test-root)))
(with-temp-file dir-locals-file
(insert "((nil . ((org-roam-project-notes-subdir . \"docs/notes\"))))"))
(should (equal (org-roam-project--value-in-dir
'org-roam-project-notes-subdir test-root)
"docs/notes")))))
;;; ─── 4. org-roam-project--context ───────────────────────────────────────────
(ert-deftest orp-test-context-outside-project ()
"`org-roam-project--context' returns nil when not inside a project."
;; Use the system temp dir which is unlikely to be a project root.
(let ((default-directory temporary-file-directory))
(should (null (org-roam-project--context temporary-file-directory)))))
(ert-deftest orp-test-context-no-notes-dir-auto-create-nil ()
"Returns nil when the notes dir does not exist and auto-create is nil."
(orp-test--with-project
(let ((org-roam-project-auto-create nil))
;; Notes dir has NOT been created.
(should (null (org-roam-project--context test-root))))))
(ert-deftest orp-test-context-returns-cons-when-notes-dir-exists ()
"Returns (notes-dir . db-path) when the notes dir exists."
(orp-test--with-project
(make-directory test-notes-dir t)
(let ((ctx (org-roam-project--context test-root)))
(should (consp ctx))
;; Normalise both sides: the source uses expand-file-name without
;; a trailing slash; file-name-as-directory makes comparison robust.
(should (string= (file-name-as-directory (car ctx))
(file-name-as-directory test-notes-dir)))
(should (string= (cdr ctx)
(expand-file-name org-roam-project-db-filename
test-notes-dir))))))
(ert-deftest orp-test-context-auto-create ()
"Auto-creates the notes dir when `org-roam-project-auto-create' is non-nil."
(orp-test--with-project
(let ((org-roam-project-auto-create t))
;; Notes dir does not exist yet.
(should (not (file-directory-p test-notes-dir)))
(let ((ctx (org-roam-project--context test-root)))
(should (consp ctx))
;; The directory should now have been created.
(should (file-directory-p test-notes-dir))))))
(ert-deftest orp-test-context-respects-custom-subdir ()
"Context uses a custom notes subdir."
(orp-test--with-project
(let* ((org-roam-project-notes-subdir "custom-notes")
(custom-notes-dir (expand-file-name "custom-notes" test-root)))
(make-directory custom-notes-dir t)
(let ((ctx (org-roam-project--context test-root)))
(should (consp ctx))
(should (string-prefix-p custom-notes-dir (car ctx)))))))
(ert-deftest orp-test-context-respects-custom-db-filename ()
"Context uses a custom database filename."
(orp-test--with-project
(let ((org-roam-project-db-filename "my-custom.db"))
(make-directory test-notes-dir t)
(let ((ctx (org-roam-project--context test-root)))
(should (consp ctx))
(should (string-suffix-p "my-custom.db" (cdr ctx)))))))
;;; ─── 5. org-roam-project--with-context (macro) ──────────────────────────────
(ert-deftest orp-test-with-context-binds-org-roam-vars ()
"The macro rebinds `org-roam-directory' and `org-roam-db-location'."
(orp-test--with-initialized-project
;; Inside an initialized project the macro should bind to project values.
(let ((captured-dir nil)
(captured-db nil))
(org-roam-project--with-context
(setq captured-dir org-roam-directory)
(setq captured-db org-roam-db-location))
(should (string= (file-name-as-directory captured-dir)
(file-name-as-directory test-notes-dir)))
(should (string= captured-db
(expand-file-name org-roam-project-db-filename
test-notes-dir))))))
(ert-deftest orp-test-with-context-passthrough-outside-project ()
"The macro uses global values when there is no project context."
(let* ((global-dir "/tmp/global-notes/")
(global-db "/tmp/global-notes/.org-roam.db")
(org-roam-directory global-dir)
(org-roam-db-location global-db)
(default-directory temporary-file-directory)
(captured-dir nil)
(captured-db nil))
(org-roam-project--with-context
(setq captured-dir org-roam-directory)
(setq captured-db org-roam-db-location))
(should (string= captured-dir global-dir))
(should (string= captured-db global-db))))
;;; ─── 6. org-roam-project--require-context ───────────────────────────────────
(ert-deftest orp-test-require-context-no-project ()
"Signals `user-error' when not inside a project."
(let ((default-directory temporary-file-directory))
(should-error (org-roam-project--require-context)
:type 'user-error)))
(ert-deftest orp-test-require-context-no-notes-dir ()
"Signals `user-error' mentioning `org-roam-project-init' when notes dir absent."
(orp-test--with-project
(let ((err (should-error (org-roam-project--require-context)
:type 'user-error)))
(should (string-match-p "org-roam-project-init"
(cadr err))))))
(ert-deftest orp-test-require-context-success ()
"Returns (notes-dir . db-path) when the project is properly initialised."
(orp-test--with-initialized-project
(let ((ctx (org-roam-project--require-context)))
(should (consp ctx))
(should (string= (file-name-as-directory (car ctx))
(file-name-as-directory test-notes-dir)))
(should (string= (cdr ctx)
(expand-file-name org-roam-project-db-filename
test-notes-dir))))))
;;; ─── 7. org-roam-project--add-to-gitignore ──────────────────────────────────
(ert-deftest orp-test-gitignore-no-file ()
"Does nothing when there is no .gitignore in the project root."
(orp-test--with-project
;; Ensure no .gitignore exists.
(let ((gitignore (expand-file-name ".gitignore" test-root)))
(should (not (file-exists-p gitignore)))
(org-roam-project--add-to-gitignore test-root ".org-roam.db")
;; Still should not exist.
(should (not (file-exists-p gitignore))))))
(ert-deftest orp-test-gitignore-appends-entry ()
"Appends the db filename as a new line when not already present."
(orp-test--with-project
(let ((gitignore (expand-file-name ".gitignore" test-root)))
(with-temp-file gitignore
(insert "*.log\n"))
(org-roam-project--add-to-gitignore test-root ".org-roam.db")
(let ((contents (with-temp-buffer
(insert-file-contents gitignore)
(buffer-string))))
(should (string-match-p "^\.org-roam\.db$" contents))))))
(ert-deftest orp-test-gitignore-no-duplicate ()
"Does not append the entry when it is already present."
(orp-test--with-project
(let ((gitignore (expand-file-name ".gitignore" test-root)))
(with-temp-file gitignore
(insert "*.log\n.org-roam.db\n"))
(org-roam-project--add-to-gitignore test-root ".org-roam.db")
(let ((contents (with-temp-buffer
(insert-file-contents gitignore)
(buffer-string))))
;; Count occurrences — should be exactly 1.
(let ((count 0)
(start 0))
(while (string-match "\\.org-roam\\.db" contents start)
(cl-incf count)
(setq start (match-end 0)))
(should (= count 1)))))))
(ert-deftest orp-test-gitignore-adds-newline-before-entry ()
"Ensures a newline is inserted before the entry when the file lacks one."
(orp-test--with-project
(let ((gitignore (expand-file-name ".gitignore" test-root)))
;; Write file WITHOUT a trailing newline.
(with-temp-file gitignore
(insert "*.log"))
(org-roam-project--add-to-gitignore test-root ".org-roam.db")
(let ((contents (with-temp-buffer
(insert-file-contents gitignore)
(buffer-string))))
;; The db entry should be on its own line (preceded by \n).
(should (string-match-p "\n\\.org-roam\\.db\n" contents))))))
;;; ─── 8. org-roam-project--update-file-advice ────────────────────────────────
(ert-deftest orp-test-update-file-advice-with-project ()
"Advice rebinds org-roam vars when the file belongs to an initialised project."
(orp-test--with-initialized-project
(let* ((note-file (expand-file-name "test-note.org" test-notes-dir))
(seen-dir nil)
(seen-db nil)
(orig-fn (lambda (&optional _file _no-req)
(setq seen-dir org-roam-directory)
(setq seen-db org-roam-db-location))))
(with-temp-file note-file (insert "* Test\n"))
(org-roam-project--update-file-advice orig-fn note-file nil)
(should (string= (file-name-as-directory seen-dir)
(file-name-as-directory test-notes-dir)))
(should (string= seen-db
(expand-file-name org-roam-project-db-filename
test-notes-dir))))))
(ert-deftest orp-test-update-file-advice-without-project ()
"Advice calls orig-fn unchanged when the file is not in any project."
(let* ((global-dir "/tmp/global-roam/")
(global-db "/tmp/global-roam/.org-roam.db")
(org-roam-directory global-dir)
(org-roam-db-location global-db)
(seen-dir nil)
(seen-db nil)
(orig-fn (lambda (&optional _file _no-req)
(setq seen-dir org-roam-directory)
(setq seen-db org-roam-db-location)))
;; A file that lives in /tmp and is not in any VC project.
(lone-file (concat temporary-file-directory "lone-file.org")))
(org-roam-project--update-file-advice orig-fn lone-file nil)
(should (string= seen-dir global-dir))
(should (string= seen-db global-db))))
;;; ─── 9. project-switch-commands integration ─────────────────────────────────
(defun orp-test--switch-command-present-p (cmd)
"Return non-nil when CMD appears in `project-switch-commands'."
(cl-find cmd project-switch-commands :key #'car))
(ert-deftest orp-test-register-adds-commands ()
"Registering adds both roam-find and capture entries."
(let ((project-switch-commands (copy-sequence project-switch-commands)))
(org-roam-project--register-switch-commands)
(should (orp-test--switch-command-present-p 'org-roam-project-node-find))
(should (orp-test--switch-command-present-p 'org-roam-project-capture))))
(ert-deftest orp-test-unregister-removes-commands ()
"Unregistering removes both entries."
(let ((project-switch-commands (copy-sequence project-switch-commands)))
(org-roam-project--register-switch-commands)
(org-roam-project--unregister-switch-commands)
(should (not (orp-test--switch-command-present-p 'org-roam-project-node-find)))
(should (not (orp-test--switch-command-present-p 'org-roam-project-capture)))))
(ert-deftest orp-test-register-is-idempotent ()
"Calling register twice does not produce duplicate entries."
(let ((project-switch-commands (copy-sequence project-switch-commands)))
(org-roam-project--register-switch-commands)
(org-roam-project--register-switch-commands)
(let ((find-count
(cl-count 'org-roam-project-node-find project-switch-commands
:key #'car))
(capture-count
(cl-count 'org-roam-project-capture project-switch-commands
:key #'car)))
(should (= find-count 1))
(should (= capture-count 1)))))
;;; ─── 10. org-roam-project-mode ──────────────────────────────────────────────
(defmacro orp-test--with-mode-off (&rest body)
"Execute BODY ensuring `org-roam-project-mode' is disabled beforehand
and restored to its prior state afterward."
(declare (indent 0) (debug t))
`(let ((was-on org-roam-project-mode)
(project-switch-commands (copy-sequence project-switch-commands)))
(when org-roam-project-mode
(org-roam-project-mode -1))
(unwind-protect
(progn ,@body)
;; Restore: ensure mode is off after the test regardless.
(when org-roam-project-mode
(org-roam-project-mode -1))
(when was-on
(org-roam-project-mode 1)))))
(ert-deftest orp-test-mode-enable-installs-advice ()
"Enabling the mode installs advice on `org-roam-db-update-file'."
(orp-test--with-mode-off
(org-roam-project-mode 1)
(should (advice-member-p #'org-roam-project--update-file-advice
'org-roam-db-update-file))))
(ert-deftest orp-test-mode-disable-removes-advice ()
"Disabling the mode removes the advice."
(orp-test--with-mode-off
(org-roam-project-mode 1)
(org-roam-project-mode -1)
(should (not (advice-member-p #'org-roam-project--update-file-advice
'org-roam-db-update-file)))))
(ert-deftest orp-test-mode-enable-registers-switch-commands ()
"Enabling the mode adds commands to `project-switch-commands'."
(orp-test--with-mode-off
(org-roam-project-mode 1)
(should (orp-test--switch-command-present-p 'org-roam-project-node-find))
(should (orp-test--switch-command-present-p 'org-roam-project-capture))))
(ert-deftest orp-test-mode-disable-unregisters-switch-commands ()
"Disabling the mode removes commands from `project-switch-commands'."
(orp-test--with-mode-off
(org-roam-project-mode 1)
(org-roam-project-mode -1)
(should (not (orp-test--switch-command-present-p 'org-roam-project-node-find)))
(should (not (orp-test--switch-command-present-p 'org-roam-project-capture)))))
(provide 'org-roam-project-test)
;;; org-roam-project-test.el ends here

354
org-roam-project.el Normal file
View File

@@ -0,0 +1,354 @@
;;; 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

11
shell.nix Normal file
View File

@@ -0,0 +1,11 @@
{ pkgs ? import <nixpkgs> { } }:
let
emacsWithPackages = pkgs.emacs.pkgs.withPackages (epkgs: [
epkgs.org-roam
]);
in
pkgs.mkShell {
buildInputs = [ emacsWithPackages pkgs.git ];
}