meedstrom / org-node

GNU General Public License v3.0
74 stars 2 forks source link

+begin_src elisp

(use-package org-node :straight (org-node :type git :host github :repo "meedstrom/org-node" :branch "0.1pre") :hook (org-mode . org-node-cache-mode))

+end_src

New features

Removals

Renames

Changes

** What's all this

I found org-roam too slow, so I made [[https://github.com/meedstrom/quickroam][quickroam]]. And that idea spun off into this package, a standalone thing. I hope it's also easier to learn.

** What's a "node"?

My life can be divided into two periods "before org-roam" and "after org-roam". I crossed a kind of gap once I got a good way to link between my notes. It's odd to remember when I just relied on browsing hierarchies of subtrees -- what an odd way to work!

I used to lose track of things I had written, under some forgotten heading in a forgotten file in a forgotten directory. The org-roam method let me find and build on my own work, instead of having repetitive [[https://en.wikipedia.org/wiki/Cryptomnesia][cryptomnesia]] and staying on square one forever.

At the core, all the "notetaking packages" ([[https://github.com/rtrppl/orgrr][orgrr]]/[[https://github.com/localauthor/zk][zk]]/[[https://github.com/EFLS/zetteldeft][zetteldeft]]/[[https://github.com/org-roam/org-roam][org-roam]]/[[https://github.com/protesilaos/denote][denote]]/[[https://github.com/kaorahi/howm][howm]]/[[https://github.com/kisaragi-hiu/minaduki][minaduki]]/...) try to help you with this: make it easy to link between notes and explore them.

Right off the bat, that imposes two requirements: a method to search for notes, since you can't link to something you can't search for, and a design-philosophy about exactly what kinds of things should turn up as search hits. What's a "note"?

Just searching for Org files is too coarse, and just searching for any subtree anywhere brings in too much clutter.

Here's what org-roam invented. It turns out that if you limit the search-hits to just those files and subtrees you've deigned to assign an org-id -- which roughly maps to /everything you've ever thought it was worth linking to/ -- it filters out the noise excellently.

Once a subtree has an ID you can link to, it's a "node" because it has joined the wider graph, the network of linked nodes. I wish the English language had more distinct sounds for the words "node" and "note", but to clarify, I'll say "ID-node" when the distinction matters.

** Features

A comparison of three similar systems, all permitting org-id as first-class citizen, and not locking you into an "one-note-per-file" concept.

| Feature | org-roam | org-node | [[https://github.com/toshism/org-super-links][org-super-links]] | |-----------------------------------+----------+--------------------+----------------------| | Backlinks | yes | yes | yes | | Node search and insert | yes | yes | -- (suggests [[https://github.com/alphapapa/org-ql][org-ql]]) | | Node aliases | yes | yes | -- | | Rich backlinks buffer | yes | yes (org-roam's) | -- | | Reflinks | yes | yes (as backlinks) | -- | | Ref search | yes | yes (as aliases) | -- | | Can have rich search completion | yes | yes | not applicable | | Can work thru org-roam-capture | yes | yes | -- | | Can work thru org-capture | -- | yes | -- | | Can show backlinks in same window | -- | yes | yes | | Avoid double-counting =:BACKLINKS:= | -- | yes | not applicable | | Grep across all files | -- | yes | -- | | Update stale link descriptions | -- | yes | -- | | List dead links | -- | yes | | | Warn about duplicate IDs | -- | yes | | | Node exclusion | yes | limited | not applicable | | Support Org 9.5 citations | yes | -- (planned) | -- | | Support org-ref | yes | -- (WONTFIX) | -- | | Support =roam:= links | yes | -- (WONTFIX) | -- | | Can have separate note piles | yes | -- (WONTFIX) | not applicable | |-----------------------------------+----------+--------------------+----------------------| | Some query-able cache | EmacSQL | hash tables | -- | | Async caching | -- | yes | not applicable | | Time to re-cache my 3000 nodes | 2m 48s | 0m 02s | not applicable |

Add an init snippet like this (assuming [[https://github.com/radian-software/straight.el][straight.el]]):

+begin_src elisp

(use-package org-node :straight (org-node :type git :host github :repo "meedstrom/org-node") :hook (org-mode . org-node-cache-mode))

+end_src

** Quick start

If you're new to these concepts, fear not. The main things for day-to-day operation are two verbs: "find" and "insert-link".

Pick some good keys, close the browser, and try them out.

+begin_src elisp

(global-set-key (kbd " f") #'org-node-find) (global-set-key (kbd " i") #'org-node-insert-link)

+end_src

(If you don't like F2, maybe M-s?)

+begin_src elisp

(global-set-key (kbd "M-s f") #'org-node-find) (global-set-key (kbd "M-s i") #'org-node-insert-link)

+end_src

To browse config options, type =M-x customize-group RET org-node RET=.

Final tip for the newbie: there's no separate command for creating a new node! Reuse one of the commands above, and type the name of a node that doesn't exist. Try it and see what happens!

** Use Org-roam at the same time?

These settings help you feel at home using both packages side-by-side:

+begin_src elisp

(setq org-node-creation-fn #'org-node-new-by-roam-capture) (setq org-node-filename-fn #'org-node-slugify-like-roam)

+end_src

If you've been struggling with slow saving of big files in the past, consider these org-roam settings:

+begin_src elisp

(setq org-roam-db-update-on-save nil) ;; don't update DB on save, not needed (setq org-roam-link-auto-replace nil) ;; don't look for "roam:" links on save

+end_src

Finally, make sure org-id knows all the files org-roam knows about (you'd think it would, but that isn't a given!). Either run =M-x org-roam-update-org-id-locations=, or edit the following setting so it includes your =org-roam-directory=. If your =org-roam-directory= is "~/org/":

+begin_src elisp

(setq org-node-extra-id-dirs '("~/org/"))

+end_src

With that done, try out the commands we went over in [[https://github.com/meedstrom/org-node?tab=readme-ov-file#quick-start][Quick start]]. There's more under [[https://github.com/meedstrom/org-node?tab=readme-ov-file#toolbox][Toolbox]]. Enjoy!

If you want to keep using =M-x org-roam-buffer-toggle=, see the next section.

Backlink solution 1: borrow org-roam's backlink buffer ** Option 1A. Let org-roam manage its own DB

If you didn't have laggy saves, this is fine. In other words, keep =org-roam-db-update-on-save= at t.

*** Option 1B*. Tell org-node to write to the org-roam DB

Use this minor mode:

+begin_src elisp

(org-node-roam-db-shim-mode) (setq org-roam-db-update-on-save nil)

+end_src

To full-reset the DB, either of these should work

*** Option 1C*. Cut out the DB altogether

Yes it's possible, no SQLite needed!

Type =M-x org-node-roam-no-sql-mode=, then see what populates your Roam buffer henceforth. Hopefully you see the same links as before.

If you're happy with the result, and you don't need =roam:= links functionality, you can disable =org-roam-db-autosync-mode= in favour of the slimmer =M-x org-node-roam-redisplay-mode=. As an init snippet:

+begin_src elisp

(org-roam-db-autosync-mode 0) (org-node-roam-no-sql-mode) (org-node-roam-redisplay-mode)

+end_src

** Backlink solution 2: print inside the file I rarely have the screen space to display a backlink buffer. So this is better.

*** Option 2A*. Let org-node add a =:BACKLINKS:= property to all nodes

For a first-time run, type =M-x org-node-backlink-fix-all=. (Don't worry, if you change your mind, you can undo with =M-x org-node-backlink-regret=.)

Then start using the minor mode =org-node-backlink-mode=, which keeps these properties updated. Init snippet:

+begin_src elisp

(add-hook 'org-mode-hook #'org-node-backlink-mode)

+end_src

*** Option 2B.* Let [[https://github.com/toshism/org-super-links][org-super-links]] manage a =:BACKLINKS:...:END:= drawer in all nodes

I /think/ the following should work. Totally untested, let me know!

+begin_src elisp

(add-hook 'org-node-insert-link-hook #'org-node-convert-link-to-super)

+end_src

Alas, this is currently directed towards people who used org-super-links from the beginning, or people who are just now starting to assign IDs, as there is not yet a command to add new BACKLINKS drawers in bulk to preexisting nodes. ([[https://github.com/toshism/org-super-links/issues/93][org-super-links#93]])

** Misc *** Org-capture

You may have heard that org-roam has its own set of capture templates: the =org-roam-capture-templates=.

It can make sense for people who understand the magic of capture templates. I didn't, so I was not confident using a second-order abstraction over an already leaky abstraction.

Can we reproduce the functionality on top of vanilla org-capture? That'd be less scary. The answer is yes!

Example capture templates follow. The secret sauce is =(function org-node-capture-target)=.

+begin_src elisp

(setq org-capture-templates '(("n" "ID node") ("nc" "Capture into ID node (maybe creating it)" plain (function org-node-capture-target) nil :empty-lines-after 1)

    ("nv" "Visit ID node (maybe creating it)"
     plain (function org-node-capture-target) nil
     :jump-to-captured t
     :immediate-finish t)

    ;; Sometimes useful with `org-node-insert-link' to make a stub you'll
    ;; fill in later
    ("ni" "Instantly create stub ID node without visiting"
     plain (function org-node-capture-target) nil
     :immediate-finish t)))

+end_src

With that done, the commands =org-node-find= & =org-node-insert-link= can outsource to org-capture when creating new nodes:

+begin_src elisp

(setq org-node-creation-fn #'org-capture)

+end_src

*** Managing org-id-locations

I find unsatisfactory the config options in org-id (Why? See [[http://edstrom.dev/wjwrl/taking-ownership-of-org-id][Taking ownership of org-id]]), so org-node gives you a new way to feed data to org-id, making sure we won't run into "ID not found" situations.

Example setting:

+begin_src elisp

(setq org-node-extra-id-dirs '("/home/kept/notes/" "/home/kept/project1/" "/home/kept/project2/")

+end_src

*** Completion-at-point

+begin_src elisp

(org-node-complete-at-point-mode)

+end_src

*** Instruct org-open-at-point to visit ref-node if one exists

Say you there's a link to a web URL, and you've forgot you have a node listing that exact URL in its =ROAM_REFS= property.

Would it be nice if, clicking on it, you're automatically sent to that node instead of going on the web? Here you go:

+begin_src elisp

(add-hook 'org-open-at-point-functions #'org-node-try-visit-ref-node)

+end_src

*** Limitation: unique titles

If two ID-nodes exist with the same title, one of them disappears from minibuffer completions.

That's just the nature of completion. Other packages such as Org-roam have the same limitation. Much can be said for embracing the uniqueness constraint, and org-node will print messages telling you about title collisions.

Anyway, there's a workaround. Assuming you leave =org-node-affixation-fn= at its default setting, just set =org-node-alter-candidates= to t.

This merges the Org outline path with the matchable part of each candidate (so you do not match only on title), so it resolves most conflicts. [[https://fosstodon.org/@nickanderson/112249581810196258][Some people]] depend on that workflow.

NB: this workaround won't help the in-buffer completions provided by =org-node-complete-at-point-mode=, but hopefully you won't need it often.

Maybe I'll add distinguishers like "1" "2" "3" to each naming conflict?

** Limitation: excluding notes The option =org-node-filter-fn= works well for excluding TODO items that happen to have an ID, and excluding org-drill items and that sort of thing, but beyond that, it has limited utility because unlike org-roam, child ID nodes of an excluded node are not excluded!*

So let's say you have a big archive file, fulla IDs, and you want to exclude all of them from appearing as search hits. Putting a =:ROAM_EXCLUDE: t= at the top won't do it. As it stands, what I'd suggest is unfortunately, look at the file name.

While the point of IDs is to avoid depending on exact filenames, it's often pragmatic to let up on purism just a bit :-) It works well for me to filter out any file or directory that happens to contain "archive" in the name, via the last line here:

+begin_src elisp

(setq org-node-filter-fn (lambda (node) (not (or (org-node-get-todo node) ;; Ignore headings with todo state (member "drill" (org-node-get-tags node)) ;; Ignore :drill: (assoc "ROAM_EXCLUDE" (org-node-get-properties node)) (string-search "archive" (org-node-get-file-path node))))))

+end_src

*** Limitation: TRAMP Working over TRAMP is untested, but I suspect it won't work. Org-node tries to be very fast, often nulling =file-name-handler-alist=, which TRAMP needs.

If you need TRAMP, use org-roam, which is made to /not/ re-access files or directories so often anyway, in favor of trusting its own DB.

(Actually, that design caused me no end of problems with out-of-sync DB. So I'm happy org-node checks the disk files more often.)

*** Toolbox

Basic commands:

Rarer commands:

API cheatsheet between org-roam and org-node.

| Action | org-roam | org-node | |-----------------------------------------+------------------------------------+-----------------------------------------------------------------------| | Get ID near point | =(org-roam-id-at-point)= | =(org-entry-get nil "ID" t)= | | Get node at point | =(org-roam-node-at-point)= | =(org-node-at-point)= | | Get list of files | =(org-roam-list-files)= | =(org-node-files)= | | Prompt user to pick a node | =(org-roam-node-read)= | =(org-node-read)= | | Get backlink objects | =(org-roam-backlinks-get NODE)= | =(org-node-get-backlinks NODE)= | | Get reflink objects | =(org-roam-reflinks-get NODE)= | =(org-node-get-reflinks NODE)= | | Get title | =(org-roam-node-title NODE)= | =(org-node-get-title NODE)= | | Get title of file where NODE is | =(org-roam-node-file-title NODE)= | =(org-node-get-file-title NODE)= | | Get title /or/ name of file where NODE is | | =(org-node-get-file-title-or-basename NODE)= | | Get ID | =(org-roam-node-id NODE)= | =(org-node-get-id NODE)= | | Get filename | =(org-roam-node-file NODE)= | =(org-node-get-file-path NODE)= | | Get tags | =(org-roam-node-tags NODE)= | =(org-node-get-tags NODE)=, no inheritance | | Get outline level | =(org-roam-node-level NODE)= | =(org-node-get-level NODE)= | | Get whether this is a subtree | =(zerop (org-roam-node-level NODE))= | =(org-node-get-is-subtree NODE)= | | Get char position | =(org-roam-node-point NODE)= | =(org-node-get-pos NODE)= | | Get properties | =(org-roam-node-properties NODE)= | =(org-node-get-properties NODE)=, no inheritance | | Get subtree TODO state | =(org-roam-node-todo NODE)= | =(org-node-get-todo NODE)= | | Get subtree SCHEDULED | =(org-roam-node-scheduled NODE)= | =(org-node-get-scheduled NODE)= | | Get subtree DEADLINE | =(org-roam-node-deadline NODE)= | =(org-node-get-deadline NODE)= | | Get subtree priority | =(org-roam-node-priority NODE)= | =(org-node-get-priority NODE)= | | Get outline-path | =(org-roam-node-olp NODE)= | =(org-node-get-olp NODE)= | | Get =ROAM_REFS= | =(org-roam-node-refs NODE)= | =(org-node-get-refs NODE)= | | Get =ROAM_ALIASES= | =(org-roam-node-aliases NODE)= | =(org-node-get-aliases NODE)= | | Get =ROAM_EXCLUDE= | | =(assoc "ROAM_EXCLUDE" (org-node-get-properties NODE))=, no inheritance | | Ensure fresh data | =(org-roam-db-sync)= | =(org-node-cache--scan-new-or-modified)= |