Make your own Read Article Later menu bar
Add a hotkey that lets you save interesting articles to read later, with Dropbox sync between computers.
Next ChapterAPIs used today | Description |
hs.alert | Draws an alert message on the screen. |
hs.application | Query and interact with running applications. |
hs.fnutils | A collection of functional programming utility functions. |
hs.json | Functions for decoding and encoding JSON. |
hs.menubar | Create and manage system menubar icons, as well as interactive submenus. |
hs.osascript | Execute any AppleScript with this library. |
hs.pathwatcher | Watches a file or directory. When anything changes, it calls a callback function you supply. |
hs.task | An async (non-blocking) library for running shell commands. |
What you’re building #
In this chapter, you’re going to create a hotkey that saves the URL and title of your current Chrome tab to a list to read later. You’re also going to build a system menubar that shows all the articles you’ve saved, along with some basic controls to open and delete articles, as well as pick an article at random to read. Finally, you’ll save your read article queue to a file on your hard drive, and optionally choose to sync it to Dropbox so you can share your articles across multiple computers. Fancy!

Create your project files #
Create a directory to hold your new files in, and create an init.lua
file inside it:

mkdir -p ~/.hammerspoon/read-later
touch ~/.hammerspoon/read-later/init.lua
Next, require the new folder in your main config:

require("read-later")
Add a hotkey to save an article #
Next, you need a way to save articles for later, so you can render them in your menubar.
First, add a new ReadLater.articles
table to hold your list of articles to read:

ReadLater = {}
ReadLater.articles = {}
ReadLater.menu = hs.menubar.new()
ReadLater.menu:setIcon(hs.image.imageFromPath(os.getenv('HOME') .. '/.hammerspoon/read-later/book.png'))
Next, you’ll create a function to save the current Chrome tab to the articles
table. At the end of this snippet, you’ll bind that function to the Super + S hotkey.
Add the code in green to your config file:

ReadLater = {}
ReadLater.menu = hs.menubar.new()
ReadLater.menu:setIcon(hs.image.imageFromPath(os.getenv('HOME') .. '/.hammerspoon/read-later/book.png'))
updateMenu = function()
local items = {
{
title = "ReadLater",
disabled = true
},
}
ReadLater.menu:setMenu(items)
end
-- Get the URL and <title> of the current Chrome tab, and return it as
--
-- {
-- url = "https://...",
-- title = "An interesting article",
-- }
--
-- Returns `nil` if there are no open Chrome tabs.
local function getCurrentArticle()
if not hs.application.find('Google Chrome') then
-- Chrome isn't running right now.
return nil
end
-- Get the URL of the current tab
local _, url = hs.osascript.applescript(
[[
tell application "Google Chrome"
get URL of active tab of first window
end tell
]]
)
-- Get the <title> of the current tab.
local _, title = hs.osascript.applescript(
[[
tell application "Google Chrome"
get title of active tab of first window
end tell
]]
)
-- Remove trailing garbage from window title
title = string.gsub(title, "- - Google Chrome.*", "")
return {
url = url,
title = title,
}
end
saveCurrentTabArticle = function()
article = getCurrentArticle()
if not article then
return
end
-- Save the article
table.insert(ReadLater.articles, article)
-- Refresh the menubar since we have a new article
updateMenu()
hs.alert("Saved " .. article.title)
end
superKey:bind('s'):toFunction('Read later', saveCurrentTabArticle)
----
updateMenu()
Reload Hammerspoon, navigate to any website in Chrome, and press your new Super + S hotkey. You should see a popup that says “Saved
Save the articles to disk #
By now you’ve probably noticed that every time you quit and reopen Hammerspoon, the table of articles
resets to being empty. To fix this, you have to make sure that every time you add or remove an article, you save the articles
table to disk somewhere.
You can use Hammerspoon’s hs.json
functions to convert the table to and from JSON, and Lua’s core io.open
function to read/write your articles file.
You’ll write a function to write the articles
table to disk, and call it every time you modify your articles
table. You’ll also write a function to read the article JSON from disk and parse it once on startup, so that you don’t lose your articles between Hammerspoon restarts.
Add the two sync functions highlighted in green to your config:

ReadLater.menu = hs.menubar.new()
ReadLater.menu:setIcon(hs.image.imageFromPath(os.getenv('HOME') .. '/.hammerspoon/read-later/book.png'))
local saveCurrentTabArticle = nil
local updateMenu = nil
--------- sync functions
-- Where do you want to persist your articles to disk?
--
-- If you use Dropbox and save it to your ~/Dropbox folder, it will work across
-- multiple computers. Otherwise, you can choose a different path.
ReadLater.jsonSyncPath = os.getenv('HOME') .. "/Dropbox/read-later.json"
local function readArticlesFromDisk()
local file = io.open(ReadLater.jsonSyncPath, 'r')
if file then
local contents = file:read("*all")
file:close()
ReadLater.articles = hs.json.decode(contents) or {}
updateMenu()
end
end
local function writeArticlesToDisk()
hs.json.write(ReadLater.articles, ReadLater.jsonSyncPath, true, true)
end
Sync to disk when an article is added: #
Next, make sure you sync the articles
table to disk every time a new article is added, by making the edits in green:

saveCurrentTabArticle = function()
article = getCurrentArticle()
if not article then
return
end
-- Save the article
table.insert(ReadLater.articles, article)
-- Refresh the menubar since we have a new article
updateMenu()
-- Sync to disk
writeArticlesToDisk()
hs.alert("Saved " .. article.title)
end
Sync to disk when an article is removed: #
You also need to make sure you sync the articles
table to disk every time a new article is removed, by making the edits in green:

local function removeArticle(article)
ReadLater.articles = hs.fnutils.filter(ReadLater.articles, function(savedArticle)
return savedArticle.url ~= article.url
end)
updateMenu()
writeArticlesToDisk()
end
Read from disk on startup #
Now that you’re correctly writing to disk, the last step is to read from disk on every startup, so you can pick back up every time with the latest version of articles
. Add this line at the very bottom of the config file:

updateMenu()
readArticlesFromDisk()
Give it a test:
- Add some articles to your list.
- Quit Hammerspoon and reopen it.
The articles should still be there when you reload!
Optional: Watch the JSON file for external changes and reload #
If you’re using Dropbox sync, and you want your computer to automatically reload the JSON file when another computer writes to it, you can create a new file watcher to watch for changes to the JSON file. This will keep your computers in sync no matter what!
Add the snippet in green to the bottom of your config:

updateMenu()
readArticlesFromDisk()
jsonWatcher = hs.pathwatcher.new(ReadLater.jsonSyncPath, function(paths, flags)
if hs.fnutils.contains(paths, ReadLater.jsonSyncPath) then
readArticlesFromDisk()
end
end)
jsonWatcher:start()
If you have two computers running this Hammerspoon code, try it out by adding an article on one computer. You should see it show up on the second one!