org-medium
org-medium copied to clipboard
Publish your Org-Mode buffer to Medium.com
#+TITLE: org-sendto-medium: publish to Medium from Org-Mode #+AUTHOR: Haoyang Xu
NOTE: Archived due to [[https://github.com/celadevra/org-medium/issues/1#issuecomment-2008944637][Medium killing its API]].
-
Usage
Clone the repo, tangle this file:
#+BEGIN_SRC elisp :tangle no (org-babel-tangle) #+END_SRC
Put generated ~.el~ file in your load-path.
Get an integration token from the bottom of [[https://medium.com/me/settings][your Medium settings page]].
Put the following into your ~.emacs~:
#+BEGIN_SRC elisp :tangle no (require 'org-sendto-medium) (define-key org-mode-map (kbd "C-x C-j") 'org-sendto-medium) #+END_SRC
Press ~M-x customize-group<CR>~, then input 'org-medium'. Put your integration token in the field "Org Medium Integration Token", then apply and save.
Of course, you should use your own token.
In Org-Mode, you can use ~M-x org-sendto-medium~ or ~C-x C-j~ to publish current file to Medium. The post will be licensed with ~org-medium-default-license~. Alternatively, call ~C-u C-x C-j~ to publish, and you will be asked to choose one of the following licenses: "all-rights-reserved" "cc-40-by" "cc-40-by-sa" "cc-40-by-nd" "cc-40-by-nc" "cc-40-by-nc-nd" "cc-40-by-nc-sa" "cc-40-zero" and "public-domain", then one of the following post status (visibility): "public" "draft" and "unlisted".
-
Code :PROPERTIES: :tangle: org-sendto-medium.el :END: ** Preamble The header of the file must conform to the Emacs Lisp library header conventions. See references for more information.
#+BEGIN_SRC elisp ;;; org-sendto-medium.el --- Export articles in Org-Mode to Medium ;; Copyright (C) 2016 Haoyang Xu
;; Author: Haoyang Xu [email protected] ;; Created: 22 Mar 2016 ;; Version: 0.1 ;; Package-Requires: ((org "8.0")(emacs "24.1"))
;; Keywords: comm, processes ;; Homepage: https://github.com/celadevra/org-sendto-medium
;; This file is not part of GNU Emacs.
;;; Commentary:
;; This package provides a function to publish Org-Mode files to Medium.com.
;;; Code: #+END_SRC ** Requirements This package uses ~url.el~ for request/response handling, and ~json.el~ for parsing responses. #+BEGIN_SRC elisp (require 'url) (require 'json) #+END_SRC ** Variables *** API Endpoint Beginning Use a variable to store the beginning of API Endpoint, for ease of upgrade later: #+BEGIN_SRC elisp (defvar org-medium-apibegin "https://api.medium.com/v1" "Beginning of the Medium API endpoints. The part from 'https://' to the end of version indicator.") #+END_SRC
*** Endpoint for getting user ID #+BEGIN_SRC elisp (defvar org-medium-apiuser (concat org-medium-apibegin "/me") "API Endpoint for obtaining user information, such as ID.") #+END_SRC *** Authentication As a desktop application, this package uses Medium's 'self-issued access tokens' to authenticate the user and let Medium API know it is talking to an agent who is on behalf of some user. A variable ~org-medium-integration-token~ is used to store the token.
Save token in a variable:
#+BEGIN_SRC elisp
(defcustom org-medium-integration-token nil
"Self-issued token for authentication with Medium. You can generate yours at https://medium.com/me/settings"
:group 'org-medium
:type 'string)
#+END_SRC
With correct integration token, Medium's user information API endpoint will send us a series of data, we are storing the "id" data in this variable:
#+BEGIN_SRC elisp
(defvar org-medium-author-id nil
"Author id returned by Medium API given correct token.")
#+END_SRC
*** Default license When publishing on Medium, you can reserve all the rights, or choose from several Creative Commons license. By default, it's "All Rights Reserved". This variable allow you customize the default license you want to use for your posts. #+BEGIN_SRC elisp (defcustom org-medium-default-license "all-rights-reserved" "Default license for posts published." :group 'org-medium :type '(choice (string :tag "all-rights-reserved" "all-rights-reserved") (string :tag "cc-40-by" "cc-40-by") (string :tag "cc-40-by-sa" "cc-40-by-sa") (string :tag "cc-40-by-nd" "cc-40-by-nd") (string :tag "cc-40-by-nc" "cc-40-by-nc") (string :tag "cc-40-by-nc-nd" "cc-40-by-nc-nd") (string :tag "cc-40-by-nc-sa" "cc-40-by-nc-sa") (string :tag "cc-40-zero" "cc-40-zero") (string :tag "public-domain" "public-domain"))) #+END_SRC *** Publish status In some cases, I may want to publish the article as a draft or unlisted on Medium. This customizable variable determines the default behaviour: to publish as public, unlisted, or a draft. #+BEGIN_SRC elisp (defcustom org-medium-default-visibility "public" "Default visibility of posts published. Can be one status among the 3 below: public, unlisted, or draft." :group 'org-medium :type '(choice (string :tag "Public" "public") (string :tag "Unlisted" "unlisted") (string :tag "Draft" "draft"))) #+END_SRC ** Functions *** Test integration token existence, and help user set Before doing anything else, test if the integration token is empty. If empty, prompt user to go to medium.com, get an integration token and set the variable.
#+NAME: org-medium-test-token
#+BEGIN_SRC elisp
(defun org-medium-test-token ()
"Test if the integration token for medium is present. If not, ask the user to get one and open the url for user."
(if (or (not org-medium-integration-token) (string= "" org-medium-integration-token))
(progn
(if (y-or-n-p "Your integration token is not set, take you to medium so you can get one? ")
(browse-url-default-browser "https://medium.com/me/settings"))
(generate-new-buffer "*Instructions*")
(switch-to-buffer-other-window "*Instructions*")
(insert "Scroll to the bottom of your Medium settings page, find heading \"integration tokens\".\n
In the text box below, input an identifier such as \"my emacs\", \n
and hit the \"Get integration token\" button, copy the generated\n
token and paste it in the minibuffer.")
(let ((x (read-string "Paste your integration token here: ")))
(customize-save-variable 'org-medium-integration-token (eval x)))
(message "Integration token saved.")
(kill-buffer "*Instructions*"))
(message "Integration token found.")))
#+END_SRC
*** Get author's ID To create a post, one must send a POST request to the API endpoint, part of which is the author's ID.
Get author ID from Medium:
#+NAME: org-medium-get-authorid
#+BEGIN_SRC elisp
(defun org-medium-get-authorid ()
"Obtain author information from Medium and return the id for later use"
(progn
(org-medium-test-token)
(org-medium-me-query)))
#+END_SRC
#+NAME: org-medium-me-query
#+BEGIN_SRC elisp
(defun org-medium-me-query ()
"Query Medium for user information."
(let* ((url-request-method "GET")
(auth-token (concat "Bearer " org-medium-integration-token))
(url-request-extra-headers
`(("Content-Type" . "application/json")
("Accept" . "application/json")
("Authorization" . ,auth-token)
("Accept-Charset" . "utf-8"))))
(url-retrieve org-medium-apiuser 'org-medium-find-id)))
(defun org-medium-find-id (status)
"Parse JSON to extract required data from response."
(if status ;something bad happens on the remote end
(message "Medium returns error %s. Please try later." (car (plist-get status :error)))
(progn
(switch-to-buffer (current-buffer))
(set-window-point (selected-window) (point-min))
(search-forward-regexp "\"id\":\"\\([0-9abcdef]*\\)\"")
(setq org-medium-author-id (current-word))
(kill-buffer))))
#+END_SRC
Things I learned writing these two functions: you can use backquote, instead of quote, to quote a list. In this case, you can use a comma in a backquoted list to force evaluation of lists and variables. Alternatively, you can use ~cons~ to construct an association list, which evaluates the values before creating the key-value pair. You don't always need ~json.el~. You can use search and 'current-word' to extract useful information.
*** Generate data from Org-Mode file The API accepts the following parameters: | Parameter | Type | Required? | |---------------+--------------+-----------| | title | string | y | | contentFormat | string | y | | content | string | y | | tags | string array | n | | canonicalUrl | string | n | | publishStatus | enum | n | | license | enum | n |
Below are some experiment space for optimized output:
#+BEGIN_SRC elisp :tangle no
(org-html-export-as-html nil nil nil t '(:with-toc nil))
#+END_SRC
The above code seems good enough. When running the code, Emacs opens a HTML buffer in another window, the generated HTML only have the ~<body>~ part, so the content part can be generated with this.
Then I can use a function to read the content of the buffer, another to process the content so they become a sane html string, and return the string.
#+NAME: org-medium-process-html
#+BEGIN_SRC elisp
(defun org-medium-get-content (title)
"Get generated html from Org's export buffer."
(save-excursion
(let ((buffer (org-html-export-as-html nil nil nil t '(:with-toc nil))))
(org-medium-process-html buffer title))))
(defun org-medium-process-html (buffer title)
"Sanitize buffer content so they are acceptable by Medium's API.
Only tags such as <h1><h2><blockquote><p><figure><a><hr> and some
emphases are accepted."
(save-excursion
(with-current-buffer buffer
(goto-char (point-min))
(insert (concat "<h1>" title "</h1>"))
(let ((string (buffer-string)))
(replace-regexp-in-string "\\\n" "" string)))))
#+END_SRC
How do I get title?
#+NAME: org-medium-get-title
#+BEGIN_SRC elisp
(defun org-medium-get-title ()
"Get title from the #+TITLE keyword of current document."
(save-excursion
(goto-char (point-min))
(search-forward-regexp "#\\+title:\\ *")
(let ((beg (point))) (end-of-line) (buffer-substring-no-properties beg (point)))))
#+END_SRC
Then we can create the json and post it to Medium:
#+NAME: org-sendto-medium
#+BEGIN_SRC elisp
(defun org-sendto-medium (&optional arg lic visib)
"When called without arguments, publish your post to Medium with default settings.
When called with universal argument, allow interactive selection of license and visibility.
When called with LIC and/or VISIB arguments, send post request with customized arguments to alter publishing behaviour.
Possible LIC values are:
\"all-rights-reserved\"
\"cc-40-by\"
\"cc-40-by-sa\"
\"cc-40-by-nc\"
\"cc-40-by-nd\"
\"cc-40-by-nc-nd\"
\"cc-40-by-nc-sa\"
\"cc-40-zero\"
\"public-domain\"
Possible VISIB values are \"public\" \"draft\" and \"unlisted\".
"
(interactive "P")
(if (not (and org-medium-author-id (org-medium-test-token)))
(setq org-medium-author-id (org-medium-get-authorid)))
(let* ((url-request-method "POST")
(auth-token (concat "Bearer " org-medium-integration-token))
(url-request-extra-headers
`(("Content-Type" . "application/json")
("Accept" . "application/json")
("Authorization" . ,auth-token)
("Accept-Charset" . "utf-8")))
(custom-params (equal arg '(4)))
(title (org-medium-get-title))
(content (org-medium-get-content title))
(content-format "html")
(license (if custom-params (org-medium-show-license-help)
(or lic org-medium-default-license)))
(publish-status (if custom-params (org-medium-show-visibility-help)
(or visib org-medium-default-visibility)))
(url-request-data (json-encode-plist `(:title ,title
:contentFormat ,content-format
:content ,content
:publishStatus ,publish-status
:license ,license)))
(url (concat org-medium-apibegin "/users/" org-medium-author-id "/posts")))
(url-retrieve url (lambda (status) (switch-to-buffer (current-buffer)))))
(kill-buffer (get-buffer "*Licenses*"))
(kill-buffer (get-buffer "*Visibility*")))
#+END_SRC
By default, the above function sends the post with default settings. You can also send in arguments to customize license, visibility, etc.
** Choose license and status during export Maybe I don't need a full-blown interface like ~org-export-dispatch~. For now, I only need to let user choose license and visibility if the function is called with =C-u=.
Need to define two help functions to show user the choices and set values:
#+BEGIN_SRC elisp (defun org-medium-show-license-help () "Helper function, show a buffer with possible licenses, let user choose, and return the license value." () (generate-new-buffer "Licenses") (switch-to-buffer-other-window "Licenses") (insert "Possible choices: All rights reserved [a] CC-BY [b] CC-BY-SA [s] CC-BY-NC [c] CC-BY-ND [d] CC-BY-NC-SA [n] CC-BY-NC-ND [y] CC-0 [0] Public Domain [o] ") (let ((x (read-char-choice "Select a license: " (append "abscdny0o" nil)))) (cond ((equal x 97) "all-rights-reserved") ((equal x 98) "cc-40-by") ((equal x 115) "cc-40-by-sa") ((equal x 99) "cc-40-by-nc") ((equal x 100) "cc-40-by-nd") ((equal x 110) "cc-40-by-nc-sa") ((equal x 121) "cc-40-by-nc-nd") ((equal x 48) "cc-zero") ((equal x 111) "public-domain"))))
(defun org-medium-show-visibility-help ()
"Helper function, show a buffer with possible visibility choices for post, let user choose, and return the visibility value."
()
(generate-new-buffer "*Visibility*")
(switch-to-buffer-other-window "*Visibility*")
(insert "Possible choices:
Public [P]
Draft [D]
Unlisted [U]")
(case (read-char-choice "Select visibility: " (append "PDU" nil))
(80 "published")
(68 "draft")
(85 "unlisted")))
#+END_SRC
#+BEGIN_SRC elisp :tangle no ;; testing (org-sendto-medium '(4) nil nil) #+END_SRC ** Postamble #+BEGIN_SRC elisp (provide 'org-sendto-medium) ;;; org-sendto-medium.el ends here #+END_SRC
-
Ideas/Road Map ** +Handle errors during authentication/publishing+ ** +Allow choosing license during publishing+ ** +Allow publish as draft/unlisted+ ** Allow assigning tags to post ** Allow choosing markdown as an intermediate format ** Allow using curl to talk with Medium in async mode ** Allow user to choose whether publish the whole file or a subtree
-
References
- [[https://medium.com/developers/welcome-to-the-medium-api-3418f956552#.7kpre5bjs][Welcome to the Medium API]]
- [[https://github.com/Medium/medium-api-docs][Medium API Docs]]
- [[https://www.gnu.org/software/emacs/manual/html_node/elisp/Simple-Packages.html][Simple Packages]]
- [[https://www.gnu.org/software/emacs/manual/html_node/elisp/Library-Headers.html#Library-Headers][Conventional Headers for Emacs Libraries]]
- https://github.com/lambtron/medium-cli/blob/master/lib/medium.js#L35-L46 Using integration token
- [[https://medium.com/developers/accepted-markup-for-medium-s-publishing-api-a4367010924e#.5hgquatwe][Accepted markup for Medium’s Publishing API]]