initial commit
This commit is contained in:
24
Makefile
Normal file
24
Makefile
Normal 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
218
README.org
Normal 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
496
flake.lock
generated
Normal 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
53
flake.nix
Normal 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
15
opencode.json
Normal 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
426
org-roam-project-test.el
Normal 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
354
org-roam-project.el
Normal 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
|
||||||
Reference in New Issue
Block a user