(feature): use sqlite as backing database (#200)

All org-roam related information will now be stored in the database. Henceforth, the cache needs to be built synchronously once (via `M-x org-roam-build-cache`), which is then incrementally updated.
This commit is contained in:
Jethro Kuan
2020-02-29 15:56:08 +08:00
committed by GitHub
parent d086d1675d
commit 0c2aaad3df
25 changed files with 738 additions and 839 deletions

View File

@ -2,6 +2,12 @@
## 0.1.3 (TBD)
The biggest change, by far, the shift of database storage into SQLite.
This means that the org-roam cache needs to be built manually at least
once via `M-x org-roam-build-cache`.
### Breaking Changes
* [#200][gh-200] Move Org-roam cache into a SQLite database.
### New Features
* [#182][gh-182] Support file name aliases via `#+ROAM_ALIAS`.
* [#188][gh-188] Add `org-roam-protocol`, shifting `roam://` link handling into Emacs-lisp.
@ -99,6 +105,7 @@ Mostly a documentation/cleanup release.
[gh-165]: https://github.com/jethrokuan/org-roam/pull/165
[gh-182]: https://github.com/jethrokuan/org-roam/pull/182
[gh-188]: https://github.com/jethrokuan/org-roam/pull/188
[gh-200]: https://github.com/jethrokuan/org-roam/pull/200
# Local Variables:
# eval: (auto-fill-mode -1)

View File

@ -28,10 +28,9 @@ As of February 2020, it is in a very early stage of development.
Here's a screenshot of `org-roam`. The `org-roam` buffer shows
backlinks for the active org buffer in the left window, as well as the
surrounding content in the backlink file. The backlink database is
built asynchronously in the background, and is not noticeable to the
end user. The graph is generated from the link structure, and can be
used to navigate to the respective files.
surrounding content in the backlink file. The database is built once,
and updated incrementally. The graph is generated from the link
structure, and can be used to navigate to the respective files.
![img](doc/images/org-roam-graph.gif)

View File

@ -35,9 +35,12 @@ Here is an example `.dir-locals.el` file that would be placed in a
second Org-roam directory.
```emacs-lisp
((nil . ((eval . (setq-local org-roam-directory (locate-dominating-file default-directory ".dir-locals.el"))))))
((nil . ((org-roam-directory . "/path/to/here/"))))
```
Remember to run `org-roam-build-cache` from a file within that
directory, at least once.
## Org-roam Buffer
The Org-roam buffer defaults to popping up from the right. You may

View File

@ -13,13 +13,11 @@ Without further ado, let's begin!
## Building the Cache
Assuming you've set `org-roam-directory` appropriately, running `M-x
org-roam--build-cache-async` should build up the caches that will
allow you to begin using Org-roam. I do this on startup:
```emacs-lisp
(add-hook 'after-init-hook 'org-roam--build-cache-async)
```
The cache is a sqlite database named `org-roam.db`, which resides at
the root of your `org-roam-directory`. To begin, we need to do a first
build of this cache. To do so, run `M-x org-roam-build-cache`. This
may take a while the first time, but is generally instantaneous in
subsequent runs.
## Finding a Note

146
org-roam-db.el Normal file
View File

@ -0,0 +1,146 @@
;;; org-roam-db.el --- Roam Research replica with Org-mode -*- coding: utf-8; lexical-binding: t -*-
;; Copyright © 2020 Jethro Kuan <jethrokuan95@gmail.com>
;; Author: Jethro Kuan <jethrokuan95@gmail.com>
;; 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, 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 GNU Emacs; see the file COPYING. If not, write to the
;; Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
;; Boston, MA 02110-1301, USA.
;;; Commentary:
;;
;; This code is heavily referenced from https://github.com/magit/forge.
;;
;;; Code:
(require 'emacsql)
(require 'emacsql-sqlite)
;;; Options
(defcustom org-roam-directory (expand-file-name "~/org-roam/")
"Default path to Org-roam files.
All Org files, at any level of nesting, is considered part of the Org-roam."
:type 'directory
:group 'org-roam)
(defconst org-roam-db-filename "org-roam.db"
"Name of the Org-roam database file.")
(defconst org-roam--db-version 1)
(defconst org-roam--sqlite-available-p
(with-demoted-errors "Org-roam initialization: %S"
(emacsql-sqlite-ensure-binary)
t))
(defvar org-roam--db-connection (make-hash-table :test #'equal)
"Database connection to Org-roam database.")
;;; Core
(defun org-roam--get-db ()
"Return the sqlite db file."
(interactive "P")
(expand-file-name org-roam-db-filename org-roam-directory))
(defun org-roam--get-db-connection ()
"Return the database connection, if any."
(gethash (file-truename org-roam-directory)
org-roam--db-connection))
(defun org-roam-db ()
(unless (and (org-roam--get-db-connection)
(emacsql-live-p (org-roam--get-db-connection)))
(let* ((db-file (org-roam--get-db))
(init-db (not (file-exists-p db-file))))
(make-directory (file-name-directory db-file) t)
(let ((conn (emacsql-sqlite db-file)))
(puthash (file-truename org-roam-directory)
conn
org-roam--db-connection)
(when init-db
(org-roam--db-init conn))
(let* ((version (caar (emacsql conn "PRAGMA user_version")))
(version (org-roam--db-maybe-update conn version)))
(cond
((> version org-roam--db-version)
(emacsql-close conn)
(user-error
"The Org-roam database was created with a newer Org-roam version. %s"
"You need to update the Org-roam package.")
((< version org-roam--db-version)
(emacsql-close conn)
(error "BUG: The Org-roam database scheme changed %s"
"and there is no upgrade path"))))))))
(org-roam--get-db-connection))
;;; Api
(defun org-roam-sql (sql &rest args)
(if (stringp sql)
(emacsql (org-roam-db) (apply #'format sql args))
(apply #'emacsql (org-roam-db) sql args)))
;;; Schemata
(defconst org-roam--db-table-schemata
'((files
[(file :unique :primary-key)
(hash :not-null)
(last-modified :not-null)
])
(file-links
[(file-from :not-null)
(file-to :not-null)
(properties :not-null)])
(titles
[
(file :not-null)
titles])
(refs
[(ref :unique :not-null)
(file :not-null)])))
(defun org-roam--db-init (db)
(emacsql-with-transaction db
(pcase-dolist (`(,table . ,schema) org-roam--db-table-schemata)
(emacsql db [:create-table $i1 $S2] table schema))
(emacsql db (format "PRAGMA user_version = %s" org-roam--db-version))))
(defun org-roam--db-maybe-update (db version)
(emacsql-with-transaction db
'ignore
;; Do nothing now
version))
(defun org-roam--db-close (&optional db)
(unless db
(setq db (org-roam--get-db-connection)))
(when (and db (emacsql-live-p db))
(emacsql-close db)))
(defun org-roam--db-close-all ()
(dolist (conn (hash-table-values org-roam--db-connection))
(org-roam--db-close conn)))
(provide 'org-roam-db)
;;; org-roam-db.el ends here

View File

@ -2,10 +2,6 @@
;; Copyright © 2020 Jethro Kuan <jethrokuan95@gmail.com>
;; Author: Jethro Kuan <jethrokuan95@gmail.com>
;; URL: https://github.com/jethrokuan/org-roam
;; Keywords: org-mode, roam, convenience
;; Version: 0.1.2
;; Package-Requires: ((emacs "26.1") (org "9.0"))
;; This file is NOT part of GNU Emacs.
@ -71,8 +67,7 @@ If the function returns nil, the filename is removed from the
list of filenames passed from emacsclient to the server. If the
function returns a non-nil value, that value is passed to the
server as filename."
(let ((the-protocol (concat (regexp-quote org-roam-protocol-the-protocol)
":")))
(let ((the-protocol (concat (regexp-quote org-roam-protocol-the-protocol) ":")))
(when (string-match the-protocol fname)
(cadr (split-string fname the-protocol)))))

View File

@ -1,222 +0,0 @@
;;; org-roam-utils.el --- Roam Research replica with Org-mode -*- coding: utf-8; lexical-binding: t -*-
;; Copyright © 2020 Jethro Kuan <jethrokuan95@gmail.com>
;; Author: Jethro Kuan <jethrokuan95@gmail.com>
;; URL: https://github.com/jethrokuan/org-roam
;; Keywords: org-mode, roam, convenience
;; Version: 0.1.2
;; Package-Requires: ((emacs "26.1") (org "9.0"))
;; 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, 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 GNU Emacs; see the file COPYING. If not, write to the
;; Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
;; Boston, MA 02110-1301, USA.
;;; Commentary:
;;
;; This library is an attempt at injecting Roam functionality into Org-mode.
;; This is achieved primarily through building caches for forward links,
;; backward links, and file titles.
;;
;;; Code:
(require 'org)
(require 'org-element)
(require 'ob-core) ;for org-babel-parse-header-arguments
(require 'subr-x)
(require 'cl-lib)
(defun org-roam--file-name-extension (filename)
"Return file name extension for FILENAME.
Like file-name-extension, but does not strip version number."
(save-match-data
(let ((file (file-name-nondirectory filename)))
(if (and (string-match "\\.[^.]*\\'" file)
(not (eq 0 (match-beginning 0))))
(substring file (+ (match-beginning 0) 1))))))
(defun org-roam--org-file-p (path)
"Check if PATH is pointing to an org file."
(let ((ext (org-roam--file-name-extension path)))
(or (string= ext "org")
(and
(string= ext "gpg")
(string= (org-roam--file-name-extension (file-name-sans-extension path)) "org")))))
(defun org-roam--find-files (dir)
"Return all `org-roam' files in `DIR'."
(if (file-exists-p dir)
(let ((files (directory-files dir t "." t))
(dir-ignore-regexp (concat "\\(?:"
"\\."
"\\|\\.\\."
"\\)$"))
result)
(dolist (file files)
(cond
((file-directory-p file)
(when (not (string-match dir-ignore-regexp file))
(setq result (append (org-roam--find-files file) result))))
((and (file-readable-p file)
(org-roam--org-file-p file))
(setq result (cons (file-truename file) result)))))
result)))
(defun org-roam--parse-content (&optional file-path)
"Parse the current buffer, and return a list of items for processing."
(org-element-map (org-element-parse-buffer) 'link
(lambda (link)
(let ((type (org-element-property :type link))
(path (org-element-property :path link))
(start (org-element-property :begin link)))
(when (and (string= type "file")
(org-roam--org-file-p path))
(goto-char start)
(let* ((element (org-element-at-point))
(begin (or (org-element-property :content-begin element)
(org-element-property :begin element)))
(content (or (org-element-property :raw-value element)
(buffer-substring
begin
(or (org-element-property :content-end element)
(org-element-property :end element)))))
(content (string-trim content))
(file-path (or file-path
(file-truename (buffer-file-name (current-buffer))))))
(list :from file-path
:to (file-truename (expand-file-name path (file-name-directory file-path)))
:properties (list :content content :point begin))))))))
(cl-defun org-roam--insert-item (item &key forward backward)
"Insert ITEM into FORWARD and BACKWARD cache.
ITEM is of the form: (:from from-path :to to-path :properties (:content preview-content :point point))."
(pcase-let ((`(:from ,p-from :to ,p-to :properties ,props) item))
;; Build forward-links
(let ((links (gethash p-from forward)))
(if links
(puthash p-from
(if (member p-to links)
links
(cons p-to links)) forward)
(puthash p-from (list p-to) forward)))
;; Build backward-links
(let ((contents-hash (gethash p-to backward)))
(if contents-hash
(if-let ((contents-list (gethash p-from contents-hash)))
(let ((updated (cons props contents-list)))
(puthash p-from updated contents-hash)
(puthash p-to contents-hash backward))
(progn
(puthash p-from (list props) contents-hash)
(puthash p-to contents-hash backward)))
(let ((contents-hash (make-hash-table :test #'equal)))
(puthash p-from (list props) contents-hash)
(puthash p-to contents-hash backward))))))
(defun org-roam--extract-global-props (props)
"Extract PROPS from the current buffer."
(let ((buf (org-element-parse-buffer))
(res '()))
(dolist (prop props)
(let ((p (org-element-map
buf
'keyword
(lambda (kw)
(when (string= (org-element-property :key kw) prop)
(org-element-property :value kw)))
:first-match t)))
(setq res (cons (cons prop p) res))))
res))
(defun org-roam--aliases-str-to-list (str)
"Function to transform string STR into list of alias titles.
This snippet is obtained from ox-hugo:
https://github.com/kaushalmodi/ox-hugo/blob/a80b250987bc770600c424a10b3bca6ff7282e3c/ox-hugo.el#L3131"
(when (stringp str)
(let* ((str (org-trim str))
(str-list (split-string str "\n"))
ret)
(dolist (str-elem str-list)
(let* ((format-str ":dummy '(%s)") ;The :dummy key is discarded in the `lst' var below.
(alist (org-babel-parse-header-arguments (format format-str str-elem)))
(lst (cdr (car alist)))
(str-list2 (mapcar (lambda (elem)
(cond
((symbolp elem)
(symbol-name elem))
(t
elem)))
lst)))
(setq ret (append ret str-list2))))
ret)))
(defun org-roam--extract-titles ()
"Extract the titles from current buffer.
Titles are obtained via the #+TITLE property, or aliases
specified via the #+ROAM_ALIAS property."
(let* ((props (org-roam--extract-global-props '("TITLE" "ROAM_ALIAS")))
(aliases (cdr (assoc "ROAM_ALIAS" props)))
(title (cdr (assoc "TITLE" props)))
(alias-list (org-roam--aliases-str-to-list aliases)))
(if title
(cons title alias-list)
alias-list)))
(defun org-roam--extract-ref ()
"Extract the ref from current buffer."
(cdr (assoc "ROAM_KEY" (org-roam--extract-global-props '("ROAM_KEY")))))
(defun org-roam--build-cache (dir)
"Build the org-roam caches in DIR."
(let ((backward-links (make-hash-table :test #'equal))
(forward-links (make-hash-table :test #'equal))
(file-titles (make-hash-table :test #'equal))
(refs (make-hash-table :test #'equal)))
(let* ((org-roam-files (org-roam--find-files dir))
(file-items (mapcar (lambda (file)
(with-temp-buffer
(insert-file-contents file)
(org-roam--parse-content file))) org-roam-files)))
(dolist (items file-items)
(dolist (item items)
(org-roam--insert-item
item
:forward forward-links
:backward backward-links)))
(dolist (file org-roam-files)
(with-temp-buffer
(insert-file-contents file)
(when-let ((titles (org-roam--extract-titles)))
(puthash file titles file-titles))
(when-let ((ref (org-roam--extract-ref)))
;; FIXME: this overrides previous refs, should probably have a
;; warning when ref is not unique
(puthash ref file refs)))
org-roam-files))
(list
:directory dir
:forward forward-links
:backward backward-links
:titles file-titles
:refs refs)))
(provide 'org-roam-utils)
;;; org-roam-utils.el ends here

View File

@ -6,7 +6,7 @@
;; URL: https://github.com/jethrokuan/org-roam
;; Keywords: org-mode, roam, convenience
;; Version: 0.1.2
;; Package-Requires: ((emacs "26.1") (dash "2.13") (f "0.17.2") (s "1.12.0") (async "1.9.4") (org "9.0"))
;; Package-Requires: ((emacs "26.1") (dash "2.13") (f "0.17.2") (s "1.12.0") (org "9.0") (emacsql "3.0.0") (emacsql-sqlite "1.0.0"))
;; This file is NOT part of GNU Emacs.
@ -35,14 +35,15 @@
;;; Code:
(eval-when-compile (require 'cl-lib))
(require 'dash)
(require 'org)
(require 'org-element)
(require 'async)
(require 'ob-core) ;for org-babel-parse-header-arguments
(require 'subr-x)
(require 'dash)
(require 's)
(require 'f)
(require 'org-roam-utils)
(require 'eieio)
(require 'cl-lib)
(require 'org-roam-db)
;;; Customizations
(defgroup org-roam nil
@ -52,12 +53,10 @@
:link '(url-link :tag "Github" "https://github.com/jethrokuan/org-roam")
:link '(url-link :tag "Online Manual" "https://org-roam.readthedocs.io/"))
(defcustom org-roam-directory (expand-file-name "~/org-roam/")
"Path to Org-roam files.
All Org files, at any level of nesting, is considered part of the Org-roam."
:type 'directory
:group 'org-roam)
(defgroup org-roam-faces nil
"Faces used by Org-Roam."
:group 'org-roam
:group 'faces)
(defcustom org-roam-new-file-directory nil
"Path to where new Org-roam files are created.
@ -66,11 +65,6 @@ If nil, default to the org-roam-directory (preferred)."
:type 'directory
:group 'org-roam)
(defcustom org-roam-mute-cache-build nil
"Whether to mute the cache build message."
:type 'boolean
:group 'org-roam)
(defcustom org-roam-buffer-position 'right
"Position of `org-roam' buffer.
@ -81,14 +75,6 @@ Valid values are
(const right))
:group 'org-roam)
(defcustom org-roam-file-name-function #'org-roam--file-name-timestamp-title
"The function used to generate filenames.
The function takes as parameter `TITLE', a string the user inputs."
:group 'org-roam
:type '(choice (const :tag "Default" org-roam--file-name-timestamp-title)
(function :tag "Personalized function")))
(defcustom org-roam-link-title-format "%s"
"The format string used when inserting org-roam links that use their title."
:type 'string
@ -126,55 +112,15 @@ If nil, always ask for filename."
:group 'org-roam)
(defcustom org-roam-graph-max-title-length 100
"Maximum length of titles in graphviz graph nodes"
"Maximum length of titles in Graphviz graph nodes."
:type 'number
:group 'org-roam)
(defcustom org-roam-graph-node-shape "ellipse"
"Maximum length of titles in graphviz graph nodes"
"Shape of Graphviz nodes."
:type 'string
:group 'org-roam)
(defgroup org-roam-faces nil
"Faces used by Org-Roam."
:group 'org-roam
:group 'faces)
;;; Polyfills
;; These are for functions I use that are only available in newer Emacs
;; Introduced in Emacs 27.1
(unless (fboundp 'make-empty-file)
(defun make-empty-file (filename &optional parents)
"Create an empty file FILENAME.
Optional arg PARENTS, if non-nil then creates parent dirs as needed.
If called interactively, then PARENTS is non-nil."
(interactive
(let ((filename (read-file-name "Create empty file: ")))
(list filename t)))
(when (and (file-exists-p filename) (null parents))
(signal 'file-already-exists `("File exists" ,filename)))
(let ((paren-dir (file-name-directory filename)))
(when (and paren-dir (not (file-exists-p paren-dir)))
(make-directory paren-dir parents)))
(write-region "" nil filename nil 0)))
;;; Classes
(defclass org-roam-cache ()
((initialized :initarg :initialized
:documentation "Is cache valid?")
(forward-links :initarg :forward-links
:documentation "Cache containing forward links.")
(backward-links :initarg :backward-links
:documentation "Cache containing backward links.")
(titles :initarg :titles
:documentation "Cache containing titles for org-roam files.")
(refs :initarg :refs
:documentation "Cache with ref as key, and file path as value."))
"All cache for an org-roam directory.")
;;; Dynamic variables
(defvar org-roam--current-buffer nil
"Currently displayed file in `org-roam' buffer.")
@ -182,70 +128,196 @@ If called interactively, then PARENTS is non-nil."
(defvar org-roam-last-window nil
"Last window `org-roam' was called from.")
(defvar org-roam--cache (make-hash-table :test 'equal)
"The list of cache separated by directory.")
(defvar-local org-roam--local-cache-ref nil
"Local reference of the buffer's cache object.")
(defvar-local org-roam--local-cache-id nil
"Local reference of the buffer's cache object id, which is
comparable by \"eq\".")
;;; Utilities
(defun org-roam-directory-normalized ()
"Get the org-roam-directory normalized so that it can be used
as a unique key."
(directory-file-name (file-truename org-roam-directory)))
(defun org-roam--touch-file (path)
"Touches an empty file at PATH."
(make-directory (file-name-directory path) t)
(f-touch path))
(defmacro org-roam--get-local (name)
"Get a variable that is local to the current org-roam-directory."
`(gethash (org-roam-directory-normalized) ,name nil))
(defun org-roam--file-name-extension (filename)
"Return file name extension for FILENAME.
Like file-name-extension, but does not strip version number."
(save-match-data
(let ((file (file-name-nondirectory filename)))
(if (and (string-match "\\.[^.]*\\'" file)
(not (eq 0 (match-beginning 0))))
(substring file (+ (match-beginning 0) 1))))))
(defmacro org-roam--set-local (name value)
"Set a variable that is local to the current org-roam-directory."
`(puthash (org-roam-directory-normalized) ,value ,name))
(defun org-roam--org-file-p (path)
"Check if PATH is pointing to an org file."
(let ((ext (org-roam--file-name-extension path)))
(or (string= ext "org")
(and
(string= ext "gpg")
(string= (org-roam--file-name-extension (file-name-sans-extension path)) "org")))))
(defun org-roam--get-directory-cache ()
"Get the cache object for the current org-roam-directory."
(unless (eq org-roam--local-cache-id org-roam-directory)
;; Prevent needless repeated calls to org-roam-directory-normalized by
;; having a reference to the cache object that matches the local buffer.
(setq org-roam--local-cache-ref
(let* ((cache (org-roam--get-local org-roam--cache)))
(if cache
cache
(let ((new-cache (org-roam--default-cache)))
(org-roam--set-local org-roam--cache new-cache)
new-cache))))
(setq org-roam--local-cache-id org-roam-directory))
org-roam--local-cache-ref)
(defun org-roam--find-files (dir)
"Return all `org-roam' files in `DIR'."
(if (file-exists-p dir)
(let ((files (directory-files dir t "." t))
(dir-ignore-regexp (concat "\\(?:"
"\\."
"\\|\\.\\."
"\\)$"))
result)
(dolist (file files)
(cond
((file-directory-p file)
(when (not (string-match dir-ignore-regexp file))
(setq result (append (org-roam--find-files file) result))))
((and (file-readable-p file)
(org-roam--org-file-p file))
(setq result (cons (file-truename file) result)))))
result)))
(defun org-roam--get-links (&optional file-path)
"Get the links in the buffer.
If FILE-PATH is passed, use that as the source file."
(let ((file-path (or file-path
(file-truename (buffer-file-name (current-buffer))))))
(org-element-map (org-element-parse-buffer) 'link
(lambda (link)
(let ((type (org-element-property :type link))
(path (org-element-property :path link))
(start (org-element-property :begin link)))
(when (and (string= type "file")
(org-roam--org-file-p path))
(goto-char start)
(let* ((element (org-element-at-point))
(begin (or (org-element-property :content-begin element)
(org-element-property :begin element)))
(content (or (org-element-property :raw-value element)
(buffer-substring
begin
(or (org-element-property :content-end element)
(org-element-property :end element)))))
(content (string-trim content)))
(vector file-path
(file-truename (expand-file-name path (file-name-directory file-path)))
(list :content content :point begin)))))))))
(defun org-roam--extract-global-props (props)
"Extract PROPS from the current buffer."
(let ((buf (org-element-parse-buffer))
(res '()))
(dolist (prop props)
(let ((p (org-element-map
buf
'keyword
(lambda (kw)
(when (string= (org-element-property :key kw) prop)
(org-element-property :value kw)))
:first-match t)))
(setq res (cons (cons prop p) res))))
res))
(defun org-roam--aliases-str-to-list (str)
"Function to transform string STR into list of alias titles.
This snippet is obtained from ox-hugo:
https://github.com/kaushalmodi/ox-hugo/blob/a80b250987bc770600c424a10b3bca6ff7282e3c/ox-hugo.el#L3131"
(when (stringp str)
(let* ((str (org-trim str))
(str-list (split-string str "\n"))
ret)
(dolist (str-elem str-list)
(let* ((format-str ":dummy '(%s)") ;The :dummy key is discarded in the `lst' var below.
(alist (org-babel-parse-header-arguments (format format-str str-elem)))
(lst (cdr (car alist)))
(str-list2 (mapcar (lambda (elem)
(cond
((symbolp elem)
(symbol-name elem))
(t
elem)))
lst)))
(setq ret (append ret str-list2))))
ret)))
(defun org-roam--extract-titles ()
"Extract the titles from current buffer.
Titles are obtained via the #+TITLE property, or aliases
specified via the #+ROAM_ALIAS property."
(let* ((props (org-roam--extract-global-props '("TITLE" "ROAM_ALIAS")))
(aliases (cdr (assoc "ROAM_ALIAS" props)))
(title (cdr (assoc "TITLE" props)))
(alias-list (org-roam--aliases-str-to-list aliases)))
(if title
(cons title alias-list)
alias-list)))
(defun org-roam--extract-ref ()
"Extract the ref from current buffer."
(cdr (assoc "ROAM_KEY" (org-roam--extract-global-props '("ROAM_KEY")))))
(defun org-roam--insert-links (links)
"Insert LINK into the org-roam cache."
(org-roam-sql
[:insert :into file-links
:values $v1]
links))
(defun org-roam--insert-titles (file titles)
"Insert TITLES into the org-roam-cache."
(org-roam-sql
[:insert :into titles
:values $v1]
(list (vector file titles))))
(defun org-roam--insert-ref (file ref)
"Insert REF into the Org-roam cache."
(org-roam-sql
[:insert :into refs
:values $v1]
(list (vector ref file))))
(defun org-roam--clear-cache ()
"Clears all entries in the caches."
(interactive)
(when (file-exists-p (org-roam--get-db))
(org-roam-sql [:delete :from files])
(org-roam-sql [:delete :from titles])
(org-roam-sql [:delete :from file-links])
(org-roam-sql [:delete :from files])
(org-roam-sql [:delete :from refs])))
(defun org-roam--clear-file-from-cache (&optional filepath)
"Remove any related links to the file at FILEPATH.
This is equivalent to removing the node from the graph."
(let* ((path (or filepath
(buffer-file-name (current-buffer))))
(file (file-truename path)))
(org-roam-sql [:delete :from files
:where (= file $s1)]
file)
(org-roam-sql [:delete :from file-links
:where (= file-from $s1)]
file)
(org-roam-sql [:delete :from titles
:where (= file $s1)]
file)
(org-roam-sql [:delete :from refs
:where (= file $s1)]
file)))
(defun org-roam--get-current-files ()
"Return a hash of file to buffer string hash."
(let* ((current-files (org-roam-sql [:select * :from files]))
(ht (make-hash-table :test #'equal)))
(dolist (row current-files)
(puthash (car row) (cadr row) ht))
ht))
(defun org-roam--cache-initialized-p ()
"Is cache valid?"
(oref (org-roam--get-directory-cache) initialized))
(defun org-roam--forward-links-cache ()
"Cache containing forward links."
(oref (org-roam--get-directory-cache) forward-links))
(defun org-roam--backward-links-cache ()
"Cache containing backward links."
(oref (org-roam--get-directory-cache) backward-links))
(defun org-roam--titles-cache ()
"Cache containing titles for org-roam files."
(oref (org-roam--get-directory-cache) titles))
(defun org-roam--refs-cache ()
"Cache containing refs for org-roam files."
(oref (org-roam--get-directory-cache) refs))
"Whether the cache has been initialized."
(and (file-exists-p (org-roam--get-db))
(> (caar (org-roam-sql [:select (funcall count) :from titles]))
0)))
(defun org-roam--ensure-cache-built ()
"Ensures that org-roam cache is built."
(unless (org-roam--cache-initialized-p)
(org-roam--build-cache-async)
(user-error "Your Org-Roam cache isn't built yet! Please wait")))
(error "[Org-roam] your cache isn't built yet! Please wait.")))
(defun org-roam--org-roam-file-p (&optional file)
"Return t if FILE is part of org-roam system, defaulting to the name of the current buffer. Else, return nil."
@ -258,11 +330,10 @@ as a unique key."
(defun org-roam--get-titles-from-cache (file)
"Return titles and aliases of `FILE' from the cache."
(or (gethash file (org-roam--titles-cache))
(progn
(unless (org-roam--cache-initialized-p)
(user-error "The Org-Roam caches aren't built! Please run org-roam--build-cache-async"))
nil)))
(caar (org-roam-sql [:select [titles] :from titles
:where (= file $s1)]
file
:limit 1)))
(defun org-roam--get-title-from-cache (file)
"Return the title of `FILE' from the cache."
@ -341,7 +412,7 @@ It uses TITLE and the current timestamp to form a unique title."
(setq file-path (org-roam--new-file-path (file-name-fn title) t))
(if (file-exists-p file-path)
file-path
(make-empty-file file-path t)
(org-roam--touch-file file-path)
(write-region
(s-format (plist-get template :content)
'aget
@ -350,18 +421,6 @@ It uses TITLE and the current timestamp to form a unique title."
nil file-path nil)
file-path))))
(defun org-roam--get-new-id (title)
"Return a new ID, given the note TITLE."
(let* ((proposed-slug (funcall org-roam-file-name-function title))
(new-slug (if org-roam-filename-noconfirm
proposed-slug
(read-string "Enter ID (without extension): "
proposed-slug)))
(file-path (org-roam--new-file-path new-slug t)))
(if (file-exists-p file-path)
(user-error "There's already a file at %s")
new-slug)))
;;; Inserting org-roam links
(defun org-roam-insert (prefix)
"Find an org-roam file, and insert a relative org link to it at point.
@ -397,13 +456,16 @@ If PREFIX, downcase the title before insertion."
;;; Finding org-roam files
(defun org-roam--get-title-path-completions ()
"Return a list of cons pairs for titles to absolute path of Org-roam files."
(let ((files (org-roam--find-all-files))
(res '()))
(dolist (file files)
(if-let (titles (org-roam--get-titles-from-cache file))
(dolist (title titles)
(setq res (cons (cons title file) res)))
(setq res (cons (cons (org-roam--path-to-slug file) file) res))))
(let* ((rows (org-roam-sql [:select [file titles] :from titles]))
res)
(dolist (row rows)
(let ((file-path (car row))
(titles (cadr row)))
(if titles
(dolist (title titles)
(setq res (cons (cons title file-path) res)))
(setq res (cons (cons (org-roam--path-to-slug file-path)
file-path) res)))))
res))
(defun org-roam-find-file ()
@ -437,115 +499,102 @@ If PREFIX, downcase the title before insertion."
(when-let ((name (completing-read "Choose a buffer: " names-and-buffers)))
(switch-to-buffer (cdr (assoc name names-and-buffers))))))
(defvar org-roam--ongoing-async-build (make-hash-table :test 'equal)
"Prevent multiple async cache builds. This can happen when
restoring a session or loading multiple org-roam files before a
build has completed.")
;;; Building the org-roam cache
(defun org-roam--build-cache-async (&optional on-success)
"Builds the caches asychronously."
(defun org-roam-build-cache ()
"Build the cache for `org-roam-directory'."
(interactive)
(let ((existing (org-roam--get-local org-roam--ongoing-async-build)))
(unless (and (processp existing)
(not (async-ready existing)))
(org-roam--set-local
org-roam--ongoing-async-build
(async-start
`(lambda ()
(setq load-path ',load-path)
(package-initialize)
(require 'org-roam-utils)
,(async-inject-variables "org-roam-directory")
(org-roam--build-cache org-roam-directory))
(lambda (cache)
(let ((org-roam-directory (plist-get cache :directory)))
(let ((obj (org-roam--get-directory-cache)))
(oset obj initialized t)
(oset obj forward-links (plist-get cache :forward))
(oset obj backward-links (plist-get cache :backward))
(oset obj titles (plist-get cache :titles))
(oset obj refs (plist-get cache :refs)))
(unless org-roam-mute-cache-build
(message "Org-roam cache built!"))
(when on-success
(funcall on-success)))))))))
(defun org-roam--clear-cache ()
"Clears all entries in the caches."
(interactive)
(let ((cache (org-roam--get-directory-cache)))
(oset cache initialized nil)
(oset cache forward-links (make-hash-table :test #'equal))
(oset cache backward-links (make-hash-table :test #'equal))
(oset cache titles (make-hash-table :test #'equal))
(oset cache refs (make-hash-table :test #'equal))))
(defun org-roam--default-cache ()
"A default, uninitialized cache object."
(org-roam-cache :initialized nil
:forward-links (make-hash-table :test #'equal)
:backward-links (make-hash-table :test #'equal)
:titles (make-hash-table :test #'equal)
:refs (make-hash-table :test #'equal)))
(defun org-roam--clear-file-from-cache (&optional filepath)
"Remove any related links to the file at FILEPATH.
This is equivalent to removing the node from the graph."
(let* ((path (or filepath
(buffer-file-name (current-buffer))))
(file (file-truename path)))
;; Step 1: Remove all existing links for file
(when-let ((forward-links (gethash file (org-roam--forward-links-cache))))
;; Delete backlinks to file
(dolist (link forward-links)
(when-let ((backward-links (gethash link (org-roam--backward-links-cache))))
(remhash file backward-links)
(puthash link backward-links (org-roam--backward-links-cache))))
;; Clean out forward links
(remhash file (org-roam--forward-links-cache)))
;; Step 2: Remove from the title cache
(remhash file (org-roam--titles-cache))
;; Step 3: Remove from the refs cache
(maphash (lambda (k v)
(when (string= v file)
(remhash k (org-roam--refs-cache))))
(org-roam--refs-cache))))
(org-roam-db) ;; To initialize the database, no-op if already initialized
(let* ((org-roam-files (org-roam--find-files org-roam-directory))
(current-files (org-roam--get-current-files))
(time (current-time))
all-files all-links all-titles all-refs)
(dolist (file org-roam-files)
(with-temp-buffer
(insert-file-contents file)
(let ((contents-hash (secure-hash 'sha1 (current-buffer))))
(unless (string= (gethash file current-files)
contents-hash)
(org-roam--clear-file-from-cache file)
(setq all-files
(cons (vector file contents-hash time) all-files))
(when-let (links (org-roam--get-links file))
(setq all-links (append links all-links)))
(let ((titles (org-roam--extract-titles)))
(setq all-titles (cons (vector file titles) all-titles)))
(when-let ((ref (org-roam--extract-ref)))
(setq all-refs (cons (vector ref file) all-refs))))
(remhash file current-files))))
(dolist (file (hash-table-keys current-files))
;; These files are no longer around, remove from cache...
(org-roam--clear-file-from-cache file))
(when all-files
(org-roam-sql
[:insert :into files
:values $v1]
all-files))
(when all-links
(org-roam-sql
[:insert :into file-links
:values $v1]
all-links))
(when all-titles
(org-roam-sql
[:insert :into titles
:values $v1]
all-titles))
(when all-refs
(org-roam-sql
[:insert :into refs
:values $v1]
all-refs))
(let ((stats (list :files (length all-files)
:links (length all-links)
:titles (length all-titles)
:refs (length all-refs)
:deleted (length (hash-table-keys current-files)))))
(message (format "files: %s, links: %s, titles: %s, refs: %s, deleted: %s"
(plist-get stats :files)
(plist-get stats :links)
(plist-get stats :titles)
(plist-get stats :refs)
(plist-get stats :deleted)))
stats)))
(defun org-roam--update-cache-titles ()
"Insert the title of the current buffer into the cache."
(when-let ((titles (org-roam--extract-titles)))
(puthash (file-truename (buffer-file-name (current-buffer)))
titles
(org-roam--titles-cache))))
"Update the title of the current buffer into the cache."
(let ((file (file-truename (buffer-file-name (current-buffer)))))
(org-roam-sql [:delete :from titles
:where (= file $s1)]
file)
(org-roam--insert-titles file (org-roam--extract-titles))))
(defun org-roam--update-cache-refs ()
"Insert the ref of the current buffer into the cache."
(when-let ((ref (org-roam--extract-ref)))
(puthash ref
(file-truename (buffer-file-name (current-buffer)))
(org-roam--refs-cache))))
"Update the ref of the current buffer into the cache."
(let ((file (file-truename (buffer-file-name (current-buffer)))))
(org-roam-sql [:delete :from refs
:where (= file $s1)]
file)
(when-let ((ref (org-roam--extract-ref)))
(org-roam--insert-ref file ref))))
(defun org-roam--update-cache-links ()
"Update the file links of the current buffer in the cache."
(let ((file (file-truename (buffer-file-name (current-buffer)))))
(org-roam-sql [:delete :from file-links
:where (= file-from $s1)]
file)
(when-let ((links (org-roam--get-links)))
(org-roam--insert-links links))))
(defun org-roam--update-cache ()
"Update org-roam caches for the current buffer file."
(save-excursion
(org-roam--clear-file-from-cache)
;; Insert into title cache
(org-roam--update-cache-titles)
;; Insert into ref cache
(org-roam--update-cache-refs)
;; Insert new items
(let ((items (org-roam--parse-content)))
(dolist (item items)
(org-roam--insert-item
item
:forward (org-roam--forward-links-cache)
:backward (org-roam--backward-links-cache))))
;; Rerender buffer
(org-roam--update-cache-links)
(org-roam--maybe-update-buffer :redisplay t)))
;;; Org-roam daily notes
(defun org-roam--file-for-time (time)
"Create and find file for TIME."
(let* ((org-roam-templates (list (list "daily" (list :file (lambda (title) title)
@ -618,10 +667,15 @@ If item at point is not org-roam specific, default to Org behaviour."
(select-window org-roam-last-window))
(find-file file)))
(defun org-roam--get-backlinks (file)
(org-roam-sql [:select [file-from, file-to, properties] :from file-links
:where (= file-to $s1)]
file))
(defun org-roam-update (file-path)
"Show the backlinks for given org file for file at `FILE-PATH'."
(org-roam--ensure-cache-built)
(let* ((source-org-roam-directory org-roam-directory))
(org-roam--ensure-cache-built)
(let ((buffer-title (org-roam--get-title-or-slug file-path)))
(with-current-buffer org-roam-buffer
;; When dir-locals.el is used to override org-roam-directory,
@ -640,24 +694,27 @@ If item at point is not org-roam specific, default to Org behaviour."
(setq org-return-follows-link t)
(insert
(propertize buffer-title 'font-lock-face 'org-document-title))
(if-let ((backlinks (gethash file-path (org-roam--backward-links-cache))))
(if-let* ((backlinks (org-roam--get-backlinks file-path))
(grouped-backlinks (--group-by (nth 0 it) backlinks)))
(progn
(insert (format "\n\n* %d Backlinks\n"
(hash-table-count backlinks)))
(maphash (lambda (file-from contents)
(insert (format "** [[file:%s][%s]]\n"
file-from
(org-roam--get-title-or-slug file-from)))
(dolist (properties contents)
(let ((content (propertize
(s-trim (s-replace "\n" " "
(plist-get properties :content)))
'font-lock-face 'org-block
'help-echo "mouse-1: visit backlinked note"
'file-from file-from
'file-from-point (plist-get properties :point))))
(insert (format "%s \n\n" content)))))
backlinks))
(length backlinks)))
(dolist (group grouped-backlinks)
(let ((file-from (car group))
(bls (cdr group)))
(insert (format "** [[file:%s][%s]]\n"
file-from
(org-roam--get-title-or-slug file-from)))
(dolist (backlink bls)
(pcase-let ((`(,file-from ,file-to ,props) backlink))
(insert (propertize
(s-trim (s-replace "\n" " "
(plist-get props :content)))
'font-lock-face 'org-block
'help-echo "mouse-1: visit backlinked note"
'file-from file-from
'file-from-point (plist-get props :point)))
(insert "\n\n"))))))
(insert "\n\n* No backlinks!")))
(read-only-mode 1))))))
@ -667,24 +724,24 @@ If item at point is not org-roam specific, default to Org behaviour."
(org-roam--ensure-cache-built)
(with-temp-buffer
(insert "digraph {\n")
(dolist (file (org-roam--find-all-files))
(let ((title (org-roam--get-title-or-slug file)))
(let ((shortened-title (s-truncate org-roam-graph-max-title-length title)))
(insert
(let ((rows (org-roam-sql [:select [file titles] :from titles])))
(dolist (row rows)
(let* ((file (car row))
(title (or (caadr row)
(org-roam--path-to-slug file)))
(shortened-title (s-truncate org-roam-graph-max-title-length title)))
(insert
(format " \"%s\" [label=\"%s\", shape=%s, URL=\"roam://%s\", tooltip=\"%s\"];\n"
title
file
shortened-title
org-roam-graph-node-shape
file
title
)))))
(maphash
(lambda (from-link to-links)
(dolist (to-link to-links)
(insert (format " \"%s\" -> \"%s\";\n"
(org-roam--get-title-or-slug from-link)
(org-roam--get-title-or-slug to-link)))))
(org-roam--forward-links-cache))
title)))))
(let ((link-rows (org-roam-sql [:select :distinct [file-to file-from] :from file-links])))
(dolist (row link-rows)
(insert (format " \"%s\" -> \"%s\";\n"
(car row)
(cadr row)))))
(insert "}")
(buffer-string)))
@ -715,7 +772,6 @@ This needs to be quick/infrequent, because this is run at
(when (and (or redisplay
(not (eq org-roam--current-buffer buffer)))
(eq 'visible (org-roam--current-visibility))
(org-roam--cache-initialized-p)
(buffer-local-value 'buffer-file-truename buffer))
(setq org-roam--current-buffer buffer)
(org-roam-update (expand-file-name
@ -740,13 +796,8 @@ Applies `org-roam-link-face' if PATH correponds to a Roam file."
(setq org-roam-last-window (get-buffer-window))
(add-hook 'post-command-hook #'org-roam--maybe-update-buffer nil t)
(add-hook 'after-save-hook #'org-roam--update-cache nil t)
(if (org-roam--cache-initialized-p)
(org-roam--setup-found-file)
(org-roam--build-cache-async
(let ((buf (buffer-name)))
#'(lambda ()
(with-current-buffer buf
(org-roam--setup-found-file))))))))
(org-roam--setup-file-links)
(org-roam--maybe-update-buffer :redisplay nil)))
(defun org-roam--setup-file-links ()
"Set up `file:' Org links with org-roam-link-face."
@ -759,11 +810,6 @@ This sets `file:' Org links to have the org-link face."
(unless (version< org-version "9.2")
(org-link-set-parameters "file" :face 'org-link)))
(defun org-roam--setup-found-file ()
"Setup a buffer recognized via the \"find-file-hook\"."
(org-roam--setup-file-links)
(org-roam--maybe-update-buffer :redisplay nil))
(defvar org-roam-mode-map
(make-sparse-keymap)
"Keymap for org-roam commands.")
@ -778,9 +824,10 @@ This sets `file:' Org links to have the org-link face."
(not (auto-save-file-name-p new-file))
(org-roam--org-roam-file-p new-file))
(org-roam--ensure-cache-built)
(org-roam--clear-file-from-cache file)
(let* ((files (gethash file (org-roam--backward-links-cache) nil))
(let* ((files-to-rename (org-roam-sql [:select :distinct [file-from]
:from file-links
:where (= file-to $s1)]
file))
(path (file-truename file))
(new-path (file-truename new-file))
(slug (org-roam--get-title-or-slug file))
@ -788,27 +835,31 @@ This sets `file:' Org links to have the org-link face."
(new-slug (or (org-roam--get-title-from-cache path)
(org-roam--get-title-or-slug new-path)))
(new-title (format org-roam-link-title-format new-slug)))
(when files
(maphash (lambda (file-from props)
(let* ((file-dir (file-name-directory file-from))
(relative-path (file-relative-name new-path file-dir))
(old-relative-path (file-relative-name path file-dir))
(slug-regex (regexp-quote (format "[[file:%s][%s]]" old-relative-path old-title)))
(named-regex (concat
(regexp-quote (format "[[file:%s][" old-relative-path))
"\\(.*\\)"
(regexp-quote "]]"))))
(with-temp-file file-from
(insert-file-contents file-from)
(while (re-search-forward slug-regex nil t)
(replace-match (format "[[file:%s][%s]]" relative-path new-title)))
(goto-char (point-min))
(while (re-search-forward named-regex nil t)
(replace-match (format "[[file:%s][\\1]]" relative-path))))
(save-window-excursion
(find-file file-from)
(org-roam--update-cache))))
files))
(org-roam--clear-file-from-cache file)
(dolist (file-from files-to-rename)
(let* ((file-from (car file-from))
(file-from (if (string-equal (file-truename file-from)
path)
new-path
file-from))
(file-dir (file-name-directory file-from))
(relative-path (file-relative-name new-path file-dir))
(old-relative-path (file-relative-name path file-dir))
(slug-regex (regexp-quote (format "[[file:%s][%s]]" old-relative-path old-title)))
(named-regex (concat
(regexp-quote (format "[[file:%s][" old-relative-path))
"\\(.*\\)"
(regexp-quote "]]"))))
(with-temp-file file-from
(insert-file-contents file-from)
(while (re-search-forward slug-regex nil t)
(replace-match (format "[[file:%s][%s]]" relative-path new-title)))
(goto-char (point-min))
(while (re-search-forward named-regex nil t)
(replace-match (format "[[file:%s][\\1]]" relative-path))))
(save-window-excursion
(find-file file-from)
(org-roam--update-cache))))
(save-window-excursion
(find-file new-path)
(org-roam--update-cache)))))
@ -818,7 +869,7 @@ This sets `file:' Org links to have the org-link face."
"Minor mode for Org-roam.
When called interactively, toggle `org-roam-mode'. with prefix ARG, enable `org-roam-mode'
if ARG is posiwive, otherwise disable it.
if ARG is positive, otherwise disable it.
When called from Lisp, enable `org-roam-mode' if ARG is omitted, nil, or positive.
If ARG is `toggle', toggle `org-roam-mode'. Otherwise, behave as if called interactively."
@ -829,15 +880,16 @@ If ARG is `toggle', toggle `org-roam-mode'. Otherwise, behave as if called inter
:global t
(cond
(org-roam-mode
(unless (org-roam--cache-initialized-p)
(org-roam--build-cache-async))
(add-hook 'find-file-hook #'org-roam--find-file-hook-function)
(add-hook 'kill-emacs-hook #'org-roam--db-close-all)
(advice-add 'rename-file :after #'org-roam--rename-file-advice)
(advice-add 'delete-file :before #'org-roam--delete-file-advice))
(t
(remove-hook 'find-file-hook #'org-roam--find-file-hook-function)
(remove-hook 'kill-emacs-hook #'org-roam--db-close-all)
(advice-remove 'rename-file #'org-roam--rename-file-advice)
(advice-remove 'delete-file #'org-roam--delete-file-advice)
(org-roam--db-close-all)
;; Disable local hooks for all org-roam buffers
(dolist (buf (org-roam--get-roam-buffers))
(with-current-buffer buf
@ -845,8 +897,6 @@ If ARG is `toggle', toggle `org-roam-mode'. Otherwise, behave as if called inter
(remove-hook 'post-command-hook #'org-roam--maybe-update-buffer t)
(remove-hook 'after-save-hook #'org-roam--update-cache t))))))
(provide 'org-roam)
;;; Show/hide the org-roam buffer
(define-inline org-roam--current-visibility ()
"Return whether the current visibility state of the org-roam buffer.
@ -889,6 +939,9 @@ Valid states are 'visible, 'exists and 'none."
('visible (delete-window (get-buffer-window org-roam-buffer)))
('exists (org-roam--setup-buffer))
('none (org-roam--setup-buffer))))
;;; -
(provide 'org-roam)
;;; org-roam.el ends here
;; Local Variables:

View File

@ -1,7 +0,0 @@
#+TITLE: Multi-File 1
link to [[file:nested/mf1.org][Nested Multi-File 1]]
link to [[file:mf2.org][Multi-File 2]]
Arbitrary [[https://google.com][HTML]] link
Arbitrary text

View File

@ -1,3 +0,0 @@
#+TITLE: Multi-File 2
This file has no links.

View File

@ -1,5 +0,0 @@
#+TITLE: Multi-File 3
This file has a link to an file with no title.
[[file:multi-no-title.org][multi-no-title]]

View File

@ -1 +0,0 @@
no title in this file :O

View File

@ -1,4 +0,0 @@
#+TITLE: Nested Multi-File 1
Link to [[file:mf2.org][Nested Multi-File 2]]
Link to [[file:../mf1.org][Mulit-File 1]]

View File

@ -1,3 +0,0 @@
#+TITLE: Nested Multi-File 2
Link to [[file:mf1.org][Nested Multi-File 1]]

3
tests/roam-files/bar.org Normal file
View File

@ -0,0 +1,3 @@
#+TITLE: Bar
This is file bar. Bar links to [[file:nested/bar.org][Nested Bar]].

View File

@ -1,7 +0,0 @@
#+TITLE: File 1
link to [[file:nested/f1.org][Nested File 1]]
link to [[file:f2.org][File 2]]
Arbitrary [[https://google.com][HTML]] link
Arbitrary text

View File

@ -1,5 +0,0 @@
#+TITLE: File 3
This file has a link to an file with no title.
[[file:no-title.org][no-title]]

8
tests/roam-files/foo.org Normal file
View File

@ -0,0 +1,8 @@
#+TITLE: Foo
This is the foo file. It contains a link to [[file:bar.org][Bar]].
To make the tests more robust, here are some arbitrary links:
- [[https:google.com][Google]]
- [[mailto:foo@john.com][mail to foo]]

View File

@ -0,0 +1,3 @@
#+TITLE: Nested Bar
This file is nested, 1 level deeper. It links to both [[file:../foo.org][Foo]] and [[file:foo.org][Nested Foo]].

View File

@ -1,4 +0,0 @@
#+TITLE: Nested File 1
Link to [[file:f2.org][Nested File 2]]
Link to [[file:../f1.org][File 1]]

View File

@ -1,3 +0,0 @@
#+TITLE: Nested File 2
Link to [[file:f1.org][Nested File 1]]

View File

@ -1,3 +1,3 @@
#+TITLE: File 2
#+TITLE: Nested Foo
This file has no links.

View File

@ -1 +1,3 @@
no title in this file :O
links to itself, with no title: [[file:no-title.org][no-title]]

View File

@ -0,0 +1,3 @@
#+TITLE: Unlinked
Nothing links here :(

View File

@ -29,6 +29,7 @@
(require 'buttercup)
(require 'with-simulated-input)
(require 'org-roam)
(require 'org-roam-db)
(require 'dash)
(defun abs-path (file-path)
@ -42,320 +43,262 @@
(defvar org-roam--tests-directory (file-truename (concat default-directory "tests/roam-files"))
"Directory containing org-roam test org files.")
(defvar org-roam--tests-multi (file-truename (concat default-directory "tests/roam-files-multi"))
"Directory containing org-roam test org files.")
(defun org-roam--test-init ()
(org-roam--db-close)
(let ((original-dir org-roam--tests-directory)
(new-dir (expand-file-name (make-temp-name "org-roam") temporary-file-directory)))
(copy-directory original-dir new-dir)
(setq org-roam-directory new-dir)
(setq org-roam-mute-cache-build t))
(org-roam-mode +1))
(defun org-roam--test-multi-init ()
(let ((original-dir-1 org-roam--tests-directory)
(original-dir-2 org-roam--tests-multi)
(new-dir-1 (expand-file-name (make-temp-name "org-roam") temporary-file-directory))
(new-dir-2 (expand-file-name (make-temp-name "org-roam") temporary-file-directory)))
(copy-directory original-dir-1 new-dir-1)
(copy-directory original-dir-2 new-dir-2)
(setq org-roam-directory new-dir-1)
(setq org-roam-directory2 new-dir-2)
(setq org-roam-mute-cache-build t))
(org-roam-mode +1))
(defun org-roam--test-build-cache ()
"Builds the caches synchronously."
(let ((cache (org-roam--build-cache org-roam-directory)))
(let ((obj (org-roam--get-directory-cache)))
(oset obj initialized t)
(oset obj forward-links (plist-get cache :forward))
(oset obj backward-links (plist-get cache :backward))
(oset obj titles (plist-get cache :titles))
(oset obj refs (plist-get cache :refs)))))
(org-roam-mode +1)))
;;; Tests
(describe "org-roam--build-cache-async"
(describe "org-roam-build-cache"
(it "initializes correctly"
(org-roam--clear-cache)
(org-roam--test-multi-init)
(expect (org-roam--cache-initialized-p) :to-be nil)
(expect (hash-table-count (org-roam--forward-links-cache)) :to-be 0)
(expect (hash-table-count (org-roam--backward-links-cache)) :to-be 0)
(expect (hash-table-count (org-roam--titles-cache)) :to-be 0)
(org-roam--test-init)
(org-roam-build-cache)
(org-roam--build-cache-async)
(sleep-for 3) ;; Because it's async
;; Cache
(expect (caar (org-roam-sql [:select (funcall count) :from files])) :to-be 8)
(expect (caar (org-roam-sql [:select (funcall count) :from file-links])) :to-be 5)
(expect (caar (org-roam-sql [:select (funcall count) :from titles])) :to-be 8)
(expect (caar (org-roam-sql [:select (funcall count) :from titles
:where titles :is-null])) :to-be 2)
(expect (caar (org-roam-sql [:select (funcall count) :from refs])) :to-be 1)
;; Caches should be populated
(expect (org-roam--cache-initialized-p) :to-be t)
(expect (hash-table-count (org-roam--forward-links-cache)) :to-be 4)
(expect (hash-table-count (org-roam--backward-links-cache)) :to-be 5)
(expect (hash-table-count (org-roam--titles-cache)) :to-be 6)
(expect (hash-table-count (org-roam--refs-cache)) :to-be 1)
;; TODO Test files
;; Forward cache
(let ((f1 (gethash (abs-path "f1.org")
(org-roam--forward-links-cache)))
(f2 (gethash (abs-path "f2.org")
(org-roam--forward-links-cache)))
(nested-f1 (gethash (abs-path "nested/f1.org")
(org-roam--forward-links-cache)))
(nested-f2 (gethash (abs-path "nested/f2.org")
(org-roam--forward-links-cache)))
(expected-f1 (list (abs-path "nested/f1.org")
(abs-path "f2.org")))
(expected-nested-f1 (list (abs-path "nested/f2.org")
(abs-path "f1.org")))
(expected-nested-f2 (list (abs-path "nested/f1.org"))))
;; Links -- File-from
(expect (caar (org-roam-sql [:select (funcall count) :from file-links
:where (= file-from $s1)]
(abs-path "foo.org"))) :to-be 1)
(expect (caar (org-roam-sql [:select (funcall count) :from file-links
:where (= file-from $s1)]
(abs-path "nested/bar.org"))) :to-be 2)
(expect f1 :to-have-same-items-as expected-f1)
(expect f2 :to-be nil)
(expect nested-f1 :to-have-same-items-as expected-nested-f1)
(expect nested-f2 :to-have-same-items-as expected-nested-f2))
;; Links -- File-to
(expect (caar (org-roam-sql [:select (funcall count) :from file-links
:where (= file-to $s1)]
(abs-path "nested/foo.org"))) :to-be 1)
(expect (caar (org-roam-sql [:select (funcall count) :from file-links
:where (= file-to $s1)]
(abs-path "nested/bar.org"))) :to-be 1)
(expect (caar (org-roam-sql [:select (funcall count) :from file-links
:where (= file-to $s1)]
(abs-path "unlinked.org"))) :to-be 0)
;; TODO Test titles
(expect (org-roam-sql [:select * :from titles])
:to-have-same-items-as
(list (list (abs-path "alias.org")
(list "t1" "a1" "a 2"))
(list (abs-path "bar.org")
(list "Bar"))
(list (abs-path "foo.org")
(list "Foo"))
(list (abs-path "nested/bar.org")
(list "Nested Bar"))
(list (abs-path "nested/foo.org")
(list "Nested Foo"))
(list (abs-path "no-title.org") nil)
(list (abs-path "web_ref.org") nil)
(list (abs-path "unlinked.org")
(list "Unlinked"))))
;; Backward cache
(let ((f1 (hash-table-keys (gethash (abs-path "f1.org")
(org-roam--backward-links-cache))))
(f2 (hash-table-keys (gethash (abs-path "f2.org")
(org-roam--backward-links-cache))))
(nested-f1 (hash-table-keys
(gethash (abs-path "nested/f1.org")
(org-roam--backward-links-cache))))
(nested-f2 (hash-table-keys
(gethash (abs-path "nested/f2.org")
(org-roam--backward-links-cache))))
(expected-f1 (list (abs-path "nested/f1.org")))
(expected-f2 (list (abs-path "f1.org")))
(expected-nested-f1 (list (abs-path "nested/f2.org")
(abs-path "f1.org")))
(expected-nested-f2 (list (abs-path "nested/f1.org"))))
(expect f1 :to-have-same-items-as expected-f1)
(expect f2 :to-have-same-items-as expected-f2)
(expect nested-f1 :to-have-same-items-as expected-nested-f1)
(expect nested-f2 :to-have-same-items-as expected-nested-f2))
(expect (org-roam-sql [:select * :from refs])
:to-have-same-items-as
(list (list "https://google.com/" (abs-path "web_ref.org"))))
;; Titles Cache
(expect (gethash (abs-path "f1.org")
(org-roam--titles-cache)) :to-equal (list "File 1"))
(expect (gethash (abs-path "f2.org")
(org-roam--titles-cache)) :to-equal (list "File 2"))
(expect (gethash (abs-path "nested/f1.org")
(org-roam--titles-cache)) :to-equal (list "Nested File 1"))
(expect (gethash (abs-path "nested/f2.org")
(org-roam--titles-cache)) :to-equal (list "Nested File 2"))
(expect (gethash (abs-path "alias.org")
(org-roam--titles-cache)) :to-equal (list "t1" "a1" "a 2"))
(expect (gethash (abs-path "no-title.org")
(org-roam--titles-cache)) :to-be nil)
;; Refs Cache
(expect (gethash "https://google.com/"
(org-roam--refs-cache)) :to-equal (abs-path "web_ref.org"))
;; Multi
(let ((org-roam-directory org-roam-directory2))
(org-roam--build-cache-async)
(sleep-for 3) ;; Because it's async
;; Caches should be populated
(expect (org-roam--cache-initialized-p) :to-be t)
(expect (hash-table-count (org-roam--forward-links-cache)) :to-be 4)
(expect (hash-table-count (org-roam--backward-links-cache)) :to-be 5)
(expect (hash-table-count (org-roam--titles-cache)) :to-be 5)
;; Forward cache
(let ((mf1 (gethash (abs-path "mf1.org")
(org-roam--forward-links-cache)))
(mf2 (gethash (abs-path "mf2.org")
(org-roam--forward-links-cache)))
(nested-mf1 (gethash (abs-path "nested/mf1.org")
(org-roam--forward-links-cache)))
(nested-mf2 (gethash (abs-path "nested/mf2.org")
(org-roam--forward-links-cache)))
(expected-mf1 (list (abs-path "nested/mf1.org")
(abs-path "mf2.org")))
(expected-nested-mf1 (list (abs-path "nested/mf2.org")
(abs-path "mf1.org")))
(expected-nested-mf2 (list (abs-path "nested/mf1.org"))))
(expect mf1 :to-have-same-items-as expected-mf1)
(expect mf2 :to-be nil)
(expect nested-mf1 :to-have-same-items-as expected-nested-mf1)
(expect nested-mf2 :to-have-same-items-as expected-nested-mf2))
;; Backward cache
(let ((mf1 (hash-table-keys
(gethash (abs-path "mf1.org")
(org-roam--backward-links-cache))))
(mf2 (hash-table-keys
(gethash (abs-path "mf2.org")
(org-roam--backward-links-cache))))
(nested-mf1 (hash-table-keys
(gethash (abs-path "nested/mf1.org")
(org-roam--backward-links-cache))))
(nested-mf2 (hash-table-keys
(gethash (abs-path "nested/mf2.org")
(org-roam--backward-links-cache))))
(expected-mf1 (list (abs-path "nested/mf1.org")))
(expected-mf2 (list (abs-path "mf1.org")))
(expected-nested-mf1 (list (abs-path "nested/mf2.org")
(abs-path "mf1.org")))
(expected-nested-mf2 (list (abs-path "nested/mf1.org"))))
(expect mf1 :to-have-same-items-as expected-mf1)
(expect mf2 :to-have-same-items-as expected-mf2)
(expect nested-mf1 :to-have-same-items-as expected-nested-mf1)
(expect nested-mf2 :to-have-same-items-as expected-nested-mf2))
;; Titles Cache
(expect (gethash (abs-path "mf1.org")
(org-roam--titles-cache))
:to-equal (list "Multi-File 1"))
(expect (gethash (abs-path "mf2.org")
(org-roam--titles-cache))
:to-equal (list "Multi-File 2"))
(expect (gethash (abs-path "nested/mf1.org")
(org-roam--titles-cache))
:to-equal (list "Nested Multi-File 1"))
(expect (gethash (abs-path "nested/mf2.org")
(org-roam--titles-cache))
:to-equal (list "Nested Multi-File 2"))
(expect (gethash (abs-path "no-title.org")
(org-roam--titles-cache))
:to-be nil))))
;; Expect rebuilds to be really quick (nothing changed)
(expect (org-roam-build-cache)
:to-equal
(list :files 0 :links 0 :titles 0 :refs 0 :deleted 0))))
(describe "org-roam-insert"
(before-each
(org-roam--test-init)
(org-roam--clear-cache)
(org-roam--test-build-cache))
(org-roam-build-cache))
(it "temp1 -> f1"
(it "temp1 -> foo"
(let ((buf (org-roam--test-find-new-file "temp1.org")))
(with-current-buffer buf
(with-simulated-input
"File SPC 1 RET"
"Foo RET"
(org-roam-insert nil))))
(expect (buffer-string) :to-match (regexp-quote "file:f1.org")))
(expect (buffer-string) :to-match (regexp-quote "file:foo.org")))
(it "temp2 -> nested/f1"
(it "temp2 -> nested/foo"
(let ((buf (org-roam--test-find-new-file "temp2.org")))
(with-current-buffer buf
(with-simulated-input
"Nested SPC File SPC 1 RET"
"Nested SPC Foo RET"
(org-roam-insert nil))))
(expect (buffer-string) :to-match (regexp-quote "file:nested/f1.org")))
(expect (buffer-string) :to-match (regexp-quote "file:nested/foo.org")))
(it "nested/temp3 -> f1"
(it "nested/temp3 -> foo"
(let ((buf (org-roam--test-find-new-file "nested/temp3.org")))
(with-current-buffer buf
(with-simulated-input
"File SPC 1 RET"
"Foo RET"
(org-roam-insert nil))))
(expect (buffer-string) :to-match (regexp-quote "file:../f1.org")))
(expect (buffer-string) :to-match (regexp-quote "file:../foo.org")))
(it "a/b/temp4 -> nested/f1"
(it "a/b/temp4 -> nested/foo"
(let ((buf (org-roam--test-find-new-file "a/b/temp4.org")))
(with-current-buffer buf
(with-simulated-input
"Nested SPC File SPC 1 RET"
"Nested SPC Foo RET"
(org-roam-insert nil))))
(expect (buffer-string) :to-match (regexp-quote "file:../../nested/f1.org"))))
(expect (buffer-string) :to-match (regexp-quote "file:../../nested/foo.org"))))
(describe "rename file updates cache"
(before-each
(org-roam--test-init)
(org-roam--clear-cache)
(org-roam--test-build-cache))
(org-roam-build-cache))
(it "f1 -> new_f1"
(rename-file (abs-path "f1.org")
(abs-path "new_f1.org"))
(it "foo -> new_foo"
(rename-file (abs-path "foo.org")
(abs-path "new_foo.org"))
;; Cache should be cleared of old file
(expect (gethash (abs-path "f1.org") (org-roam--forward-links-cache)) :to-be nil)
(expect (->> (org-roam--backward-links-cache)
(gethash (abs-path "nested/f1.org"))
(hash-table-keys)
(member (abs-path "f1.org"))) :to-be nil)
(expect (caar (org-roam-sql [:select (funcall count)
:from titles
:where (= file $s1)]
(abs-path "foo.org"))) :to-be 0)
(expect (caar (org-roam-sql [:select (funcall count)
:from refs
:where (= file $s1)]
(abs-path "foo.org"))) :to-be 0)
(expect (caar (org-roam-sql [:select (funcall count)
:from file-links
:where (= file-from $s1)]
(abs-path "foo.org"))) :to-be 0)
(expect (->> (org-roam--forward-links-cache)
(gethash (abs-path "new_f1.org"))) :not :to-be nil)
(expect (->> (org-roam--forward-links-cache)
(gethash (abs-path "new_f1.org"))
(member (abs-path "nested/f1.org"))) :not :to-be nil)
;; Cache should be updated
(expect (org-roam-sql [:select [file-to]
:from file-links
:where (= file-from $s1)]
(abs-path "new_foo.org"))
:to-have-same-items-as
(list (list (abs-path "bar.org"))))
(expect (org-roam-sql [:select [file-from]
:from file-links
:where (= file-to $s1)]
(abs-path "new_foo.org"))
:to-have-same-items-as
(list (list (abs-path "nested/bar.org"))))
;; Links are updated
(expect (with-temp-buffer
(insert-file-contents (abs-path "nested/f1.org"))
(buffer-string)) :to-match (regexp-quote "[[file:../new_f1.org][File 1]]")))
(insert-file-contents (abs-path "nested/bar.org"))
(buffer-string))
:to-match
(regexp-quote "[[file:../new_foo.org][Foo]]")))
(it "f1 -> f1 with spaces"
(rename-file (abs-path "f1.org")
(abs-path "f1 with spaces.org"))
(it "foo -> foo with spaces"
(rename-file (abs-path "foo.org")
(abs-path "foo with spaces.org"))
;; Cache should be cleared of old file
(expect (gethash (abs-path "f1.org") (org-roam--forward-links-cache)) :to-be nil)
(expect (->> (org-roam--backward-links-cache)
(gethash (abs-path "nested/f1.org"))
(hash-table-keys)
(member (abs-path "f1.org"))) :to-be nil)
(expect (caar (org-roam-sql [:select (funcall count)
:from titles
:where (= file $s1)]
(abs-path "foo.org"))) :to-be 0)
(expect (caar (org-roam-sql [:select (funcall count)
:from refs
:where (= file $s1)]
(abs-path "foo.org"))) :to-be 0)
(expect (caar (org-roam-sql [:select (funcall count)
:from file-links
:where (= file-from $s1)]
(abs-path "foo.org"))) :to-be 0)
;; Cache should be updated
(expect (org-roam-sql [:select [file-to]
:from file-links
:where (= file-from $s1)]
(abs-path "foo with spaces.org"))
:to-have-same-items-as
(list (list (abs-path "bar.org"))))
(expect (org-roam-sql [:select [file-from]
:from file-links
:where (= file-to $s1)]
(abs-path "foo with spaces.org"))
:to-have-same-items-as
(list (list (abs-path "nested/bar.org"))))
;; Links are updated
(expect (with-temp-buffer
(insert-file-contents (abs-path "nested/f1.org"))
(buffer-string)) :to-match (regexp-quote "[[file:../f1 with spaces.org][File 1]]")))
(insert-file-contents (abs-path "nested/bar.org"))
(buffer-string))
:to-match
(regexp-quote "[[file:../foo with spaces.org][Foo]]")))
(it "no-title -> meaningful-title"
(rename-file (abs-path "no-title.org")
(abs-path "meaningful-title.org"))
;; File has no forward links
(expect (gethash (abs-path "no-title.org") (org-roam--forward-links-cache)) :to-be nil)
(expect (gethash (abs-path "meaningful-title.org")
(org-roam--forward-links-cache)) :to-be nil)
(expect (->> (org-roam--forward-links-cache)
(gethash (abs-path "f3.org"))
(member (abs-path "no-title.org"))) :to-be nil)
(expect (->> (org-roam--forward-links-cache)
(gethash (abs-path "f3.org"))
(member (abs-path "meaningful-title.org"))) :not :to-be nil)
(expect (caar (org-roam-sql [:select (funcall count)
:from file-links
:where (= file-from $s1)]
(abs-path "no-title.org"))) :to-be 0)
(expect (caar (org-roam-sql [:select (funcall count)
:from file-links
:where (= file-from $s1)]
(abs-path "meaningful-title.org"))) :to-be 1)
;; Links are updated with the appropriate name
(expect (with-temp-buffer
(insert-file-contents (abs-path "f3.org"))
(buffer-string)) :to-match (regexp-quote "[[file:meaningful-title.org][meaningful-title]]")))
(insert-file-contents (abs-path "meaningful-title.org"))
(buffer-string))
:to-match
(regexp-quote "[[file:meaningful-title.org][meaningful-title]]")))
(it "web_ref -> hello"
(expect (->> (org-roam--refs-cache)
(gethash "https://google.com/"))
:to-equal (abs-path "web_ref.org"))
(expect (org-roam-sql
[:select [file] :from refs
:where (= ref $s1)]
"https://google.com/")
:to-equal
(list (list (abs-path "web_ref.org"))))
(rename-file (abs-path "web_ref.org")
(abs-path "hello.org"))
(expect (->> (org-roam--refs-cache)
(gethash "https://google.com/"))
:to-equal (abs-path "hello.org"))))
(expect (org-roam-sql
[:select [file] :from refs
:where (= ref $s1)]
"https://google.com/")
:to-equal (list (list (abs-path "hello.org"))))
(expect (caar (org-roam-sql
[:select [ref] :from refs
:where (= file $s1)]
(abs-path "web_ref.org")))
:to-equal nil)))
(describe "delete file updates cache"
(before-each
(org-roam--test-init)
(org-roam--clear-cache)
(org-roam--test-build-cache))
(it "delete f1"
(delete-file (abs-path "f1.org"))
(expect (->> (org-roam--forward-links-cache)
(gethash (abs-path "f1.org"))) :to-be nil)
(expect (->> (org-roam--backward-links-cache)
(gethash (abs-path "nested/f1.org"))
(gethash (abs-path "f1.org"))) :to-be nil)
(expect (->> (org-roam--backward-links-cache)
(gethash (abs-path "nested/f1.org"))
(gethash (abs-path "nested/f2.org"))) :not :to-be nil))
(org-roam-build-cache)
(sleep-for 1))
(it "delete foo"
(delete-file (abs-path "foo.org"))
(expect (caar (org-roam-sql [:select (funcall count)
:from titles
:where (= file $s1)]
(abs-path "foo.org"))) :to-be 0)
(expect (caar (org-roam-sql [:select (funcall count)
:from refs
:where (= file $s1)]
(abs-path "foo.org"))) :to-be 0)
(expect (caar (org-roam-sql [:select (funcall count)
:from file-links
:where (= file-from $s1)]
(abs-path "foo.org"))) :to-be 0))
(it "delete web_ref"
(expect (->> (org-roam--refs-cache)
(gethash "https://google.com/"))
:to-equal (abs-path "web_ref.org"))
(expect (org-roam-sql [:select * :from refs])
:to-have-same-items-as
(list (list "https://google.com/" (abs-path "web_ref.org"))))
(delete-file (abs-path "web_ref.org"))
(expect (->> (org-roam--refs-cache)
(gethash "https://google.com/"))
:to-be nil)))
(expect (org-roam-sql [:select * :from refs])
:to-have-same-items-as
(list))))