Using org-capture to save links to Pinboard

I'm not sure how many people use org-protocol (the Reddit question about a year ago, "Does anyone here still use org-protocol?" garnered several answers in the affirmative), but if you do, I spent some time this weekend (ab)using it to send captured links to Pinboard.

I don't know how useful this is, really; Pinboard already offers bookmarklets that let you save links from your browser. You can modify the Javascript to create bookmarks that will bring up the save dialog with the tags field pre-populated, although I suppose you would still have to hit Enter to complete the process. And what I've hacked together also allows one to capture links to Org mode and send them off to Pinboard at the same time, which I suppose is useful.

I still haven't taken a look at alphapapa's org-web-tools; that could provide something in this area, as well.

What is org-protocol?

org-protocol is part of Org mode; it "intercepts calls from emacsclient to trigger custom actions without external dependencies" (see here). That didn't mean much to me when I first read it, so it was some time before I got around to digging in, reading the code & understanding what it does.

When you invoke emacsclient with a file argument, Emacs ends up in server-visit-files to locate the given file(s), visit them, and locate point in the resulting buffer(s). org-protocol defines a bit of advice before server-visit-files that examines the file argument and, if it begins with "org-protocol://{sub-protocol}", dispatches to a function in a lookup table corresponding to the actual value of "{sub-protocol}".

My first reaction on reading the code was to wonder why anyone would want to do such a thing. After all, one can simply invoke emacsclient with the --eval option to invoke arbitrary Elisp code. The usefulness of this scheme becomes clear when you're in a situation in which you can't execute arbitrary programs, but you can at least associate protocol names with a binary… like, say, a web browser: I can't directly invoke emacsclient --eval '(my-function)' from my browser, but I can tell the browser that to navigate to a link whose protocol is "foo" it should invoke emacsclient with an argument of "org-protocol://foo…".

What is pinboard?

"Pinboard is a bookmarking website for introverted people in a hurry." For those of you old enough to remember del.icio.us, it's a successor to that. I use it quite a bit both to tag & save links for future reference as well as a "read later" list. I've been working on a little command-line client for it, which I call pin. It can do a few things, among them send a link to Pinboard with a given set of tags.

OK– how do they work together?

Sending Captured Links to Pinboard

org-protocol ships with a few sub-protocols, one of which is "capture". When invoked, it will initiate the capture process using a template you specify in an argument in the link, so for instance, a bookmark of:

javascript:location.href='org-protocol://capture?template=r&url='+
encodeURIComponent(location.href)+
'&title='+
encodeURIComponent(document.title)+
'&body='+encodeURIComponent(window.getSelection())

will select your capture template keyed by "r", fill out that template with all the usual replacement parameters as well as ":link", ":description" & a few others and drop you into a capture buffer to complete the process (unless you've set :immediate-finish to t in your template, in which case the template will be filed with no user interaction). Here's the full set of replacement parameters org-capture adds:

(org-link-store-props :type type
                          :link url
                          :description title
                          :annotation orglink
                          :initial region
                          :query parts)

You can include arbitrary S-expressions as elements in your capture templates, which is what I leveraged to send the links on to Pinboard in addition to filing them in my Org-mode file. For instance:

("r" "Read later"
       entry (file+olp+datetree "~/notebooks/gtd/journal.org")
       "* [[%:link][%:description]]\n Entered on %U\n%i\n%(sp1ff/org-capture/send-to-pinboard \"%:link\" \"%:description\" nil nil \"@review\")"
       :immediate-finish t)

will create a new sub-heading in today's journal entry with the link & a timestamp. It will also invoke sp1ff/org-capture/send-to-pinboard and substitute the text results in the new entry:

(defun sp1ff/org-capture/send-to-pinboard (link dsc &rest tags)
  "Org Capture template function that sends LINK to Pinboard.

Send LINK to Pinboard with description DSC & tags TAGS.  Return a
string indicating the result."
  (let ((args '("pin" nil t nil "send" "-R")))
    (cl-mapcar
     (lambda (x)
       (setq args (append args (list "-t" x))))
     tags)
    (setq args (append args (list (format "%s | %s" link dsc))))
    (with-current-buffer (get-buffer-create " *pin-output*")
      (if (eq (apply 'call-process args) 0) "Sent to Pinboard" (buffer-string)))))

So, navigating to https://pages.sachachua.com/.emacs.d/Sacha.html & selecting that bookmark will produce an entry like so:

[[https://pages.sachachua.com/.emacs.d/Sacha.html][Sacha Chua's Emacs configuration]]
   Entered on [2020-06-03 Wed 06:45]

  Sent to Pinboard

Sending Links to Directly to Pinboard (no capture)

In addition to capturing links, I wanted to be able to just send them directly to Pinboard. For that, I defined my own org-protocol "sub-protocol". Again, I could just hack up the bookmarklets Pinboard already provides, but my little utility already offers a few advantages (sending the link to Instapaper at the same time, for instance).

To define a new sub-protocol, you just add your sub-protocol's name & handler to org-protocol-protocol-alist:

(add-to-list
 org-protocol-protocol-alist
 ("sp1ff-pin"
         :protocol "pin"
         :function sp1ff/org/pin-handler))

This of course depends on sp1ff/org/pin-handler being defined:

(require 'cl-lib)

(defun sp1ff/org/pin-handler (arg)
  "Handler for the `pin' protocol. ARG shall be a property list
containing the arguments:

    - :url
    - :title
    - :tag: may be present more than once

A property list is presumed to have only one instance of any
given property, but `org-protocol' deserializes new-style links
using the conventional approach to url-encoding multiple values
for the same parameter
(\"foo.com/?param=a&param=b\") as such an \"improper\" property
list."
  (let ((tags (sp1ff/improper-plist-get arg :tag))
        (args '("/home/mgh/bin/pin" nil t nil "send" "-R"))
        (url (format "%s | %s" (plist-get arg :url) (plist-get arg :title))))
    (cl-mapcar (lambda (x) (setq args (append args (list "-t" x)))) tags)
    (setq args (append args (list url)))
    (with-current-buffer (get-buffer-create " *pin-output*")
      (if (eq (apply 'call-process args) 0)
                (message "sent to Pinboard: %s" url)
        (message "when sending to Pinboard: %s" (buffer-string)))))
  ;; Make sure we do not return a string, as `server-visit-files',
  ;; through `server-edit', would interpret it as a file name.
  nil)

Since plist-get will only return the value of the first occurrence of a property, I wrote my own little replacement:

(defun sp1ff/improper-plist-get (plist tag)
  (let ((tags '()))
    (while plist
      (if (eq (car plist) tag)
                (progn
                  (setq plist (cdr plist))
                  (setq tags (append tags (list (car plist))))))
      (setq plist (cdr plist)))
    tags))

Now, when you setup a bookmark like so:

javascript:location.href='org-protocol://pin?url='+
    encodeURIComponent(location.href)+
    '&title='+encodeURIComponent(document.title)+
    '&body='+encodeURIComponent(window.getSelection())+
    '&tag=@review-dev'

and invoke it, the link will be sent directly to Pinboard (all you'll see in your Emacs session is the message "Sent to Pinboard: …").

Conclusion

And that's it. To be honest, I hesitated to even publish this since I'm not sure how useful it is (I'm still experimenting with it myself). What finally decided it for me was the fact that I wound up just reading the code for org-protocol both to understand what it did and to get this little hack up & running. Perhaps it's just me, but I thought that if others found org-protocol similarly opaque this might be useful to them.

06/03/20 17:51