org-roam-browser-extension
org-roam-browser-extension copied to clipboard
Org-roam extension to the browser
#+TITLE: Org-roam extension to the browser
This browser plugin gives you the ability to display that you've captured information on a page in org-roam and allows you to jump to the related page.
- Using this addon This addon is a work in progress. Features may change, and APIs have not stabilised yet. It has not been published on MELPA nor has a Firefox Addon been published. Wiring it as we do for development is not hard and can be found in this section.
** Start the elisp webserver Install the web-server package with ~M-x package-install web-server~.
Evaluate the [[./elisp-server/org-roam-browser-server.el]] file from this repository. ~M-x eval-buffer~
** Load the browser plugin
- Open up Firefox
- Browse to [[about:debugging]]
- Click "This Firefox"
- Click Load Temporary Add-on
- Select the manifest or js file & enjoy the plugin
** See it in action If you've visited a page on which you have made notes, either by inserting the link to the page, or by having it as a ~ROAM_KEY~, the icon next to the URL should change accordingly.
If the icon is not grayed out, you can click it to visit the notes you've made on the current page.
- How this works The plugin consists of two parts: a browser plugin and an elisp web service. They go hand-in-hand to supply the necessary information to the browser.
** Browser plugin The browser plugin shows a pageAction to indicate whether we've found links on the current page or not.
A browser plugin consists of a manifest file and a source-file.
*** Manifest The manifest file mainly supplies metadata of the browser plugin. Most of this is metadata. Note that we fetch the =tabs= and =activeTab= permissions so we can figure out what URL the user is browsing.
The logo we're using here is licensed under [[http://creativecommons.org/licenses/by-sa/4.0/][CC BY-SA 4.0]] and is
included as such; it was designed by [[https://github.com/zaeph][zaeph]] for the [[https://www.orgroam.com/][Org-roam
project]], adapted by [[https://github.com/nobiot][nobiot]]. The logo is supplied as an SVG and
works in both 48x48px as well as 96x96px.
#+begin_src json :tangle ./browser-extension/manifest.json
{
"manifest_version": 2,
"name": "org-roam monitor",
"version": "0.0.1",
"description": "Shows whether or not you have an org-roam page for the currently visited site.",
"icons": {
"48": "org-roam-logo.svg",
"96": "org-roam-logo.svg"
},
"background": {
"scripts": ["roam.js"]
},
"page_action": {
"default_icon": "org-roam-logo-inactive.svg",
"browser_style": true
},
"permissions": [
"activeTab",
"tabs"
]
}
#+end_src
*** Browser plugin implementation :PROPERTIES: :header-args: :tangle ./browser-extension/roam.js :comments link :END:
For each visible tab, whenever it is updated too, we want to
figure out what page we're visiting.
First thing we do is figure out the current URL. We split off the
protocol because that's what roam does internally (this would
better be served in the elisp world and it may change).
Once we have that, we ask the org-roam backend server if there is
any info on the page. If there is, then we render a positive
pageIcon, otherwise a negative one.
If there is a positive response, we'll also bind a click handler
to the pageAction so we can open up the page when it is clicked.
#+begin_src javascript
const tabInfo = {};
/**
,* Initialize the page action: set icon and title, then show.
,*/
async function initializePageAction(tab) {
// Make information request over http
const urlNoProtocol = tab.url.slice((new URL(tab.url)).protocol.length);
const fetched = await fetch(`http://localhost:10001/roam/info?url=${urlNoProtocol}`);
const body = await fetched.json();
// Extract information from request
const pageExists = body.pageExists;
const linkExists = body.linkExists;
const parentKnown = body.parentKnown;
// set up information to be rendered for the icon
let iconUrl;
let title;
let found;
if (pageExists) {
iconUrl = "org-roam-logo-has-page.svg";
title = "Has page";
found = true;
} else if (linkExists) {
iconUrl = "org-roam-logo-has-link.svg";
title = "Has link";
found = true;
} else if (parentKnown) {
iconUrl = "org-roam-logo-has-upper-reference.svg";
title = "Parent is known";
found = true;
} else {
iconUrl = "org-roam-logo-inactive.svg";
title = "Nothing found";
found = false;
}
if (found) {
title += `: ${body.bestLink};`;
}
// attach information to the icon
browser.pageAction.setIcon({ tabId: tab.id, path: iconUrl });
browser.pageAction.setTitle({ tabId: tab.id, title });
browser.pageAction.show(tab.id);
// enable click handler
tabInfo[tab.id] = { link: body.bestLink };
browser.pageAction.onClicked.removeListener( clickEventListener );
browser.pageAction.onClicked.addListener( clickEventListener );
}
async function clickEventListener(tab) {
// the tab itself does not need to stay the same object, but the id
// is stable. If a browser creates an infinite amount of tab ids,
// this would be a small memory leak.
const link = tabInfo[tab.id]?.link;
if( link ) {
fetch(`http://localhost:10001/roam/open?page=${link}`);
}
}
#+end_src
We need to ensure the above function is called whenever a tab is updated.
#+begin_src javascript
/**
* Each time a tab is updated, reset the page action for that tab.
*/
browser.tabs.onUpdated.addListener((id, changeInfo, tab) => {
initializePageAction(tab);
});
#+end_src
We also want to update when we load this plugin for the first time.
#+begin_src javascript
/**
* When first loaded, initialize the page action for all tabs.
*/
browser
.tabs
.query({})
.then((tabs) => {
for (let tab of tabs) {
console.log("Initializing TAB");
initializePageAction(tab);
}
});
#+end_src
** The elisp server :PROPERTIES: :header-args: :tangle ./elisp-server/org-roam-browser-server.el :comments link :END:
All elisp packages start with a prologue #+begin_src emacs-lisp ;;; org-roam-browser-server -- A package providing information to the browser on what you have stored in org-roam.
;;; Commentary:
;;;
;;; More information at https://github.com/madnificent/org-roam-browser-server.git
;;; Code:
#+end_src
Turns out there's a super simple emacs webserver we can use.
*** Information requests
The handler function needs to look up a bunch of URLs. To simplify
that, we draft a function to help split a URL in its interesting
parts.
The funtion generates too much matches, but it's sufficient for our
current tests.
#+begin_src emacs-lisp
(defun org-roam-browser-server--sub-urls (url)
"Generate a list of sub-urls from URL."
(when (string-prefix-p "//" url)
(remove
"//"
(reduce (lambda (acc val)
(let ((start (first acc)))
`(,(concat start val "/")
,(concat start val)
,@acc)))
(split-string (string-trim url "//") "/" "")
:initial-value '("//")))))
#+end_src
Next up we define two functions for checking if there are
interesting documents in the database. One checks if one of an
array of links can be found, the second checks if a page with the
given reference exists.
#+begin_src emacs-lisp
(defun org-roam-browser-server--reference-exists-as-key (&rest references)
"Verify if any of REFERENCES is known in org-roam."
(org-roam-db-query
[:select file
:from refs
:where ref :in $v1]
(apply #'vector references)))
(defun org-roam-browser-server--reference-exists-as-link (&rest references)
"Verify if any of REFERENCES is referred to in org-roam."
(org-roam-db-query
[:select source
:from links
:where links:dest :in $v1]
(apply #'vector references)))
#+end_src
The handler function becomes simple. It receives the stripped URL
and just has to respond with wether we have info on this or not.
As an added complexity, it also checks if any of the parent URLs is
found or referenced, based on previous functions.
We set the Access-Control-Allow-Origin header to indicate to the
browser that this API can be used from external sites (our addon
would otherwise not be allowed to load this resource).
#+begin_src emacs-lisp
(defun org-roam-browser-server--info-handler (request)
(with-slots (process headers) request
(condition-case ex
(let ((process-response
(let ((url (cdr (assoc "url" headers))))
(let ((page-exists (org-roam-browser-server--reference-exists-as-key url))
(page-referenced (org-roam-browser-server--reference-exists-as-link url))
(parent-known
(let ((parent-list (org-roam-browser-server--sub-urls url)))
(or (apply #'org-roam-browser-server--reference-exists-as-key parent-list)
(apply #'org-roam-browser-server--reference-exists-as-link parent-list)))))
(let ((best-link (or (first (first page-exists)) (first (first page-referenced)) (first (first parent-known)))))
(concat
"{\"pageExists\": " (if page-exists "true" "false") ",\n"
" \"linkExists\": " (if page-referenced "true" "false") ",\n"
" \"parentKnown\": " (if parent-known "true" "false") ",\n"
" \"bestLink\": " (if best-link
(concat "\"" best-link "\"")
"false")
"}"))
))))
(ws-response-header process 200 '("Content-type" . "application/json") '("Access-Control-Allow-Origin" . "*"))
(process-send-string process process-response))
('error (backtrace)
(ws-response-header process 500 '("Content-type" . "application/json") '("Access-Control-Allow-Origin" . "*"))
(process-send-string process "{\"error\": \"Error occurred when fetching result\" }")))))
#+end_src
#+RESULTS:
: org-roam-server-handler
*** Opening a file Because we know the "best" match, we can open it when asked to do so. We expect the file to be opened will be stored in the file parameter.
#+begin_src emacs-lisp
(defun org-roam-browser-server--open-handler (request)
(with-slots (process headers) request
(condition-case ex
(let ((page (cdr (assoc "page" headers))))
(message "Opening file %s" page)
(find-file-existing page)
(ws-response-header process 200 '("Content-type" . "application/json") '("Access-Control-Allow-Origin" . "*"))
(process-send-string process "{ \"success\": true }"))
('error (backtrace)
(ws-response-header process 500 '("Content-type" . "application/json") '("Access-Control-Allow-Origin" . "*"))
(process-send-string process "{\"error\": \"Error occurred when trying to open file\"}")))))
#+end_src
*** Booting up the server We just open it on port 10001 and add two handlers. One for incoming information handlers and one for opening a file.
#+begin_src emacs-lisp
(ws-start
'(((:GET . "/roam/info") . org-roam-browser-server--info-handler)
((:GET . "/roam/open") . org-roam-browser-server--open-handler))
10001)
#+end_src
*** Closing the sources We end with providing this package:
#+begin_src emacs-lisp
(provide 'org-roam-browser-server)
;;; org-roam-browser-server.el ends here
#+end_src
-
Next steps This is a PoC. If we want it to stick around, it should evolve into something more extensive.
Obvious things that spring to mind:
- [ ] Move stripping of protocol into elisp land
- [X] Add icon to indicate a hyperlink to a page was found
- [X] Add action to open the org-roam page for the current site
- [ ] Add action to create an org-roam page for the current site
- [X] Add indication that a parent page was found in org-roam
- [ ] Make port configurable
- [ ] Release this on known platforms
- [ ] Check if WebExtension#browserAction would be nicer than WebExtension#pageAction