Tutorial Part 1: Creating a Basic Pinboard Workflow

In which we build an Alfred workflow to view recent posts to Pinboard.in.

Note

To use workflows, you must own Alfred’s Powerpack.

Creating a new Workflow

First, create a new, blank workflow in Alfred 3’s Preferences, under the Workflows tab:

_images/screen1_blank_workflow.png

Describing your Workflow

When the info dialog pops up, give your workflow a name, a Bundle Id, and possibly a description.

Important

The Bundle Id is essential: it’s the unique name used by Alfred and Alfred-PyWorkflow internally to identify your workflow. Alfred-PyWorkflow won’t work without it.

You can also drag an image to the icon field to the left to make your workflow pretty (Alfred will use this icon to show your workflow actions in its action list). I grabbed a free Pinboard icon.

_images/screen2_workflow_info1.png

Adding a Script Filter

The next step is to add a Script Filter. Script Filters receive input from Alfred (the query entered by the user) and send results back to Alfred. They should run as quickly as possible because Alfred will try to call the Script Filter for every character typed into its query box:

_images/screen3_add_script_filter.png

And enter the details for this action (the Escaping options don’t matter at the moment because our script currently doesn’t accept a query):

_images/screen5_script_filter_details.png

Choose a Keyword, which you will enter in Alfred to activate your workflow. At the moment, our Script Filter won’t take any arguments, so choose No Argument. The Placeholder Title and Subtext are what Alfred will show when you type the Keyword:

_images/screen4_alfred_list.png

The “Please Wait” Subtext is what is shown when your workflow is working, which in our case means fetching data from pinboard.in.

Very importantly, set the Language to /bin/zsh. The Script field should contain:

python3 pinboard.py

We’re going to create the pinboard.py script in a second. The Escaping options don’t matter for now because our Script Filter doesn’t accept an argument.

Note

You can choose /usr/bin/python3 as the Language and paste your Python code into the Script box, but this isn’t the best idea.

If you do this, you can’t run the script from the Terminal (which can be helpful when developing/debugging), and you can’t as easily use a proper code editor, which makes debugging difficult: Python always tells you which line an error occurred on, but the Script field doesn’t show line numbers, so lots of counting is involved.

Now Alfred has created the workflow, we can open it up and add our script. Right-click on your workflow in the list on the left and choose Show in Finder.

_images/screen6_show_in_finder.png

The directory will show one or two files (depending on whether or not you chose an icon):

_images/screen7_finder.png

At this point, download the latest release of Alfred-PyWorkflow from GitHub, extract it and copy the workflow directory into your workflow’s directory:

_images/screen8_finder_with_workflow.png

Now we can start coding.

Writing your Python script

Using your text editor of choice [1], create a new text file and save it in your workflow directory as pinboard.py (the name we used when setting up the Script Filter).

Add the following code to pinboard.py (be sure to change API_KEY to your pinboard API key. You can find it on the settings/password page):

 1 # encoding: utf-8
 2
 3 import sys
 4 from workflow import Workflow, ICON_WEB, web
 5
 6 API_KEY = 'your-pinboard-api-key'
 7
 8
 9 def main(wf):
10     url = 'https://api.pinboard.in/v1/posts/recent'
11     params = dict(auth_token=API_KEY, count=20, format='json')
12     r = web.get(url, params)
13
14     # throw an error if request failed
15     # Workflow will catch this and show it to the user
16     r.raise_for_status()
17
18     # Parse the JSON returned by pinboard and extract the posts
19     result = r.json()
20     posts = result['posts']
21
22     # Loop through the returned posts and add an item for each to
23     # the list of results for Alfred
24     for post in posts:
25         wf.add_item(title=post['description'],
26                     subtitle=post['href'],
27                     icon=ICON_WEB)
28
29     # Send the results to Alfred as XML
30     wf.send_feedback()
31
32
33 if __name__ == u"__main__":
34     wf = Workflow()
35     sys.exit(wf.run(main))

All being well, our workflow should now work. Fire up Alfred, enter your keyword and hit ENTER. You should see something like this:

_images/screen9_workflow_results.png

If something went wrong (e.g. an incorrect API key, as in the screenshot), you should see an error like this:

_images/screen10_workflow_error.png

If Alfred shows nothing at all, it probably couldn’t run your Python script at all. You’ll have to open the workflow directory in Terminal and run the script by hand to see the error:

python3 pinboard.py

Adding workflow actions

So now we can see a list of recent posts in Alfred, but can’t do anything with them. We’re going to change that and make the items “actionable” (i.e. you can hit ENTER on them and something happens, in this case, the page will be opened in your browser).

Add the highlighted lines (27–28) to your pinboard.py file:

 1 # encoding: utf-8
 2
 3 import sys
 4 from workflow import Workflow, ICON_WEB, web
 5
 6 API_KEY = 'your-pinboard-api-key'
 7
 8
 9 def main(wf):
10     url = 'https://api.pinboard.in/v1/posts/recent'
11     params = dict(auth_token=API_KEY, count=20, format='json')
12     r = web.get(url, params)
13
14     # throw an error if request failed
15     # Workflow will catch this and show it to the user
16     r.raise_for_status()
17
18     # Parse the JSON returned by pinboard and extract the posts
19     result = r.json()
20     posts = result['posts']
21
22     # Loop through the returned posts and add an item for each to
23     # the list of results for Alfred
24     for post in posts:
25         wf.add_item(title=post['description'],
26                     subtitle=post['href'],
27                     arg=post['href'],
28                     valid=True,
29                     icon=ICON_WEB)
30
31     # Send the results to Alfred as XML
32     wf.send_feedback()
33
34
35 if __name__ == u"__main__":
36     wf = Workflow()
37     sys.exit(wf.run(main))

valid=True tells Alfred that the item is actionable and arg is the value it will pass to the next action (in this case a URL).

Go back to Alfred’s Preferences and add an Open URL action:

_images/screen11_add_open_url.png

Then enter {query} as the URL:

_images/screen12_url_query.png

When you hover your mouse over the Script Filter, you’ll notice a small “nub” appears on the right-hand side:

_images/screen12_5_nub.png

Click and hold on this, and drag a connection to the Open URL action:

_images/screen13_connection.png

Now run your workflow again in Alfred, select one of the results and hit ENTER. The post’s webpage should open in your default browser.

Improving performance and not getting banned

The terms of use of the Pinboard API specifically limit calls to the recent posts method to 1 call/minute. As it’s likely you’ll call your workflow more often than that, we need to cache the results from the API and use the cached data for at least a minute. Alfred-PyWorkflow makes this a doddle with its cached_data() method.

Go back to pinboard.py and make the following changes:

 1 # encoding: utf-8
 2
 3 import sys
 4 from workflow import Workflow, ICON_WEB, web
 5
 6 API_KEY = 'your-pinboard-api-key'
 7
 8
 9 def get_recent_posts():
10     """Retrieve recent posts from Pinboard.in
11
12     Returns a list of post dictionaries.
13
14     """
15     url = 'https://api.pinboard.in/v1/posts/recent'
16     params = dict(auth_token=API_KEY, count=20, format='json')
17     r = web.get(url, params)
18
19     # throw an error if request failed
20     # Workflow will catch this and show it to the user
21     r.raise_for_status()
22
23     # Parse the JSON returned by pinboard and extract the posts
24     result = r.json()
25     posts = result['posts']
26     return posts
27
28
29 def main(wf):
30
31     # Retrieve posts from cache if available and no more than 60
32     # seconds old
33     posts = wf.cached_data('posts', get_recent_posts, max_age=60)
34
35     # Loop through the returned posts and add an item for each to
36     # the list of results for Alfred
37     for post in posts:
38         wf.add_item(title=post['description'],
39                     subtitle=post['href'],
40                     arg=post['href'],
41                     valid=True,
42                     icon=ICON_WEB)
43
44     # Send the results to Alfred as XML
45     wf.send_feedback()

We’ve moved the code that retrieves the data from the API to a separate function (get_recent_posts(), line 9) and instead we ask Workflow.cached_data() (line 33) for the data cached under the name posts (the first argument). cached_data() will first check its cache for data saved under posts and return those data if they’re less than max_age seconds old. If the data are older or don’t exist, it will call the get_recent_posts() function passed as the second parameter, cache the data returned by that function under the name posts and return it.

So now we won’t get banned by Pinboard for hammering the API, and as a bonus, the workflow is now blazingly fast when the data are in its cache. For this reason, it’s probably a good idea to increase max_age to 300 or 600 seconds (5 or 10 minutes) or even more—depending on how often you add new posts to Pinboard—to get super-fast results more often.

Making the posts searchable

What if you’re looking for a specific post? Who’s got time to scroll through a list of 20 results? Let’s make them searchable.

First, update the Script Filter settings. Next to Keyword, change No Argument to Argument Optional and select with space. with space means that when you hit ENTER or TAB on your workflow action, Alfred will add a space after it, so you can start typing your query immediately. Then add "{query}" in the Script text field. {query} will be replaced by Alfred with whatever you’ve typed after the keyword. Finally, set the Escaping options to:

  • Backquotes

  • Double Quotes

  • Dollars

  • Backslashes

and nothing else. This ensures that the query reaches your Python script unmolested by zsh. Your Script Filter settings should now look like this:

_images/screen14_script_filter_details.png

First, we’ll set the script to get 100 recent posts from Pinboard (the maximum allowed) in line 16 and to cache them for 10 minutes in line 33 (or use 300 seconds for 5 minutes if you’re a heavy Pinboardista):

 1 # encoding: utf-8
 2
 3 import sys
 4 from workflow import Workflow, ICON_WEB, web
 5
 6 API_KEY = 'your-pinboard-api-key'
 7
 8
 9 def get_recent_posts():
10     """Retrieve recent posts from Pinboard.in
11
12     Returns a list of post dictionaries.
13
14     """
15     url = 'https://api.pinboard.in/v1/posts/recent'
16     params = dict(auth_token=API_KEY, count=100, format='json')
17     r = web.get(url, params)
18
19     # throw an error if request failed
20     # Workflow will catch this and show it to the user
21     r.raise_for_status()
22
23     # Parse the JSON returned by pinboard and extract the posts
24     result = r.json()
25     posts = result['posts']
26     return posts
27
28
29 def main(wf):
30
31     # Retrieve posts from cache if available and no more than 600
32     # seconds old
33     posts = wf.cached_data('posts', get_recent_posts, max_age=600)
34
35     # Loop through the returned posts and add an item for each to
36     # the list of results for Alfred
37     for post in posts:
38         wf.add_item(title=post['description'],
39                     subtitle=post['href'],
40                     arg=post['href'],
41                     valid=True,
42                     icon=ICON_WEB)
43
44     # Send the results to Alfred as XML
45     wf.send_feedback()
46
47
48 if __name__ == u"__main__":
49     wf = Workflow()
50     sys.exit(wf.run(main))

Then we need to add the ability to receive the query from Alfred and filter our posts based on it:

 1 # encoding: utf-8
 2
 3 import sys
 4 from workflow import Workflow, ICON_WEB, web
 5
 6 API_KEY = 'your-pinboard-api-key'
 7
 8
 9 def get_recent_posts():
10     """Retrieve recent posts from Pinboard.in
11
12     Returns a list of post dictionaries.
13
14     """
15     url = 'https://api.pinboard.in/v1/posts/recent'
16     params = dict(auth_token=API_KEY, count=100, format='json')
17     r = web.get(url, params)
18
19     # throw an error if request failed
20     # Workflow will catch this and show it to the user
21     r.raise_for_status()
22
23     # Parse the JSON returned by pinboard and extract the posts
24     result = r.json()
25     posts = result['posts']
26     return posts
27
28
29 def search_key_for_post(post):
30     """Generate a string search key for a post"""
31     elements = []
32     elements.append(post['description'])  # title of post
33     elements.append(post['tags'])  # post tags
34     elements.append(post['extended'])  # description
35     return u' '.join(elements)
36
37
38 def main(wf):
39
40     # Get query from Alfred
41     if len(wf.args):
42         query = wf.args[0]
43     else:
44         query = None
45
46     # Retrieve posts from cache if available and no more than 600
47     # seconds old
48     posts = wf.cached_data('posts', get_recent_posts, max_age=600)
49
50     # If script was passed a query, use it to filter posts
51     if query:
52         posts = wf.filter(query, posts, key=search_key_for_post)
53
54     # Loop through the returned posts and add an item for each to
55     # the list of results for Alfred
56     for post in posts:
57         wf.add_item(title=post['description'],
58                     subtitle=post['href'],
59                     arg=post['href'],
60                     valid=True,
61                     icon=ICON_WEB)
62
63     # Send the results to Alfred as XML
64     wf.send_feedback()
65
66
67 if __name__ == u"__main__":
68     wf = Workflow()
69     sys.exit(wf.run(main))

Looking at main() first, we add a query variable (lines 40–44). Because our Script Filter can run with or without an argument, we test to see if any were passed to the script using via args attribute of Workflow, and grab the first one if there were (this will be the contents of {query} from the Script Filter).

Using args is similar to accessing sys.argv[1:] directly, but additionally decodes the arguments to Unicode and normalizes them. It also enables “Magic” arguments.

After getting all the posts from the cache or Pinboard, we then filter them using the Workflow.filter() method if there is a query (lines 51–52).

Workflow.filter() implements an Alfred-like search algorithm (e.g. “am” will match “Activity Monitor” as well as “I Am Legend”), but it needs a string to search. Therefore, we write the search_key_for_post() (line 29) function that will build a searchable string for each post, comprising its title, tags and description (in that order).

Important

In the last line of search_key_for_post(), we join the elements with u' ' (a Unicode space), not ' ' (a byte-string space). The web.Response.json() method returns Unicode (as do most Alfred-PyWorkflow methods and functions), and mixing Unicode and byte-strings will cause a fatal error if the byte-string contains non-ASCII characters. In this particular situation, using a byte-string space wouldn’t cause any problems (a space is ASCII), but avoiding mixing byte-strings and Unicode is a very good habit to get into.

When coding in Python 2, you have to be aware of which strings are Unicode and which are encoded (byte) strings. Best practice is to use Unicode internally and decode all text to Unicode when it arrives in your workflow (from the Web, system etc.).

Alfred-PyWorkflow’s APIs use Unicode and it works hard to hide as much of the complexity of working with byte-strings and Unicode as possible, but you still need to manually decode encoded byte-strings from other sources with Workflow.decode() to avoid fatal encoding errors.

See Encoded bytest and strings in the User Guide for more information on dealing with encoded (byte) strings and Unicode in workflows.

Improving the search results

If you’ve been trying out the workflow, you’ve probably noticed that your queries match a lot of posts they really shouldn’t. The reason for this is that, by default, Workflow.filter() matches anything that contains all the characters of query in the same order, regardless of case. To fix this, we’ll add a min_score argument to Workflow.filter(). Change the line:

posts = wf.filter(query, posts, key=search_key_for_post)

to:

posts = wf.filter(query, posts, key=search_key_for_post, min_score=20)

and try the workflow again. The junk results should be gone. You can adjust min_score up or down depending on how strict you want to be with the results.

What now?

So we’ve got a working workflow, but it’s not yet ready to be distributed to other users (we can’t reasonably ask users to edit the code to enter their API key, especially as they’d have to do it again after updating the workflow to a new version). We’ll turn what we’ve got into a distribution-ready workflow in the second part of the tutorial.

Further reading

For more information about writing Alfred workflows, try the following:

To learn more about coding in Python, try these resources: