commit a8bccc3dea974272a53be38b91a6380c231c7a99 Author: Luis Eduardo Bueso de Barrio Date: Wed Feb 18 10:26:44 2026 +0100 initial commit diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..74a3a33 --- /dev/null +++ b/Makefile @@ -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) diff --git a/README.org b/README.org new file mode 100644 index 0000000..d8992c9 --- /dev/null +++ b/README.org @@ -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. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..32eec0d --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..5611dc4 --- /dev/null +++ b/flake.nix @@ -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]; + }) + ]; + }; + } + ); +} diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..5a7850e --- /dev/null +++ b/opencode.json @@ -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 + } + } +} diff --git a/org-roam-project-test.el b/org-roam-project-test.el new file mode 100644 index 0000000..9c15be0 --- /dev/null +++ b/org-roam-project-test.el @@ -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 diff --git a/org-roam-project.el b/org-roam-project.el new file mode 100644 index 0000000..acd8ad8 --- /dev/null +++ b/org-roam-project.el @@ -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 . + +;;; 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 diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..6bdc578 --- /dev/null +++ b/shell.nix @@ -0,0 +1,11 @@ +{ pkgs ? import { } }: + +let + emacsWithPackages = pkgs.emacs.pkgs.withPackages (epkgs: [ + epkgs.org-roam + ]); +in + +pkgs.mkShell { + buildInputs = [ emacsWithPackages pkgs.git ]; +}