Skip to content

madnificent/org-roam-browser-extension

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

19 Commits
 
 
 
 
 
 

Repository files navigation

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

  1. Open up Firefox
  2. Browse to about:debugging
  3. Click “This Firefox”
  4. Click Load Temporary Add-on
  5. 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 CC BY-SA 4.0 and is included as such; it was designed by zaeph for the Org-roam project, adapted by nobiot. The logo is supplied as an SVG and works in both 48x48px as well as 96x96px.

{
  "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"
  ]
}

Browser plugin implementation

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.

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}`);
  }
}

We need to ensure the above function is called whenever a tab is updated.

/**
 *  Each time a tab is updated, reset the page action for that tab.
 */
browser.tabs.onUpdated.addListener((id, changeInfo, tab) => {
  initializePageAction(tab);
});

We also want to update when we load this plugin for the first time.

/**
 * 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);
    }
  });

The elisp server

All elisp packages start with a prologue

;;; 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:

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.

(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 '("//")))))

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.

(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)))

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).

(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\" }")))))

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.

(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\"}")))))

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.

(ws-start
 '(((:GET . "/roam/info") . org-roam-browser-server--info-handler)
   ((:GET . "/roam/open") . org-roam-browser-server--open-handler))
 10001)

Closing the sources

We end with providing this package:

(provide 'org-roam-browser-server)
;;; org-roam-browser-server.el ends here

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

About

Org-roam extension to the browser

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published