Quick Capture Safari content into a Denote file

The why

I have been looking to setup my personal knowledge management with Denote for a while. So far I have been using Org-roam, which I was quite happy with. However I have been seeing much more development and buzz around Denote. Not just with the core package but with more package from the community in the ecosystem.

This week I finally managed to migrate from Org-roam to Denote. Well, not really migrate but rather use both in tandem. I am finding myself needing some functionalities in each system that I do not want to loose. Who knows, maybe I ll be done with the move at some point.

I like the lack of a need for an SQLite db by Denote most appealing.

Now to the topic at hand. I like a quick capture of content from my browser – in my case Safari – into a note very much. As a matter of fact I depend on it for work and for my private stuff.

So far I have been capturing the content into a long long notes file as a subtree and then refiling it into separate org files in my roam directory. With the setup of denote I took the opportunity to reduced the steps for creating a knowledge “node”.

Now I only have to hit a keybinding (works system wide on my mac) and the file gets created.

How did I do it?

TL;DR

You will find the entry point in my denote config module timu-denote.el.

Some details

I need to confess something first. I heavily borrowed code from the package org-mac-link, which I took over as maintainer a while ago and from some code by Howard Abrams.

This also builds on the work documented in my previous post Org capture from everywhere in macOS.

Step 1 - Get desired content into the macOS clipboard.

I use a macOS Service created with the Automator.app for that. This copies the selected content in Safari into the clipboard.

on run {input, parameters}
    tell application "System Events" to keystroke "c" using command down
    ...
end run

Step 2 - Return the clipboard content in html format.

Then clipboard content capture by Apple Script needs to be returned in html format. This is necessary for it to be converted in org mode format later.

These functions are part of the timu-org.el module in my config.

(defun timu-org-cmd-with-exit-code (program &rest args)
  "Run PROGRAM with ARGS and return the exit code and output in a list."
  (with-temp-buffer
    (list (apply #'call-process program nil (current-buffer) nil args)
          (buffer-string))))

(defun timu-org-convert-applescript-to-html (contents)
  "Return the Applescript's clipboard CONTENTS in a packed array.
Convert and return this encoding into a UTF-8 string."
  (cl-flet ((hex-pack-bytes (tuple) (string-to-number (apply #'string tuple) 16)))
    (let* ((data (-> contents
                     (substring 10 -2) ; strips off the =«data RTF= and =»\= bits
                     (string-to-list)))
           (byte-seq (->> data
                          (-partition 2)  ; group each two hex characters into tuple
                          (mapcar #'hex-pack-bytes))))
      (decode-coding-string
       (mapconcat #'byte-to-string byte-seq "") 'utf-8))))

(defun timu-org-get-mac-clipboard ()
  "Return the clipbaard for a macOS system.
See `timu-org-get-os-clipboard'."
  (cl-destructuring-bind
      (exit-code contents)
      (timu-org-cmd-with-exit-code
       "/usr/bin/osascript" "-e" "the clipboard as \"HTML\"")
    (if (= 0 exit-code)
        (list :html (timu-org-convert-applescript-to-html contents))
      (list :text (shell-command-to-string
                   "/usr/bin/osascript -e 'the clipboard'")))))

Step 3 - Put the html clipboard content into org mode format.

The function timu-org-clipboard, returns the macOS clipboard content in org-mode format. Notice that there is the external dependency Pandoc. This needs to be installed on your Mac.

;; This one is just for the whole thing to work both on linux and macos

(defun timu-org-get-os-clipboard ()
  "Return a list where the first entry is the either :html or :text.
The second is the clipboard contents."
  (if (eq system-type 'darwin)
      (timu-org-get-mac-clipboard)
    (timu-org-get-linux-clipboard)))

(defun timu-org-clipboard ()
  "Return the contents of the clipboard in `org-mode' format."
  (cl-destructuring-bind (type contents) (timu-org-get-os-clipboard)
    (with-temp-buffer
      (insert contents)
      (if (eq :html type)
          (shell-command-on-region
           (point-min)
           (point-max)
           (concat (executable-find "pandoc") " -f html -t org --wrap=none") t t)
        (shell-command-on-region
         (point-min)
         (point-max)
         (concat (executable-find "pandoc") " -f markdown -t org --wrap=none") t t))
      (buffer-substring-no-properties (point-min) (point-max)))))

Step 4: Get the URL from Safari

Like I mentioned above, I want to keep both Org-roam and Denote. So I need the URL for the property ROAM_REFS.

For this I use this slightly adjusted code lifted from the package org-mac-link:

(defun timu-denote-do-applescript (script)
  "Function to run AppleScript pragmatically."
  (let (start cmd return)
    (while (string-match "\n" script)
      (setq script (replace-match "\r" t t script)))
    (while (string-match "'" script start)
      (setq start (+ 2 (match-beginning 0))
            script (replace-match "\\'" t t script)))
    (setq cmd (concat "osascript -e '" script "'"))
    (setq return (shell-command-to-string cmd))
    (org-trim return)))

(defun timu-denote-safari-get-frontmost-url ()
  "AppleScript to get the link to the frontmost window of Safari."
  (timu-denote-do-applescript
   (concat
    "tell application \"Safari\"\n"
    "	set theUrl to URL of document 1\n"
    "	return theUrl \n"
    "end tell\n")))

Step 5: Get the title from Safari

Most of the time I want to use the site title as the title for the Denote note.

(defun timu-denote-safari-get-frontmost-title ()
  "AppleScript to get the title to the frontmost window of Safari."
  (timu-denote-do-applescript
   (concat
    "tell application \"Safari\"\n"
    "	set theTitle to the name of the document 1\n"
    "	return theTitle \n"
    "end tell\n")))

Let’s stick the landing

Step 1: Function and Org capture template to put everything together.

(defun timu-denote-safari-org-capture ()
  "Capture selected content and url of the current Safari window.
Create a denote note with the capture items.
Add properties for org-roam compatibility.
Triggered by a custom macOS Quick Action with keybinding."
  (let ((denote-org-capture-specifiers "\n%?\n\n%i\n")
        (denote-use-title (timu-denote-safari-get-frontmost-title))
        (denote-org-front-matter
         (concat
          ":PROPERTIES:\n"
          ":ID: " (org-id-new) "\n"
          ":ROAM_REFS: " (timu-denote-safari-get-frontmost-url) "\n"
          ":END:\n"
          "#+TITLE: %s\n"
          "#+AUTHOR: Aimé Bertrand\n"
          "#+DATE: %s\n"
          "#+FILETAGS: %s\n"
          "#+IDENTIFIER: %s\n"
          "#+SIGNATURE: %s\n"
          "#+OPTIONS: d:t tex:verbatim ^:nil broken-links:t num:nil\n"
          "#+STARTUP: indent showall")))
    (org-capture-string (timu-org-clipboard) "n")
    (ignore-errors)
    (org-capture-finalize)))

Notice how denote-org-capture-specifiers, denote-use-title and denote-org-front-matter need to be adjusted here. Also I pragmatically add an Org ID for the Org-Roam SQLite DB.

The function also calls the capture template for Denote, which is called by the key “n”.

(add-to-list 'org-capture-templates
             '("n" "macOS Safari clipboard capture to denote" plain
               (file denote-last-path)
               #'denote-org-capture
               :no-save t
               :immediate-finish nil
               :kill-buffer t
               :jump-to-captured t))

NOTE: I still got a bit of adjustments to do. Like changing the #+AUTHOR: while capturing. For now I am gonna leave my name as a placeholder. Also I still need to select #+FILETAGS: while capturing.

Step 2: Complete the macOS Service

Besides copying the Safari content the macOS Service focuses Emacs and calls the Function timu-denote-safari-org-capture to capture the note. The complete Apple Script is then:

on run {input, parameters}

    tell application "System Events" to keystroke "c" using command down

    if application "Emacs" is running then
        tell application "Emacs"
            activate
        end tell
    end if

    do shell script "emacsclient -n -e '(timu-denote-safari-org-capture)'"

    return input
end run

The steps to capture

  1. Select the content you want to copy into the note.
  2. Hit a Keybinding to call the macOS Service.
  3. Emacs gets focused with a prompt for tags and the directory to save the note.
  4. Finalize the capture note with a keybinding.

Bottom line

I have cobbled all of this together this week, which is probably way to… well cobbled together. It does work wonderfully for me though and I am hoping that it might help others on their Mac.

Feel free to give me some pointers, though!

Also I would like to make it work on my Linux machine soon, however I seam not to get it out of the drawer a lot lately.