Browse Source

添加 Alfred 支持。

oldj 8 years ago
parent
commit
bce15c1648

+ 1 - 1
app/bundle.js

@@ -16663,7 +16663,7 @@ module.exports = "data:application/vnd.ms-fontobject;base64,pj8AAIw+AAABAAIAAAAA
 "use strict";
 
 
-exports.version = [3, 3, 0, 5056];
+exports.version = [3, 3, 0, 5058];
 
 /***/ }),
 /* 71 */

+ 3 - 0
app/main.js

@@ -19,6 +19,7 @@ global.user_language = user_language
 
 const tray = require('./menu/tray')
 const SHServer = require('./server/Server')
+const svr = require('./server/svr')
 
 // Keep a global reference of the window object, if you don't, the window will
 // be closed automatically when the JavaScript object is garbage collected.
@@ -81,6 +82,8 @@ function createWindow () {
   })
 
   //require('./bg/events').init(app, contents)
+
+  svr.win = mainWindow
 }
 
 const should_quit = app.makeSingleInstance((commandLine, workingDirectory) => {

+ 44 - 0
app/server/http/api/index.js

@@ -0,0 +1,44 @@
+'use strict'
+
+const express = require('express')
+const router = express.Router()
+const paths = require('../../paths')
+const getUserHosts = require('../../actions/getUserHosts')
+const saveHosts = require('../../actions/saveHosts')
+const svr = require('../../svr')
+
+router.get('/list', (req, res) => {
+  getUserHosts()
+    .then(list => {
+      let data = {
+        success: true,
+        data: list
+      }
+      res.end(JSON.stringify(data))
+    })
+    .catch(e => {
+      res.end(e.toString())
+    })
+})
+
+router.get('/toggle', (req, res) => {
+  let id = req.param('id')
+
+  getUserHosts()
+    .then(list => {
+      let item = list.find(i => i.id === id)
+      if (!item) {
+        res.end('not-found:' + id)
+        return
+      }
+
+      item.on = !item.on
+      saveHosts(svr, list)
+        .then(() => {
+          svr.broadcast('reload')
+          res.end('toggle:' + id)
+        })
+    })
+})
+
+module.exports = router

+ 2 - 0
app/server/http/app.js

@@ -22,6 +22,8 @@ app.get('/remote-test', function (req, res) {
   res.send(`# remote-test\n# ${(new Date()).toString()}`)
 })
 
+app.use('/api', require('./api/index'))
+
 app.listen(PORT, function () {
   console.log(`SwitchHosts! HTTP server listening on port ${PORT}!`)
 })

+ 5 - 0
app/server/sudo.js

@@ -32,6 +32,11 @@ module.exports = svr => {
 
   return new Promise((resolve, reject) => {
     svr.broadcast('sudo_prompt')
+    try {
+      svr.win.show()
+    } catch (e) {
+      console.log(e)
+    }
 
     _resolve = resolve
     _reject = reject

+ 1 - 1
app/version.js

@@ -1 +1 @@
-exports.version = [3,3,0,5056];
+exports.version = [3,3,0,5058];

+ 42 - 0
scripts/alfred.py

@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+
+import sys
+# the workflow package below is download from:
+# https://github.com/deanishe/alfred-workflow/releases
+from workflow import Workflow, ICON_WEB, web
+
+
+def get_subtitle(item):
+    content = item.get('content', '')
+    return content.partition('\n')[0].strip()
+
+
+def main(wf):
+    url = 'http://127.0.0.1:50761/api/list'
+    r = web.get(url)
+
+    # throw an error if request failed
+    # Workflow will catch this and show it to the user
+    r.raise_for_status()
+
+    # Parse the JSON returned by pinboard and extract the posts
+    result = r.json()
+    items = result['data']
+
+    # Loop through the returned posts and add an item for each to
+    # the list of results for Alfred
+    for item in items:
+        on = item.get('on', False)
+        wf.add_item(title=item['title'],
+                    subtitle=get_subtitle(item),
+                    arg=item['id'],
+                    valid=True,
+                    icon='on.png' if on else 'off.png')
+
+    # Send the results to Alfred as XML
+    wf.send_feedback()
+
+
+if __name__ == '__main__':
+    my_wf = Workflow()
+    sys.exit(my_wf.run(main))

BIN
scripts/icon.png


+ 124 - 0
scripts/info.plist

@@ -0,0 +1,124 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>bundleid</key>
+	<string>switchhosts.oldj.net</string>
+	<key>category</key>
+	<string>Productivity</string>
+	<key>connections</key>
+	<dict>
+		<key>E4D66445-FD72-47A2-9EE6-7232A2BADE29</key>
+		<array>
+			<dict>
+				<key>destinationuid</key>
+				<string>78D17FD5-9628-4901-A01A-511528D5FC14</string>
+				<key>modifiers</key>
+				<integer>0</integer>
+				<key>modifiersubtext</key>
+				<string></string>
+				<key>vitoclose</key>
+				<false/>
+			</dict>
+		</array>
+	</dict>
+	<key>createdby</key>
+	<string>oldj</string>
+	<key>description</key>
+	<string>SwitchHosts quickly!</string>
+	<key>disabled</key>
+	<false/>
+	<key>name</key>
+	<string>SwitchHosts!</string>
+	<key>objects</key>
+	<array>
+		<dict>
+			<key>config</key>
+			<dict>
+				<key>concurrently</key>
+				<false/>
+				<key>escaping</key>
+				<integer>102</integer>
+				<key>script</key>
+				<string>curl 'http://127.0.0.1:50761/api/toggle?id={query}'</string>
+				<key>scriptargtype</key>
+				<integer>0</integer>
+				<key>scriptfile</key>
+				<string></string>
+				<key>type</key>
+				<integer>0</integer>
+			</dict>
+			<key>type</key>
+			<string>alfred.workflow.action.script</string>
+			<key>uid</key>
+			<string>78D17FD5-9628-4901-A01A-511528D5FC14</string>
+			<key>version</key>
+			<integer>2</integer>
+		</dict>
+		<dict>
+			<key>config</key>
+			<dict>
+				<key>alfredfiltersresults</key>
+				<false/>
+				<key>argumenttype</key>
+				<integer>2</integer>
+				<key>escaping</key>
+				<integer>68</integer>
+				<key>keyword</key>
+				<string>swh</string>
+				<key>queuedelaycustom</key>
+				<integer>3</integer>
+				<key>queuedelayimmediatelyinitially</key>
+				<true/>
+				<key>queuedelaymode</key>
+				<integer>0</integer>
+				<key>queuemode</key>
+				<integer>1</integer>
+				<key>runningsubtext</key>
+				<string>loading...</string>
+				<key>script</key>
+				<string>python alfred.py</string>
+				<key>scriptargtype</key>
+				<integer>1</integer>
+				<key>scriptfile</key>
+				<string></string>
+				<key>subtext</key>
+				<string>Switch hosts quickly!</string>
+				<key>title</key>
+				<string>Show hosts..</string>
+				<key>type</key>
+				<integer>0</integer>
+				<key>withspace</key>
+				<false/>
+			</dict>
+			<key>type</key>
+			<string>alfred.workflow.input.scriptfilter</string>
+			<key>uid</key>
+			<string>E4D66445-FD72-47A2-9EE6-7232A2BADE29</string>
+			<key>version</key>
+			<integer>2</integer>
+		</dict>
+	</array>
+	<key>readme</key>
+	<string></string>
+	<key>uidata</key>
+	<dict>
+		<key>78D17FD5-9628-4901-A01A-511528D5FC14</key>
+		<dict>
+			<key>xpos</key>
+			<integer>340</integer>
+			<key>ypos</key>
+			<integer>30</integer>
+		</dict>
+		<key>E4D66445-FD72-47A2-9EE6-7232A2BADE29</key>
+		<dict>
+			<key>xpos</key>
+			<integer>120</integer>
+			<key>ypos</key>
+			<integer>30</integer>
+		</dict>
+	</dict>
+	<key>webaddress</key>
+	<string>https://oldj.github.io/SwitchHosts/</string>
+</dict>
+</plist>

BIN
scripts/off.png


BIN
scripts/on.png


BIN
scripts/workflow/Notify.tgz


+ 107 - 0
scripts/workflow/__init__.py

@@ -0,0 +1,107 @@
+#!/usr/bin/env python
+# encoding: utf-8
+#
+# Copyright (c) 2014 Dean Jackson <[email protected]>
+#
+# MIT Licence. See http://opensource.org/licenses/MIT
+#
+# Created on 2014-02-15
+#
+
+"""A helper library for `Alfred <http://www.alfredapp.com/>`_ workflows."""
+
+import os
+
+# Workflow objects
+from .workflow import Workflow, manager
+from .workflow3 import Workflow3
+
+# Exceptions
+from .workflow import PasswordNotFound, KeychainError
+
+# Icons
+from .workflow import (
+    ICON_ACCOUNT,
+    ICON_BURN,
+    ICON_CLOCK,
+    ICON_COLOR,
+    ICON_COLOUR,
+    ICON_EJECT,
+    ICON_ERROR,
+    ICON_FAVORITE,
+    ICON_FAVOURITE,
+    ICON_GROUP,
+    ICON_HELP,
+    ICON_HOME,
+    ICON_INFO,
+    ICON_NETWORK,
+    ICON_NOTE,
+    ICON_SETTINGS,
+    ICON_SWIRL,
+    ICON_SWITCH,
+    ICON_SYNC,
+    ICON_TRASH,
+    ICON_USER,
+    ICON_WARNING,
+    ICON_WEB,
+)
+
+# Filter matching rules
+from .workflow import (
+    MATCH_ALL,
+    MATCH_ALLCHARS,
+    MATCH_ATOM,
+    MATCH_CAPITALS,
+    MATCH_INITIALS,
+    MATCH_INITIALS_CONTAIN,
+    MATCH_INITIALS_STARTSWITH,
+    MATCH_STARTSWITH,
+    MATCH_SUBSTRING,
+)
+
+
+__title__ = 'Alfred-Workflow'
+__version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read()
+__author__ = 'Dean Jackson'
+__licence__ = 'MIT'
+__copyright__ = 'Copyright 2014 Dean Jackson'
+
+__all__ = [
+    'Workflow',
+    'Workflow3',
+    'manager',
+    'PasswordNotFound',
+    'KeychainError',
+    'ICON_ACCOUNT',
+    'ICON_BURN',
+    'ICON_CLOCK',
+    'ICON_COLOR',
+    'ICON_COLOUR',
+    'ICON_EJECT',
+    'ICON_ERROR',
+    'ICON_FAVORITE',
+    'ICON_FAVOURITE',
+    'ICON_GROUP',
+    'ICON_HELP',
+    'ICON_HOME',
+    'ICON_INFO',
+    'ICON_NETWORK',
+    'ICON_NOTE',
+    'ICON_SETTINGS',
+    'ICON_SWIRL',
+    'ICON_SWITCH',
+    'ICON_SYNC',
+    'ICON_TRASH',
+    'ICON_USER',
+    'ICON_WARNING',
+    'ICON_WEB',
+    'MATCH_ALL',
+    'MATCH_ALLCHARS',
+    'MATCH_ATOM',
+    'MATCH_CAPITALS',
+    'MATCH_INITIALS',
+    'MATCH_INITIALS_CONTAIN',
+    'MATCH_INITIALS_STARTSWITH',
+    'MATCH_STARTSWITH',
+    'MATCH_SUBSTRING',
+]

+ 242 - 0
scripts/workflow/background.py

@@ -0,0 +1,242 @@
+#!/usr/bin/env python
+# encoding: utf-8
+#
+# Copyright (c) 2014 [email protected]
+#
+# MIT Licence. See http://opensource.org/licenses/MIT
+#
+# Created on 2014-04-06
+#
+
+"""Run background tasks."""
+
+from __future__ import print_function, unicode_literals
+
+import sys
+import os
+import subprocess
+import pickle
+
+from workflow import Workflow
+
+__all__ = ['is_running', 'run_in_background']
+
+_wf = None
+
+
+def wf():
+    global _wf
+    if _wf is None:
+        _wf = Workflow()
+    return _wf
+
+
+def _arg_cache(name):
+    """Return path to pickle cache file for arguments.
+
+    :param name: name of task
+    :type name: ``unicode``
+    :returns: Path to cache file
+    :rtype: ``unicode`` filepath
+
+    """
+    return wf().cachefile('{0}.argcache'.format(name))
+
+
+def _pid_file(name):
+    """Return path to PID file for ``name``.
+
+    :param name: name of task
+    :type name: ``unicode``
+    :returns: Path to PID file for task
+    :rtype: ``unicode`` filepath
+
+    """
+    return wf().cachefile('{0}.pid'.format(name))
+
+
+def _process_exists(pid):
+    """Check if a process with PID ``pid`` exists.
+
+    :param pid: PID to check
+    :type pid: ``int``
+    :returns: ``True`` if process exists, else ``False``
+    :rtype: ``Boolean``
+
+    """
+    try:
+        os.kill(pid, 0)
+    except OSError:  # not running
+        return False
+    return True
+
+
+def is_running(name):
+    """Test whether task is running under ``name``.
+
+    :param name: name of task
+    :type name: ``unicode``
+    :returns: ``True`` if task with name ``name`` is running, else ``False``
+    :rtype: ``Boolean``
+
+    """
+    pidfile = _pid_file(name)
+    if not os.path.exists(pidfile):
+        return False
+
+    with open(pidfile, 'rb') as file_obj:
+        pid = int(file_obj.read().strip())
+
+    if _process_exists(pid):
+        return True
+
+    elif os.path.exists(pidfile):
+        os.unlink(pidfile)
+
+    return False
+
+
+def _background(stdin='/dev/null', stdout='/dev/null',
+                stderr='/dev/null'):  # pragma: no cover
+    """Fork the current process into a background daemon.
+
+    :param stdin: where to read input
+    :type stdin: filepath
+    :param stdout: where to write stdout output
+    :type stdout: filepath
+    :param stderr: where to write stderr output
+    :type stderr: filepath
+
+    """
+    def _fork_and_exit_parent(errmsg):
+        try:
+            pid = os.fork()
+            if pid > 0:
+                os._exit(0)
+        except OSError as err:
+            wf().logger.critical('%s: (%d) %s', errmsg, err.errno,
+                                 err.strerror)
+            raise err
+
+    # Do first fork.
+    _fork_and_exit_parent('fork #1 failed')
+
+    # Decouple from parent environment.
+    os.chdir(wf().workflowdir)
+    os.setsid()
+
+    # Do second fork.
+    _fork_and_exit_parent('fork #2 failed')
+
+    # Now I am a daemon!
+    # Redirect standard file descriptors.
+    si = open(stdin, 'r', 0)
+    so = open(stdout, 'a+', 0)
+    se = open(stderr, 'a+', 0)
+    if hasattr(sys.stdin, 'fileno'):
+        os.dup2(si.fileno(), sys.stdin.fileno())
+    if hasattr(sys.stdout, 'fileno'):
+        os.dup2(so.fileno(), sys.stdout.fileno())
+    if hasattr(sys.stderr, 'fileno'):
+        os.dup2(se.fileno(), sys.stderr.fileno())
+
+
+def run_in_background(name, args, **kwargs):
+    r"""Cache arguments then call this script again via :func:`subprocess.call`.
+
+    :param name: name of task
+    :type name: ``unicode``
+    :param args: arguments passed as first argument to :func:`subprocess.call`
+    :param \**kwargs: keyword arguments to :func:`subprocess.call`
+    :returns: exit code of sub-process
+    :rtype: ``int``
+
+    When you call this function, it caches its arguments and then calls
+    ``background.py`` in a subprocess. The Python subprocess will load the
+    cached arguments, fork into the background, and then run the command you
+    specified.
+
+    This function will return as soon as the ``background.py`` subprocess has
+    forked, returning the exit code of *that* process (i.e. not of the command
+    you're trying to run).
+
+    If that process fails, an error will be written to the log file.
+
+    If a process is already running under the same name, this function will
+    return immediately and will not run the specified command.
+
+    """
+    if is_running(name):
+        wf().logger.info('Task `{0}` is already running'.format(name))
+        return
+
+    argcache = _arg_cache(name)
+
+    # Cache arguments
+    with open(argcache, 'wb') as file_obj:
+        pickle.dump({'args': args, 'kwargs': kwargs}, file_obj)
+        wf().logger.debug('Command arguments cached to `{0}`'.format(argcache))
+
+    # Call this script
+    cmd = ['/usr/bin/python', __file__, name]
+    wf().logger.debug('Calling {0!r} ...'.format(cmd))
+    retcode = subprocess.call(cmd)
+    if retcode:  # pragma: no cover
+        wf().logger.error('Failed to call task in background')
+    else:
+        wf().logger.debug('Executing task `{0}` in background...'.format(name))
+    return retcode
+
+
+def main(wf):  # pragma: no cover
+    """Run command in a background process.
+
+    Load cached arguments, fork into background, then call
+    :meth:`subprocess.call` with cached arguments.
+
+    """
+    name = wf.args[0]
+    argcache = _arg_cache(name)
+    if not os.path.exists(argcache):
+        wf.logger.critical('No arg cache found : {0!r}'.format(argcache))
+        return 1
+
+    # Load cached arguments
+    with open(argcache, 'rb') as file_obj:
+        data = pickle.load(file_obj)
+
+    # Cached arguments
+    args = data['args']
+    kwargs = data['kwargs']
+
+    # Delete argument cache file
+    os.unlink(argcache)
+
+    pidfile = _pid_file(name)
+
+    # Fork to background
+    _background()
+
+    # Write PID to file
+    with open(pidfile, 'wb') as file_obj:
+        file_obj.write('{0}'.format(os.getpid()))
+
+    # Run the command
+    try:
+        wf.logger.debug('Task `{0}` running'.format(name))
+        wf.logger.debug('cmd : {0!r}'.format(args))
+
+        retcode = subprocess.call(args, **kwargs)
+
+        if retcode:
+            wf.logger.error('Command failed with [{0}] : {1!r}'.format(
+                            retcode, args))
+
+    finally:
+        if os.path.exists(pidfile):
+            os.unlink(pidfile)
+        wf.logger.debug('Task `{0}` finished'.format(name))
+
+
+if __name__ == '__main__':  # pragma: no cover
+    wf().run(main)

+ 377 - 0
scripts/workflow/notify.py

@@ -0,0 +1,377 @@
+#!/usr/bin/env python
+# encoding: utf-8
+#
+# Copyright (c) 2015 [email protected]
+#
+# MIT Licence. See http://opensource.org/licenses/MIT
+#
+# Created on 2015-11-26
+#
+
+# TODO: Exclude this module from test and code coverage in py2.6
+
+"""
+Post notifications via the OS X Notification Center. This feature
+is only available on Mountain Lion (10.8) and later. It will
+silently fail on older systems.
+
+The main API is a single function, :func:`~workflow.notify.notify`.
+
+It works by copying a simple application to your workflow's data
+directory. It replaces the application's icon with your workflow's
+icon and then calls the application to post notifications.
+"""
+
+from __future__ import print_function, unicode_literals
+
+import os
+import plistlib
+import shutil
+import subprocess
+import sys
+import tarfile
+import tempfile
+import uuid
+
+import workflow
+
+
+_wf = None
+_log = None
+
+
+#: Available system sounds from System Preferences > Sound > Sound Effects
+SOUNDS = (
+    'Basso',
+    'Blow',
+    'Bottle',
+    'Frog',
+    'Funk',
+    'Glass',
+    'Hero',
+    'Morse',
+    'Ping',
+    'Pop',
+    'Purr',
+    'Sosumi',
+    'Submarine',
+    'Tink',
+)
+
+
+def wf():
+    """Return `Workflow` object for this module.
+
+    Returns:
+        workflow.Workflow: `Workflow` object for current workflow.
+    """
+    global _wf
+    if _wf is None:
+        _wf = workflow.Workflow()
+    return _wf
+
+
+def log():
+    """Return logger for this module.
+
+    Returns:
+        logging.Logger: Logger for this module.
+    """
+    global _log
+    if _log is None:
+        _log = wf().logger
+    return _log
+
+
+def notifier_program():
+    """Return path to notifier applet executable.
+
+    Returns:
+        unicode: Path to Notify.app `applet` executable.
+    """
+    return wf().datafile('Notify.app/Contents/MacOS/applet')
+
+
+def notifier_icon_path():
+    """Return path to icon file in installed Notify.app.
+
+    Returns:
+        unicode: Path to `applet.icns` within the app bundle.
+    """
+    return wf().datafile('Notify.app/Contents/Resources/applet.icns')
+
+
+def install_notifier():
+    """Extract `Notify.app` from the workflow to data directory.
+
+    Changes the bundle ID of the installed app and gives it the
+    workflow's icon.
+    """
+    archive = os.path.join(os.path.dirname(__file__), 'Notify.tgz')
+    destdir = wf().datadir
+    app_path = os.path.join(destdir, 'Notify.app')
+    n = notifier_program()
+    log().debug("Installing Notify.app to %r ...", destdir)
+    # z = zipfile.ZipFile(archive, 'r')
+    # z.extractall(destdir)
+    tgz = tarfile.open(archive, 'r:gz')
+    tgz.extractall(destdir)
+    assert os.path.exists(n), (
+        "Notify.app could not be installed in {0!r}.".format(destdir))
+
+    # Replace applet icon
+    icon = notifier_icon_path()
+    workflow_icon = wf().workflowfile('icon.png')
+    if os.path.exists(icon):
+        os.unlink(icon)
+
+    png_to_icns(workflow_icon, icon)
+
+    # Set file icon
+    # PyObjC isn't available for 2.6, so this is 2.7 only. Actually,
+    # none of this code will "work" on pre-10.8 systems. Let it run
+    # until I figure out a better way of excluding this module
+    # from coverage in py2.6.
+    if sys.version_info >= (2, 7):  # pragma: no cover
+        from AppKit import NSWorkspace, NSImage
+
+        ws = NSWorkspace.sharedWorkspace()
+        img = NSImage.alloc().init()
+        img.initWithContentsOfFile_(icon)
+        ws.setIcon_forFile_options_(img, app_path, 0)
+
+    # Change bundle ID of installed app
+    ip_path = os.path.join(app_path, 'Contents/Info.plist')
+    bundle_id = '{0}.{1}'.format(wf().bundleid, uuid.uuid4().hex)
+    data = plistlib.readPlist(ip_path)
+    log().debug('Changing bundle ID to {0!r}'.format(bundle_id))
+    data['CFBundleIdentifier'] = bundle_id
+    plistlib.writePlist(data, ip_path)
+
+
+def validate_sound(sound):
+    """Coerce `sound` to valid sound name.
+
+    Returns `None` for invalid sounds. Sound names can be found
+    in `System Preferences > Sound > Sound Effects`.
+
+    Args:
+        sound (str): Name of system sound.
+
+    Returns:
+        str: Proper name of sound or `None`.
+    """
+    if not sound:
+        return None
+
+    # Case-insensitive comparison of `sound`
+    if sound.lower() in [s.lower() for s in SOUNDS]:
+        # Title-case is correct for all system sounds as of OS X 10.11
+        return sound.title()
+    return None
+
+
+def notify(title='', text='', sound=None):
+    """Post notification via Notify.app helper.
+
+    Args:
+        title (str, optional): Notification title.
+        text (str, optional): Notification body text.
+        sound (str, optional): Name of sound to play.
+
+    Raises:
+        ValueError: Raised if both `title` and `text` are empty.
+
+    Returns:
+        bool: `True` if notification was posted, else `False`.
+    """
+    if title == text == '':
+        raise ValueError('Empty notification')
+
+    sound = validate_sound(sound) or ''
+
+    n = notifier_program()
+
+    if not os.path.exists(n):
+        install_notifier()
+
+    env = os.environ.copy()
+    enc = 'utf-8'
+    env['NOTIFY_TITLE'] = title.encode(enc)
+    env['NOTIFY_MESSAGE'] =  text.encode(enc)
+    env['NOTIFY_SOUND'] = sound.encode(enc)
+    cmd = [n]
+    retcode = subprocess.call(cmd, env=env)
+    if retcode == 0:
+        return True
+
+    log().error('Notify.app exited with status {0}.'.format(retcode))
+    return False
+
+
+def convert_image(inpath, outpath, size):
+    """Convert an image file using `sips`.
+
+    Args:
+        inpath (str): Path of source file.
+        outpath (str): Path to destination file.
+        size (int): Width and height of destination image in pixels.
+
+    Raises:
+        RuntimeError: Raised if `sips` exits with non-zero status.
+    """
+    cmd = [
+        b'sips',
+        b'-z', b'{0}'.format(size), b'{0}'.format(size),
+        inpath,
+        b'--out', outpath]
+    # log().debug(cmd)
+    with open(os.devnull, 'w') as pipe:
+        retcode = subprocess.call(cmd, stdout=pipe, stderr=subprocess.STDOUT)
+
+    if retcode != 0:
+        raise RuntimeError('sips exited with {0}'.format(retcode))
+
+
+def png_to_icns(png_path, icns_path):
+    """Convert PNG file to ICNS using `iconutil`.
+
+    Create an iconset from the source PNG file. Generate PNG files
+    in each size required by OS X, then call `iconutil` to turn
+    them into a single ICNS file.
+
+    Args:
+        png_path (str): Path to source PNG file.
+        icns_path (str): Path to destination ICNS file.
+
+    Raises:
+        RuntimeError: Raised if `iconutil` or `sips` fail.
+    """
+    tempdir = tempfile.mkdtemp(prefix='aw-', dir=wf().datadir)
+
+    try:
+        iconset = os.path.join(tempdir, 'Icon.iconset')
+
+        assert not os.path.exists(iconset), (
+            "Iconset path already exists : {0!r}".format(iconset))
+        os.makedirs(iconset)
+
+        # Copy source icon to icon set and generate all the other
+        # sizes needed
+        configs = []
+        for i in (16, 32, 128, 256, 512):
+            configs.append(('icon_{0}x{0}.png'.format(i), i))
+            configs.append((('icon_{0}x{0}@2x.png'.format(i), i*2)))
+
+        shutil.copy(png_path, os.path.join(iconset, 'icon_256x256.png'))
+        shutil.copy(png_path, os.path.join(iconset, '[email protected]'))
+
+        for name, size in configs:
+            outpath = os.path.join(iconset, name)
+            if os.path.exists(outpath):
+                continue
+            convert_image(png_path, outpath, size)
+
+        cmd = [
+            b'iconutil',
+            b'-c', b'icns',
+            b'-o', icns_path,
+            iconset]
+
+        retcode = subprocess.call(cmd)
+        if retcode != 0:
+            raise RuntimeError("iconset exited with {0}".format(retcode))
+
+        assert os.path.exists(icns_path), (
+            "Generated ICNS file not found : {0!r}".format(icns_path))
+    finally:
+        try:
+            shutil.rmtree(tempdir)
+        except OSError:  # pragma: no cover
+            pass
+
+
+# def notify_native(title='', text='', sound=''):
+#     """Post notification via the native API (via pyobjc).
+
+#     At least one of `title` or `text` must be specified.
+
+#     This method will *always* show the Python launcher icon (i.e. the
+#     rocket with the snakes on it).
+
+#     Args:
+#         title (str, optional): Notification title.
+#         text (str, optional): Notification body text.
+#         sound (str, optional): Name of sound to play.
+
+#     """
+
+#     if title == text == '':
+#         raise ValueError('Empty notification')
+
+#     import Foundation
+
+#     sound = sound or Foundation.NSUserNotificationDefaultSoundName
+
+#     n = Foundation.NSUserNotification.alloc().init()
+#     n.setTitle_(title)
+#     n.setInformativeText_(text)
+#     n.setSoundName_(sound)
+#     nc = Foundation.NSUserNotificationCenter.defaultUserNotificationCenter()
+#     nc.deliverNotification_(n)
+
+
+if __name__ == '__main__':  # pragma: nocover
+    # Simple command-line script to test module with
+    # This won't work on 2.6, as `argparse` isn't available
+    # by default.
+    import argparse
+
+    from unicodedata import normalize
+
+    def uni(s):
+        """Coerce `s` to normalised Unicode."""
+        ustr = s.decode('utf-8')
+        return normalize('NFD', ustr)
+
+    p = argparse.ArgumentParser()
+    p.add_argument('-p', '--png', help="PNG image to convert to ICNS.")
+    p.add_argument('-l', '--list-sounds', help="Show available sounds.",
+                   action='store_true')
+    p.add_argument('-t', '--title',
+                   help="Notification title.", type=uni,
+                   default='')
+    p.add_argument('-s', '--sound', type=uni,
+                   help="Optional notification sound.", default='')
+    p.add_argument('text', type=uni,
+                   help="Notification body text.", default='', nargs='?')
+    o = p.parse_args()
+
+    # List available sounds
+    if o.list_sounds:
+        for sound in SOUNDS:
+            print(sound)
+        sys.exit(0)
+
+    # Convert PNG to ICNS
+    if o.png:
+        icns = os.path.join(
+            os.path.dirname(o.png),
+            b'{0}{1}'.format(os.path.splitext(os.path.basename(o.png))[0],
+                             '.icns'))
+
+        print('Converting {0!r} to {1!r} ...'.format(o.png, icns),
+              file=sys.stderr)
+
+        assert not os.path.exists(icns), (
+            "Destination file already exists : {0}".format(icns))
+
+        png_to_icns(o.png, icns)
+        sys.exit(0)
+
+    # Post notification
+    if o.title == o.text == '':
+        print('ERROR: Empty notification.', file=sys.stderr)
+        sys.exit(1)
+    else:
+        notify(o.title, o.text, o.sound)

+ 428 - 0
scripts/workflow/update.py

@@ -0,0 +1,428 @@
+#!/usr/bin/env python
+# encoding: utf-8
+#
+# Copyright (c) 2014 Fabio Niephaus <[email protected]>,
+#       Dean Jackson <[email protected]>
+#
+# MIT Licence. See http://opensource.org/licenses/MIT
+#
+# Created on 2014-08-16
+#
+
+"""Self-updating from GitHub.
+
+.. versionadded:: 1.9
+
+.. note::
+
+   This module is not intended to be used directly. Automatic updates
+   are controlled by the ``update_settings`` :class:`dict` passed to
+   :class:`~workflow.workflow.Workflow` objects.
+
+"""
+
+from __future__ import print_function, unicode_literals
+
+import os
+import tempfile
+import re
+import subprocess
+
+import workflow
+import web
+
+# __all__ = []
+
+
+RELEASES_BASE = 'https://api.github.com/repos/{0}/releases'
+
+
+_wf = None
+
+
+def wf():
+    """Lazy `Workflow` object."""
+    global _wf
+    if _wf is None:
+        _wf = workflow.Workflow()
+    return _wf
+
+
+class Version(object):
+    """Mostly semantic versioning.
+
+    The main difference to proper :ref:`semantic versioning <semver>`
+    is that this implementation doesn't require a minor or patch version.
+
+    Version strings may also be prefixed with "v", e.g.:
+
+    >>> v = Version('v1.1.1')
+    >>> v.tuple
+    (1, 1, 1, '')
+
+    >>> v = Version('2.0')
+    >>> v.tuple
+    (2, 0, 0, '')
+
+    >>> Version('3.1-beta').tuple
+    (3, 1, 0, 'beta')
+
+    >>> Version('1.0.1') > Version('0.0.1')
+    True
+    """
+
+    #: Match version and pre-release/build information in version strings
+    match_version = re.compile(r'([0-9\.]+)(.+)?').match
+
+    def __init__(self, vstr):
+        """Create new `Version` object.
+
+        Args:
+            vstr (basestring): Semantic version string.
+        """
+        self.vstr = vstr
+        self.major = 0
+        self.minor = 0
+        self.patch = 0
+        self.suffix = ''
+        self.build = ''
+        self._parse(vstr)
+
+    def _parse(self, vstr):
+        if vstr.startswith('v'):
+            m = self.match_version(vstr[1:])
+        else:
+            m = self.match_version(vstr)
+        if not m:
+            raise ValueError('Invalid version number: {0}'.format(vstr))
+
+        version, suffix = m.groups()
+        parts = self._parse_dotted_string(version)
+        self.major = parts.pop(0)
+        if len(parts):
+            self.minor = parts.pop(0)
+        if len(parts):
+            self.patch = parts.pop(0)
+        if not len(parts) == 0:
+            raise ValueError('Invalid version (too long) : {0}'.format(vstr))
+
+        if suffix:
+            # Build info
+            idx = suffix.find('+')
+            if idx > -1:
+                self.build = suffix[idx+1:]
+                suffix = suffix[:idx]
+            if suffix:
+                if not suffix.startswith('-'):
+                    raise ValueError(
+                        'Invalid suffix : `{0}`. Must start with `-`'.format(
+                            suffix))
+                self.suffix = suffix[1:]
+
+        # wf().logger.debug('version str `{}` -> {}'.format(vstr, repr(self)))
+
+    def _parse_dotted_string(self, s):
+        """Parse string ``s`` into list of ints and strings."""
+        parsed = []
+        parts = s.split('.')
+        for p in parts:
+            if p.isdigit():
+                p = int(p)
+            parsed.append(p)
+        return parsed
+
+    @property
+    def tuple(self):
+        """Version number as a tuple of major, minor, patch, pre-release."""
+        return (self.major, self.minor, self.patch, self.suffix)
+
+    def __lt__(self, other):
+        """Implement comparison."""
+        if not isinstance(other, Version):
+            raise ValueError('Not a Version instance: {0!r}'.format(other))
+        t = self.tuple[:3]
+        o = other.tuple[:3]
+        if t < o:
+            return True
+        if t == o:  # We need to compare suffixes
+            if self.suffix and not other.suffix:
+                return True
+            if other.suffix and not self.suffix:
+                return False
+            return (self._parse_dotted_string(self.suffix) <
+                    self._parse_dotted_string(other.suffix))
+        # t > o
+        return False
+
+    def __eq__(self, other):
+        """Implement comparison."""
+        if not isinstance(other, Version):
+            raise ValueError('Not a Version instance: {0!r}'.format(other))
+        return self.tuple == other.tuple
+
+    def __ne__(self, other):
+        """Implement comparison."""
+        return not self.__eq__(other)
+
+    def __gt__(self, other):
+        """Implement comparison."""
+        if not isinstance(other, Version):
+            raise ValueError('Not a Version instance: {0!r}'.format(other))
+        return other.__lt__(self)
+
+    def __le__(self, other):
+        """Implement comparison."""
+        if not isinstance(other, Version):
+            raise ValueError('Not a Version instance: {0!r}'.format(other))
+        return not other.__lt__(self)
+
+    def __ge__(self, other):
+        """Implement comparison."""
+        return not self.__lt__(other)
+
+    def __str__(self):
+        """Return semantic version string."""
+        vstr = '{0}.{1}.{2}'.format(self.major, self.minor, self.patch)
+        if self.suffix:
+            vstr += '-{0}'.format(self.suffix)
+        if self.build:
+            vstr += '+{0}'.format(self.build)
+        return vstr
+
+    def __repr__(self):
+        """Return 'code' representation of `Version`."""
+        return "Version('{0}')".format(str(self))
+
+
+def download_workflow(url):
+    """Download workflow at ``url`` to a local temporary file.
+
+    :param url: URL to .alfredworkflow file in GitHub repo
+    :returns: path to downloaded file
+
+    """
+    filename = url.split("/")[-1]
+
+    if (not url.endswith('.alfredworkflow') or
+            not filename.endswith('.alfredworkflow')):
+        raise ValueError('Attachment `{0}` not a workflow'.format(filename))
+
+    local_path = os.path.join(tempfile.gettempdir(), filename)
+
+    wf().logger.debug(
+        'Downloading updated workflow from `%s` to `%s` ...', url, local_path)
+
+    response = web.get(url)
+
+    with open(local_path, 'wb') as output:
+        output.write(response.content)
+
+    return local_path
+
+
+def build_api_url(slug):
+    """Generate releases URL from GitHub slug.
+
+    :param slug: Repo name in form ``username/repo``
+    :returns: URL to the API endpoint for the repo's releases
+
+    """
+    if len(slug.split('/')) != 2:
+        raise ValueError('Invalid GitHub slug : {0}'.format(slug))
+
+    return RELEASES_BASE.format(slug)
+
+
+def _validate_release(release):
+    """Return release for running version of Alfred."""
+    alf3 = wf().alfred_version.major == 3
+
+    downloads = {'.alfredworkflow': [], '.alfred3workflow': []}
+    dl_count = 0
+    version = release['tag_name']
+
+    for asset in release.get('assets', []):
+        url = asset.get('browser_download_url')
+        if not url:  # pragma: nocover
+            continue
+
+        ext = os.path.splitext(url)[1].lower()
+        if ext not in downloads:
+            continue
+
+        # Ignore Alfred 3-only files if Alfred 2 is running
+        if ext == '.alfred3workflow' and not alf3:
+            continue
+
+        downloads[ext].append(url)
+        dl_count += 1
+
+        # download_urls.append(url)
+
+    if dl_count == 0:
+        wf().logger.warning(
+            'Invalid release %s : No workflow file', version)
+        return None
+
+    for k in downloads:
+        if len(downloads[k]) > 1:
+            wf().logger.warning(
+                'Invalid release %s : multiple %s files', version, k)
+            return None
+
+    # Prefer .alfred3workflow file if there is one and Alfred 3 is
+    # running.
+    if alf3 and len(downloads['.alfred3workflow']):
+        download_url = downloads['.alfred3workflow'][0]
+
+    else:
+        download_url = downloads['.alfredworkflow'][0]
+
+    wf().logger.debug('Release `%s` : %s', version, download_url)
+
+    return {
+        'version': version,
+        'download_url': download_url,
+        'prerelease': release['prerelease']
+    }
+
+
+def get_valid_releases(github_slug, prereleases=False):
+    """Return list of all valid releases.
+
+    :param github_slug: ``username/repo`` for workflow's GitHub repo
+    :param prereleases: Whether to include pre-releases.
+    :returns: list of dicts. Each :class:`dict` has the form
+        ``{'version': '1.1', 'download_url': 'http://github.com/...',
+        'prerelease': False }``
+
+
+    A valid release is one that contains one ``.alfredworkflow`` file.
+
+    If the GitHub version (i.e. tag) is of the form ``v1.1``, the leading
+    ``v`` will be stripped.
+
+    """
+    api_url = build_api_url(github_slug)
+    releases = []
+
+    wf().logger.debug('Retrieving releases list from `%s` ...', api_url)
+
+    def retrieve_releases():
+        wf().logger.info(
+            'Retrieving releases for `%s` ...', github_slug)
+        return web.get(api_url).json()
+
+    slug = github_slug.replace('/', '-')
+    for release in wf().cached_data('gh-releases-{0}'.format(slug),
+                                    retrieve_releases):
+
+        wf().logger.debug('Release : %r', release)
+
+        release = _validate_release(release)
+        if release is None:
+            wf().logger.debug('Invalid release')
+            continue
+
+        elif release['prerelease'] and not prereleases:
+            wf().logger.debug('Ignoring prerelease : %s', release['version'])
+            continue
+
+        releases.append(release)
+
+    return releases
+
+
+def check_update(github_slug, current_version, prereleases=False):
+    """Check whether a newer release is available on GitHub.
+
+    :param github_slug: ``username/repo`` for workflow's GitHub repo
+    :param current_version: the currently installed version of the
+        workflow. :ref:`Semantic versioning <semver>` is required.
+    :param prereleases: Whether to include pre-releases.
+    :type current_version: ``unicode``
+    :returns: ``True`` if an update is available, else ``False``
+
+    If an update is available, its version number and download URL will
+    be cached.
+
+    """
+    releases = get_valid_releases(github_slug, prereleases)
+
+    wf().logger.info('%d releases for %s', len(releases), github_slug)
+
+    if not len(releases):
+        raise ValueError('No valid releases for %s', github_slug)
+
+    # GitHub returns releases newest-first
+    latest_release = releases[0]
+
+    # (latest_version, download_url) = get_latest_release(releases)
+    vr = Version(latest_release['version'])
+    vl = Version(current_version)
+    wf().logger.debug('Latest : %r Installed : %r', vr, vl)
+    if vr > vl:
+
+        wf().cache_data('__workflow_update_status', {
+            'version': latest_release['version'],
+            'download_url': latest_release['download_url'],
+            'available': True
+        })
+
+        return True
+
+    wf().cache_data('__workflow_update_status', {
+        'available': False
+    })
+    return False
+
+
+def install_update():
+    """If a newer release is available, download and install it.
+
+    :returns: ``True`` if an update is installed, else ``False``
+
+    """
+    update_data = wf().cached_data('__workflow_update_status', max_age=0)
+
+    if not update_data or not update_data.get('available'):
+        wf().logger.info('No update available')
+        return False
+
+    local_file = download_workflow(update_data['download_url'])
+
+    wf().logger.info('Installing updated workflow ...')
+    subprocess.call(['open', local_file])
+
+    update_data['available'] = False
+    wf().cache_data('__workflow_update_status', update_data)
+    return True
+
+
+if __name__ == '__main__':  # pragma: nocover
+    import sys
+
+    def show_help():
+        """Print help message."""
+        print('Usage : update.py (check|install) github_slug version '
+              '[--prereleases]')
+        sys.exit(1)
+
+    argv = sys.argv[:]
+    prereleases = '--prereleases' in argv
+
+    if prereleases:
+        argv.remove('--prereleases')
+
+    if len(argv) != 4:
+        show_help()
+
+    action, github_slug, version = argv[1:]
+
+    if action not in ('check', 'install'):
+        show_help()
+
+    if action == 'check':
+        check_update(github_slug, version, prereleases)
+    elif action == 'install':
+        install_update()

+ 1 - 0
scripts/workflow/version

@@ -0,0 +1 @@
+1.25.1

+ 671 - 0
scripts/workflow/web.py

@@ -0,0 +1,671 @@
+# encoding: utf-8
+#
+# Copyright (c) 2014 Dean Jackson <[email protected]>
+#
+# MIT Licence. See http://opensource.org/licenses/MIT
+#
+# Created on 2014-02-15
+#
+
+"""Lightweight HTTP library with a requests-like interface."""
+
+import codecs
+import json
+import mimetypes
+import os
+import random
+import re
+import socket
+import string
+import unicodedata
+import urllib
+import urllib2
+import urlparse
+import zlib
+
+
+USER_AGENT = u'Alfred-Workflow/1.19 (+http://www.deanishe.net/alfred-workflow)'
+
+# Valid characters for multipart form data boundaries
+BOUNDARY_CHARS = string.digits + string.ascii_letters
+
+# HTTP response codes
+RESPONSES = {
+    100: 'Continue',
+    101: 'Switching Protocols',
+    200: 'OK',
+    201: 'Created',
+    202: 'Accepted',
+    203: 'Non-Authoritative Information',
+    204: 'No Content',
+    205: 'Reset Content',
+    206: 'Partial Content',
+    300: 'Multiple Choices',
+    301: 'Moved Permanently',
+    302: 'Found',
+    303: 'See Other',
+    304: 'Not Modified',
+    305: 'Use Proxy',
+    307: 'Temporary Redirect',
+    400: 'Bad Request',
+    401: 'Unauthorized',
+    402: 'Payment Required',
+    403: 'Forbidden',
+    404: 'Not Found',
+    405: 'Method Not Allowed',
+    406: 'Not Acceptable',
+    407: 'Proxy Authentication Required',
+    408: 'Request Timeout',
+    409: 'Conflict',
+    410: 'Gone',
+    411: 'Length Required',
+    412: 'Precondition Failed',
+    413: 'Request Entity Too Large',
+    414: 'Request-URI Too Long',
+    415: 'Unsupported Media Type',
+    416: 'Requested Range Not Satisfiable',
+    417: 'Expectation Failed',
+    500: 'Internal Server Error',
+    501: 'Not Implemented',
+    502: 'Bad Gateway',
+    503: 'Service Unavailable',
+    504: 'Gateway Timeout',
+    505: 'HTTP Version Not Supported'
+}
+
+
+def str_dict(dic):
+    """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`.
+
+    :param dic: :class:`dict` of Unicode strings
+    :returns: :class:`dict`
+
+    """
+    if isinstance(dic, CaseInsensitiveDictionary):
+        dic2 = CaseInsensitiveDictionary()
+    else:
+        dic2 = {}
+    for k, v in dic.items():
+        if isinstance(k, unicode):
+            k = k.encode('utf-8')
+        if isinstance(v, unicode):
+            v = v.encode('utf-8')
+        dic2[k] = v
+    return dic2
+
+
+class NoRedirectHandler(urllib2.HTTPRedirectHandler):
+    """Prevent redirections."""
+
+    def redirect_request(self, *args):
+        return None
+
+
+# Adapted from https://gist.github.com/babakness/3901174
+class CaseInsensitiveDictionary(dict):
+    """Dictionary with caseless key search.
+
+    Enables case insensitive searching while preserving case sensitivity
+    when keys are listed, ie, via keys() or items() methods.
+
+    Works by storing a lowercase version of the key as the new key and
+    stores the original key-value pair as the key's value
+    (values become dictionaries).
+
+    """
+
+    def __init__(self, initval=None):
+        """Create new case-insensitive dictionary."""
+        if isinstance(initval, dict):
+            for key, value in initval.iteritems():
+                self.__setitem__(key, value)
+
+        elif isinstance(initval, list):
+            for (key, value) in initval:
+                self.__setitem__(key, value)
+
+    def __contains__(self, key):
+        return dict.__contains__(self, key.lower())
+
+    def __getitem__(self, key):
+        return dict.__getitem__(self, key.lower())['val']
+
+    def __setitem__(self, key, value):
+        return dict.__setitem__(self, key.lower(), {'key': key, 'val': value})
+
+    def get(self, key, default=None):
+        try:
+            v = dict.__getitem__(self, key.lower())
+        except KeyError:
+            return default
+        else:
+            return v['val']
+
+    def update(self, other):
+        for k, v in other.items():
+            self[k] = v
+
+    def items(self):
+        return [(v['key'], v['val']) for v in dict.itervalues(self)]
+
+    def keys(self):
+        return [v['key'] for v in dict.itervalues(self)]
+
+    def values(self):
+        return [v['val'] for v in dict.itervalues(self)]
+
+    def iteritems(self):
+        for v in dict.itervalues(self):
+            yield v['key'], v['val']
+
+    def iterkeys(self):
+        for v in dict.itervalues(self):
+            yield v['key']
+
+    def itervalues(self):
+        for v in dict.itervalues(self):
+            yield v['val']
+
+
+class Response(object):
+    """
+    Returned by :func:`request` / :func:`get` / :func:`post` functions.
+
+    Simplified version of the ``Response`` object in the ``requests`` library.
+
+    >>> r = request('http://www.google.com')
+    >>> r.status_code
+    200
+    >>> r.encoding
+    ISO-8859-1
+    >>> r.content  # bytes
+    <html> ...
+    >>> r.text  # unicode, decoded according to charset in HTTP header/meta tag
+    u'<html> ...'
+    >>> r.json()  # content parsed as JSON
+
+    """
+
+    def __init__(self, request, stream=False):
+        """Call `request` with :mod:`urllib2` and process results.
+
+        :param request: :class:`urllib2.Request` instance
+        :param stream: Whether to stream response or retrieve it all at once
+        :type stream: ``bool``
+
+        """
+        self.request = request
+        self._stream = stream
+        self.url = None
+        self.raw = None
+        self._encoding = None
+        self.error = None
+        self.status_code = None
+        self.reason = None
+        self.headers = CaseInsensitiveDictionary()
+        self._content = None
+        self._content_loaded = False
+        self._gzipped = False
+
+        # Execute query
+        try:
+            self.raw = urllib2.urlopen(request)
+        except urllib2.HTTPError as err:
+            self.error = err
+            try:
+                self.url = err.geturl()
+            # sometimes (e.g. when authentication fails)
+            # urllib can't get a URL from an HTTPError
+            # This behaviour changes across Python versions,
+            # so no test cover (it isn't important).
+            except AttributeError:  # pragma: no cover
+                pass
+            self.status_code = err.code
+        else:
+            self.status_code = self.raw.getcode()
+            self.url = self.raw.geturl()
+        self.reason = RESPONSES.get(self.status_code)
+
+        # Parse additional info if request succeeded
+        if not self.error:
+            headers = self.raw.info()
+            self.transfer_encoding = headers.getencoding()
+            self.mimetype = headers.gettype()
+            for key in headers.keys():
+                self.headers[key.lower()] = headers.get(key)
+
+            # Is content gzipped?
+            # Transfer-Encoding appears to not be used in the wild
+            # (contrary to the HTTP standard), but no harm in testing
+            # for it
+            if ('gzip' in headers.get('content-encoding', '') or
+                    'gzip' in headers.get('transfer-encoding', '')):
+                self._gzipped = True
+
+    @property
+    def stream(self):
+        """Whether response is streamed.
+
+        Returns:
+            bool: `True` if response is streamed.
+        """
+        return self._stream
+
+    @stream.setter
+    def stream(self, value):
+        if self._content_loaded:
+            raise RuntimeError("`content` has already been read from "
+                               "this Response.")
+
+        self._stream = value
+
+    def json(self):
+        """Decode response contents as JSON.
+
+        :returns: object decoded from JSON
+        :rtype: :class:`list` / :class:`dict`
+
+        """
+        return json.loads(self.content, self.encoding or 'utf-8')
+
+    @property
+    def encoding(self):
+        """Text encoding of document or ``None``.
+
+        :returns: :class:`str` or ``None``
+
+        """
+        if not self._encoding:
+            self._encoding = self._get_encoding()
+
+        return self._encoding
+
+    @property
+    def content(self):
+        """Raw content of response (i.e. bytes).
+
+        :returns: Body of HTTP response
+        :rtype: :class:`str`
+
+        """
+        if not self._content:
+
+            # Decompress gzipped content
+            if self._gzipped:
+                decoder = zlib.decompressobj(16 + zlib.MAX_WBITS)
+                self._content = decoder.decompress(self.raw.read())
+
+            else:
+                self._content = self.raw.read()
+
+            self._content_loaded = True
+
+        return self._content
+
+    @property
+    def text(self):
+        """Unicode-decoded content of response body.
+
+        If no encoding can be determined from HTTP headers or the content
+        itself, the encoded response body will be returned instead.
+
+        :returns: Body of HTTP response
+        :rtype: :class:`unicode` or :class:`str`
+
+        """
+        if self.encoding:
+            return unicodedata.normalize('NFC', unicode(self.content,
+                                                        self.encoding))
+        return self.content
+
+    def iter_content(self, chunk_size=4096, decode_unicode=False):
+        """Iterate over response data.
+
+        .. versionadded:: 1.6
+
+        :param chunk_size: Number of bytes to read into memory
+        :type chunk_size: ``int``
+        :param decode_unicode: Decode to Unicode using detected encoding
+        :type decode_unicode: ``Boolean``
+        :returns: iterator
+
+        """
+        if not self.stream:
+            raise RuntimeError("You cannot call `iter_content` on a "
+                               "Response unless you passed `stream=True`"
+                               " to `get()`/`post()`/`request()`.")
+
+        if self._content_loaded:
+            raise RuntimeError(
+                "`content` has already been read from this Response.")
+
+        def decode_stream(iterator, r):
+
+            decoder = codecs.getincrementaldecoder(r.encoding)(errors='replace')
+
+            for chunk in iterator:
+                data = decoder.decode(chunk)
+                if data:
+                    yield data
+
+            data = decoder.decode(b'', final=True)
+            if data:  # pragma: no cover
+                yield data
+
+        def generate():
+
+            if self._gzipped:
+                decoder = zlib.decompressobj(16 + zlib.MAX_WBITS)
+
+            while True:
+                chunk = self.raw.read(chunk_size)
+                if not chunk:
+                    break
+
+                if self._gzipped:
+                    chunk = decoder.decompress(chunk)
+
+                yield chunk
+
+        chunks = generate()
+
+        if decode_unicode and self.encoding:
+            chunks = decode_stream(chunks, self)
+
+        return chunks
+
+    def save_to_path(self, filepath):
+        """Save retrieved data to file at ``filepath``.
+
+        .. versionadded: 1.9.6
+
+        :param filepath: Path to save retrieved data.
+
+        """
+        filepath = os.path.abspath(filepath)
+        dirname = os.path.dirname(filepath)
+        if not os.path.exists(dirname):
+            os.makedirs(dirname)
+
+        self.stream = True
+
+        with open(filepath, 'wb') as fileobj:
+            for data in self.iter_content():
+                fileobj.write(data)
+
+    def raise_for_status(self):
+        """Raise stored error if one occurred.
+
+        error will be instance of :class:`urllib2.HTTPError`
+        """
+        if self.error is not None:
+            raise self.error
+        return
+
+    def _get_encoding(self):
+        """Get encoding from HTTP headers or content.
+
+        :returns: encoding or `None`
+        :rtype: ``unicode`` or ``None``
+
+        """
+        headers = self.raw.info()
+        encoding = None
+
+        if headers.getparam('charset'):
+            encoding = headers.getparam('charset')
+
+        # HTTP Content-Type header
+        for param in headers.getplist():
+            if param.startswith('charset='):
+                encoding = param[8:]
+                break
+
+        if not self.stream:  # Try sniffing response content
+            # Encoding declared in document should override HTTP headers
+            if self.mimetype == 'text/html':  # sniff HTML headers
+                m = re.search("""<meta.+charset=["']{0,1}(.+?)["'].*>""",
+                              self.content)
+                if m:
+                    encoding = m.group(1)
+
+            elif ((self.mimetype.startswith('application/') or
+                   self.mimetype.startswith('text/')) and
+                  'xml' in self.mimetype):
+                m = re.search("""<?xml.+encoding=["'](.+?)["'][^>]*\?>""",
+                              self.content)
+                if m:
+                    encoding = m.group(1)
+
+        # Format defaults
+        if self.mimetype == 'application/json' and not encoding:
+            # The default encoding for JSON
+            encoding = 'utf-8'
+
+        elif self.mimetype == 'application/xml' and not encoding:
+            # The default for 'application/xml'
+            encoding = 'utf-8'
+
+        if encoding:
+            encoding = encoding.lower()
+
+        return encoding
+
+
+def request(method, url, params=None, data=None, headers=None, cookies=None,
+            files=None, auth=None, timeout=60, allow_redirects=False,
+            stream=False):
+    """Initiate an HTTP(S) request. Returns :class:`Response` object.
+
+    :param method: 'GET' or 'POST'
+    :type method: ``unicode``
+    :param url: URL to open
+    :type url: ``unicode``
+    :param params: mapping of URL parameters
+    :type params: :class:`dict`
+    :param data: mapping of form data ``{'field_name': 'value'}`` or
+        :class:`str`
+    :type data: :class:`dict` or :class:`str`
+    :param headers: HTTP headers
+    :type headers: :class:`dict`
+    :param cookies: cookies to send to server
+    :type cookies: :class:`dict`
+    :param files: files to upload (see below).
+    :type files: :class:`dict`
+    :param auth: username, password
+    :type auth: ``tuple``
+    :param timeout: connection timeout limit in seconds
+    :type timeout: ``int``
+    :param allow_redirects: follow redirections
+    :type allow_redirects: ``Boolean``
+    :param stream: Stream content instead of fetching it all at once.
+    :type stream: ``bool``
+    :returns: :class:`Response` object
+
+
+    The ``files`` argument is a dictionary::
+
+        {'fieldname' : { 'filename': 'blah.txt',
+                         'content': '<binary data>',
+                         'mimetype': 'text/plain'}
+        }
+
+    * ``fieldname`` is the name of the field in the HTML form.
+    * ``mimetype`` is optional. If not provided, :mod:`mimetypes` will
+      be used to guess the mimetype, or ``application/octet-stream``
+      will be used.
+
+    """
+    # TODO: cookies
+    socket.setdefaulttimeout(timeout)
+
+    # Default handlers
+    openers = []
+
+    if not allow_redirects:
+        openers.append(NoRedirectHandler())
+
+    if auth is not None:  # Add authorisation handler
+        username, password = auth
+        password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm()
+        password_manager.add_password(None, url, username, password)
+        auth_manager = urllib2.HTTPBasicAuthHandler(password_manager)
+        openers.append(auth_manager)
+
+    # Install our custom chain of openers
+    opener = urllib2.build_opener(*openers)
+    urllib2.install_opener(opener)
+
+    if not headers:
+        headers = CaseInsensitiveDictionary()
+    else:
+        headers = CaseInsensitiveDictionary(headers)
+
+    if 'user-agent' not in headers:
+        headers['user-agent'] = USER_AGENT
+
+    # Accept gzip-encoded content
+    encodings = [s.strip() for s in
+                 headers.get('accept-encoding', '').split(',')]
+    if 'gzip' not in encodings:
+        encodings.append('gzip')
+
+    headers['accept-encoding'] = ', '.join(encodings)
+
+    # Force POST by providing an empty data string
+    if method == 'POST' and not data:
+        data = ''
+
+    if files:
+        if not data:
+            data = {}
+        new_headers, data = encode_multipart_formdata(data, files)
+        headers.update(new_headers)
+    elif data and isinstance(data, dict):
+        data = urllib.urlencode(str_dict(data))
+
+    # Make sure everything is encoded text
+    headers = str_dict(headers)
+
+    if isinstance(url, unicode):
+        url = url.encode('utf-8')
+
+    if params:  # GET args (POST args are handled in encode_multipart_formdata)
+
+        scheme, netloc, path, query, fragment = urlparse.urlsplit(url)
+
+        if query:  # Combine query string and `params`
+            url_params = urlparse.parse_qs(query)
+            # `params` take precedence over URL query string
+            url_params.update(params)
+            params = url_params
+
+        query = urllib.urlencode(str_dict(params), doseq=True)
+        url = urlparse.urlunsplit((scheme, netloc, path, query, fragment))
+
+    req = urllib2.Request(url, data, headers)
+    return Response(req, stream)
+
+
+def get(url, params=None, headers=None, cookies=None, auth=None,
+        timeout=60, allow_redirects=True, stream=False):
+    """Initiate a GET request. Arguments as for :func:`request`.
+
+    :returns: :class:`Response` instance
+
+    """
+    return request('GET', url, params, headers=headers, cookies=cookies,
+                   auth=auth, timeout=timeout, allow_redirects=allow_redirects,
+                   stream=stream)
+
+
+def post(url, params=None, data=None, headers=None, cookies=None, files=None,
+         auth=None, timeout=60, allow_redirects=False, stream=False):
+    """Initiate a POST request. Arguments as for :func:`request`.
+
+    :returns: :class:`Response` instance
+
+    """
+    return request('POST', url, params, data, headers, cookies, files, auth,
+                   timeout, allow_redirects, stream)
+
+
+def encode_multipart_formdata(fields, files):
+    """Encode form data (``fields``) and ``files`` for POST request.
+
+    :param fields: mapping of ``{name : value}`` pairs for normal form fields.
+    :type fields: :class:`dict`
+    :param files: dictionary of fieldnames/files elements for file data.
+                  See below for details.
+    :type files: :class:`dict` of :class:`dicts`
+    :returns: ``(headers, body)`` ``headers`` is a :class:`dict` of HTTP headers
+    :rtype: 2-tuple ``(dict, str)``
+
+    The ``files`` argument is a dictionary::
+
+        {'fieldname' : { 'filename': 'blah.txt',
+                         'content': '<binary data>',
+                         'mimetype': 'text/plain'}
+        }
+
+    - ``fieldname`` is the name of the field in the HTML form.
+    - ``mimetype`` is optional. If not provided, :mod:`mimetypes` will be used to guess the mimetype, or ``application/octet-stream`` will be used.
+
+    """
+    def get_content_type(filename):
+        """Return or guess mimetype of ``filename``.
+
+        :param filename: filename of file
+        :type filename: unicode/string
+        :returns: mime-type, e.g. ``text/html``
+        :rtype: :class::class:`str`
+
+        """
+
+        return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
+
+    boundary = '-----' + ''.join(random.choice(BOUNDARY_CHARS)
+                                 for i in range(30))
+    CRLF = '\r\n'
+    output = []
+
+    # Normal form fields
+    for (name, value) in fields.items():
+        if isinstance(name, unicode):
+            name = name.encode('utf-8')
+        if isinstance(value, unicode):
+            value = value.encode('utf-8')
+        output.append('--' + boundary)
+        output.append('Content-Disposition: form-data; name="%s"' % name)
+        output.append('')
+        output.append(value)
+
+    # Files to upload
+    for name, d in files.items():
+        filename = d[u'filename']
+        content = d[u'content']
+        if u'mimetype' in d:
+            mimetype = d[u'mimetype']
+        else:
+            mimetype = get_content_type(filename)
+        if isinstance(name, unicode):
+            name = name.encode('utf-8')
+        if isinstance(filename, unicode):
+            filename = filename.encode('utf-8')
+        if isinstance(mimetype, unicode):
+            mimetype = mimetype.encode('utf-8')
+        output.append('--' + boundary)
+        output.append('Content-Disposition: form-data; '
+                      'name="%s"; filename="%s"' % (name, filename))
+        output.append('Content-Type: %s' % mimetype)
+        output.append('')
+        output.append(content)
+
+    output.append('--' + boundary + '--')
+    output.append('')
+    body = CRLF.join(output)
+    headers = {
+        'Content-Type': 'multipart/form-data; boundary=%s' % boundary,
+        'Content-Length': str(len(body)),
+    }
+    return (headers, body)

+ 2993 - 0
scripts/workflow/workflow.py

@@ -0,0 +1,2993 @@
+# encoding: utf-8
+#
+# Copyright (c) 2014 Dean Jackson <[email protected]>
+#
+# MIT Licence. See http://opensource.org/licenses/MIT
+#
+# Created on 2014-02-15
+#
+
+"""The :class:`Workflow` object is the main interface to this library.
+
+:class:`Workflow` is targeted at Alfred 2. Use
+:class:`~workflow.workflow3.Workflow3` if you want to use Alfred 3's new
+features, such as :ref:`workflow variables <workflow-variables>` or
+more powerful modifiers.
+
+See :ref:`setup` in the :ref:`user-manual` for an example of how to set
+up your Python script to best utilise the :class:`Workflow` object.
+
+"""
+
+from __future__ import print_function, unicode_literals
+
+import atexit
+import binascii
+from contextlib import contextmanager
+import cPickle
+from copy import deepcopy
+import errno
+import json
+import logging
+import logging.handlers
+import os
+import pickle
+import plistlib
+import re
+import shutil
+import signal
+import string
+import subprocess
+import sys
+import time
+import unicodedata
+
+try:
+    import xml.etree.cElementTree as ET
+except ImportError:  # pragma: no cover
+    import xml.etree.ElementTree as ET
+
+
+#: Sentinel for properties that haven't been set yet (that might
+#: correctly have the value ``None``)
+UNSET = object()
+
+####################################################################
+# Standard system icons
+####################################################################
+
+# These icons are default OS X icons. They are super-high quality, and
+# will be familiar to users.
+# This library uses `ICON_ERROR` when a workflow dies in flames, so
+# in my own workflows, I use `ICON_WARNING` for less fatal errors
+# (e.g. bad user input, no results etc.)
+
+# The system icons are all in this directory. There are many more than
+# are listed here
+
+ICON_ROOT = '/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources'
+
+ICON_ACCOUNT = os.path.join(ICON_ROOT, 'Accounts.icns')
+ICON_BURN = os.path.join(ICON_ROOT, 'BurningIcon.icns')
+ICON_CLOCK = os.path.join(ICON_ROOT, 'Clock.icns')
+ICON_COLOR = os.path.join(ICON_ROOT, 'ProfileBackgroundColor.icns')
+ICON_COLOUR = ICON_COLOR  # Queen's English, if you please
+ICON_EJECT = os.path.join(ICON_ROOT, 'EjectMediaIcon.icns')
+# Shown when a workflow throws an error
+ICON_ERROR = os.path.join(ICON_ROOT, 'AlertStopIcon.icns')
+ICON_FAVORITE = os.path.join(ICON_ROOT, 'ToolbarFavoritesIcon.icns')
+ICON_FAVOURITE = ICON_FAVORITE
+ICON_GROUP = os.path.join(ICON_ROOT, 'GroupIcon.icns')
+ICON_HELP = os.path.join(ICON_ROOT, 'HelpIcon.icns')
+ICON_HOME = os.path.join(ICON_ROOT, 'HomeFolderIcon.icns')
+ICON_INFO = os.path.join(ICON_ROOT, 'ToolbarInfo.icns')
+ICON_NETWORK = os.path.join(ICON_ROOT, 'GenericNetworkIcon.icns')
+ICON_NOTE = os.path.join(ICON_ROOT, 'AlertNoteIcon.icns')
+ICON_SETTINGS = os.path.join(ICON_ROOT, 'ToolbarAdvanced.icns')
+ICON_SWIRL = os.path.join(ICON_ROOT, 'ErasingIcon.icns')
+ICON_SWITCH = os.path.join(ICON_ROOT, 'General.icns')
+ICON_SYNC = os.path.join(ICON_ROOT, 'Sync.icns')
+ICON_TRASH = os.path.join(ICON_ROOT, 'TrashIcon.icns')
+ICON_USER = os.path.join(ICON_ROOT, 'UserIcon.icns')
+ICON_WARNING = os.path.join(ICON_ROOT, 'AlertCautionIcon.icns')
+ICON_WEB = os.path.join(ICON_ROOT, 'BookmarkIcon.icns')
+
+####################################################################
+# non-ASCII to ASCII diacritic folding.
+# Used by `fold_to_ascii` method
+####################################################################
+
+ASCII_REPLACEMENTS = {
+    'À': 'A',
+    'Á': 'A',
+    'Â': 'A',
+    'Ã': 'A',
+    'Ä': 'A',
+    'Å': 'A',
+    'Æ': 'AE',
+    'Ç': 'C',
+    'È': 'E',
+    'É': 'E',
+    'Ê': 'E',
+    'Ë': 'E',
+    'Ì': 'I',
+    'Í': 'I',
+    'Î': 'I',
+    'Ï': 'I',
+    'Ð': 'D',
+    'Ñ': 'N',
+    'Ò': 'O',
+    'Ó': 'O',
+    'Ô': 'O',
+    'Õ': 'O',
+    'Ö': 'O',
+    'Ø': 'O',
+    'Ù': 'U',
+    'Ú': 'U',
+    'Û': 'U',
+    'Ü': 'U',
+    'Ý': 'Y',
+    'Þ': 'Th',
+    'ß': 'ss',
+    'à': 'a',
+    'á': 'a',
+    'â': 'a',
+    'ã': 'a',
+    'ä': 'a',
+    'å': 'a',
+    'æ': 'ae',
+    'ç': 'c',
+    'è': 'e',
+    'é': 'e',
+    'ê': 'e',
+    'ë': 'e',
+    'ì': 'i',
+    'í': 'i',
+    'î': 'i',
+    'ï': 'i',
+    'ð': 'd',
+    'ñ': 'n',
+    'ò': 'o',
+    'ó': 'o',
+    'ô': 'o',
+    'õ': 'o',
+    'ö': 'o',
+    'ø': 'o',
+    'ù': 'u',
+    'ú': 'u',
+    'û': 'u',
+    'ü': 'u',
+    'ý': 'y',
+    'þ': 'th',
+    'ÿ': 'y',
+    'Ł': 'L',
+    'ł': 'l',
+    'Ń': 'N',
+    'ń': 'n',
+    'Ņ': 'N',
+    'ņ': 'n',
+    'Ň': 'N',
+    'ň': 'n',
+    'Ŋ': 'ng',
+    'ŋ': 'NG',
+    'Ō': 'O',
+    'ō': 'o',
+    'Ŏ': 'O',
+    'ŏ': 'o',
+    'Ő': 'O',
+    'ő': 'o',
+    'Œ': 'OE',
+    'œ': 'oe',
+    'Ŕ': 'R',
+    'ŕ': 'r',
+    'Ŗ': 'R',
+    'ŗ': 'r',
+    'Ř': 'R',
+    'ř': 'r',
+    'Ś': 'S',
+    'ś': 's',
+    'Ŝ': 'S',
+    'ŝ': 's',
+    'Ş': 'S',
+    'ş': 's',
+    'Š': 'S',
+    'š': 's',
+    'Ţ': 'T',
+    'ţ': 't',
+    'Ť': 'T',
+    'ť': 't',
+    'Ŧ': 'T',
+    'ŧ': 't',
+    'Ũ': 'U',
+    'ũ': 'u',
+    'Ū': 'U',
+    'ū': 'u',
+    'Ŭ': 'U',
+    'ŭ': 'u',
+    'Ů': 'U',
+    'ů': 'u',
+    'Ű': 'U',
+    'ű': 'u',
+    'Ŵ': 'W',
+    'ŵ': 'w',
+    'Ŷ': 'Y',
+    'ŷ': 'y',
+    'Ÿ': 'Y',
+    'Ź': 'Z',
+    'ź': 'z',
+    'Ż': 'Z',
+    'ż': 'z',
+    'Ž': 'Z',
+    'ž': 'z',
+    'ſ': 's',
+    'Α': 'A',
+    'Β': 'B',
+    'Γ': 'G',
+    'Δ': 'D',
+    'Ε': 'E',
+    'Ζ': 'Z',
+    'Η': 'E',
+    'Θ': 'Th',
+    'Ι': 'I',
+    'Κ': 'K',
+    'Λ': 'L',
+    'Μ': 'M',
+    'Ν': 'N',
+    'Ξ': 'Ks',
+    'Ο': 'O',
+    'Π': 'P',
+    'Ρ': 'R',
+    'Σ': 'S',
+    'Τ': 'T',
+    'Υ': 'U',
+    'Φ': 'Ph',
+    'Χ': 'Kh',
+    'Ψ': 'Ps',
+    'Ω': 'O',
+    'α': 'a',
+    'β': 'b',
+    'γ': 'g',
+    'δ': 'd',
+    'ε': 'e',
+    'ζ': 'z',
+    'η': 'e',
+    'θ': 'th',
+    'ι': 'i',
+    'κ': 'k',
+    'λ': 'l',
+    'μ': 'm',
+    'ν': 'n',
+    'ξ': 'x',
+    'ο': 'o',
+    'π': 'p',
+    'ρ': 'r',
+    'ς': 's',
+    'σ': 's',
+    'τ': 't',
+    'υ': 'u',
+    'φ': 'ph',
+    'χ': 'kh',
+    'ψ': 'ps',
+    'ω': 'o',
+    'А': 'A',
+    'Б': 'B',
+    'В': 'V',
+    'Г': 'G',
+    'Д': 'D',
+    'Е': 'E',
+    'Ж': 'Zh',
+    'З': 'Z',
+    'И': 'I',
+    'Й': 'I',
+    'К': 'K',
+    'Л': 'L',
+    'М': 'M',
+    'Н': 'N',
+    'О': 'O',
+    'П': 'P',
+    'Р': 'R',
+    'С': 'S',
+    'Т': 'T',
+    'У': 'U',
+    'Ф': 'F',
+    'Х': 'Kh',
+    'Ц': 'Ts',
+    'Ч': 'Ch',
+    'Ш': 'Sh',
+    'Щ': 'Shch',
+    'Ъ': "'",
+    'Ы': 'Y',
+    'Ь': "'",
+    'Э': 'E',
+    'Ю': 'Iu',
+    'Я': 'Ia',
+    'а': 'a',
+    'б': 'b',
+    'в': 'v',
+    'г': 'g',
+    'д': 'd',
+    'е': 'e',
+    'ж': 'zh',
+    'з': 'z',
+    'и': 'i',
+    'й': 'i',
+    'к': 'k',
+    'л': 'l',
+    'м': 'm',
+    'н': 'n',
+    'о': 'o',
+    'п': 'p',
+    'р': 'r',
+    'с': 's',
+    'т': 't',
+    'у': 'u',
+    'ф': 'f',
+    'х': 'kh',
+    'ц': 'ts',
+    'ч': 'ch',
+    'ш': 'sh',
+    'щ': 'shch',
+    'ъ': "'",
+    'ы': 'y',
+    'ь': "'",
+    'э': 'e',
+    'ю': 'iu',
+    'я': 'ia',
+    # 'ᴀ': '',
+    # 'ᴁ': '',
+    # 'ᴂ': '',
+    # 'ᴃ': '',
+    # 'ᴄ': '',
+    # 'ᴅ': '',
+    # 'ᴆ': '',
+    # 'ᴇ': '',
+    # 'ᴈ': '',
+    # 'ᴉ': '',
+    # 'ᴊ': '',
+    # 'ᴋ': '',
+    # 'ᴌ': '',
+    # 'ᴍ': '',
+    # 'ᴎ': '',
+    # 'ᴏ': '',
+    # 'ᴐ': '',
+    # 'ᴑ': '',
+    # 'ᴒ': '',
+    # 'ᴓ': '',
+    # 'ᴔ': '',
+    # 'ᴕ': '',
+    # 'ᴖ': '',
+    # 'ᴗ': '',
+    # 'ᴘ': '',
+    # 'ᴙ': '',
+    # 'ᴚ': '',
+    # 'ᴛ': '',
+    # 'ᴜ': '',
+    # 'ᴝ': '',
+    # 'ᴞ': '',
+    # 'ᴟ': '',
+    # 'ᴠ': '',
+    # 'ᴡ': '',
+    # 'ᴢ': '',
+    # 'ᴣ': '',
+    # 'ᴤ': '',
+    # 'ᴥ': '',
+    'ᴦ': 'G',
+    'ᴧ': 'L',
+    'ᴨ': 'P',
+    'ᴩ': 'R',
+    'ᴪ': 'PS',
+    'ẞ': 'Ss',
+    'Ỳ': 'Y',
+    'ỳ': 'y',
+    'Ỵ': 'Y',
+    'ỵ': 'y',
+    'Ỹ': 'Y',
+    'ỹ': 'y',
+}
+
+####################################################################
+# Smart-to-dumb punctuation mapping
+####################################################################
+
+DUMB_PUNCTUATION = {
+    '‘': "'",
+    '’': "'",
+    '‚': "'",
+    '“': '"',
+    '”': '"',
+    '„': '"',
+    '–': '-',
+    '—': '-'
+}
+
+
+####################################################################
+# Used by `Workflow.filter`
+####################################################################
+
+# Anchor characters in a name
+#: Characters that indicate the beginning of a "word" in CamelCase
+INITIALS = string.ascii_uppercase + string.digits
+
+#: Split on non-letters, numbers
+split_on_delimiters = re.compile('[^a-zA-Z0-9]').split
+
+# Match filter flags
+#: Match items that start with ``query``
+MATCH_STARTSWITH = 1
+#: Match items whose capital letters start with ``query``
+MATCH_CAPITALS = 2
+#: Match items with a component "word" that matches ``query``
+MATCH_ATOM = 4
+#: Match items whose initials (based on atoms) start with ``query``
+MATCH_INITIALS_STARTSWITH = 8
+#: Match items whose initials (based on atoms) contain ``query``
+MATCH_INITIALS_CONTAIN = 16
+#: Combination of :const:`MATCH_INITIALS_STARTSWITH` and
+#: :const:`MATCH_INITIALS_CONTAIN`
+MATCH_INITIALS = 24
+#: Match items if ``query`` is a substring
+MATCH_SUBSTRING = 32
+#: Match items if all characters in ``query`` appear in the item in order
+MATCH_ALLCHARS = 64
+#: Combination of all other ``MATCH_*`` constants
+MATCH_ALL = 127
+
+
+####################################################################
+# Used by `Workflow.check_update`
+####################################################################
+
+# Number of days to wait between checking for updates to the workflow
+DEFAULT_UPDATE_FREQUENCY = 1
+
+
+####################################################################
+# Lockfile and Keychain access errors
+####################################################################
+
+class AcquisitionError(Exception):
+    """Raised if a lock cannot be acquired."""
+
+
+class KeychainError(Exception):
+    """Raised for unknown Keychain errors.
+
+    Raised by methods :meth:`Workflow.save_password`,
+    :meth:`Workflow.get_password` and :meth:`Workflow.delete_password`
+    when ``security`` CLI app returns an unknown error code.
+    """
+
+
+class PasswordNotFound(KeychainError):
+    """Password not in Keychain.
+
+    Raised by method :meth:`Workflow.get_password` when ``account``
+    is unknown to the Keychain.
+    """
+
+
+class PasswordExists(KeychainError):
+    """Raised when trying to overwrite an existing account password.
+
+    You should never receive this error: it is used internally
+    by the :meth:`Workflow.save_password` method to know if it needs
+    to delete the old password first (a Keychain implementation detail).
+    """
+
+
+####################################################################
+# Helper functions
+####################################################################
+
+def isascii(text):
+    """Test if ``text`` contains only ASCII characters.
+
+    :param text: text to test for ASCII-ness
+    :type text: ``unicode``
+    :returns: ``True`` if ``text`` contains only ASCII characters
+    :rtype: ``Boolean``
+
+    """
+    try:
+        text.encode('ascii')
+    except UnicodeEncodeError:
+        return False
+    return True
+
+
+####################################################################
+# Implementation classes
+####################################################################
+
+class SerializerManager(object):
+    """Contains registered serializers.
+
+    .. versionadded:: 1.8
+
+    A configured instance of this class is available at
+    ``workflow.manager``.
+
+    Use :meth:`register()` to register new (or replace
+    existing) serializers, which you can specify by name when calling
+    :class:`Workflow` data storage methods.
+
+    See :ref:`manual-serialization` and :ref:`manual-persistent-data`
+    for further information.
+
+    """
+
+    def __init__(self):
+        """Create new SerializerManager object."""
+        self._serializers = {}
+
+    def register(self, name, serializer):
+        """Register ``serializer`` object under ``name``.
+
+        Raises :class:`AttributeError` if ``serializer`` in invalid.
+
+        .. note::
+
+            ``name`` will be used as the file extension of the saved files.
+
+        :param name: Name to register ``serializer`` under
+        :type name: ``unicode`` or ``str``
+        :param serializer: object with ``load()`` and ``dump()``
+            methods
+
+        """
+        # Basic validation
+        getattr(serializer, 'load')
+        getattr(serializer, 'dump')
+
+        self._serializers[name] = serializer
+
+    def serializer(self, name):
+        """Return serializer object for ``name``.
+
+        :param name: Name of serializer to return
+        :type name: ``unicode`` or ``str``
+        :returns: serializer object or ``None`` if no such serializer
+            is registered.
+
+        """
+        return self._serializers.get(name)
+
+    def unregister(self, name):
+        """Remove registered serializer with ``name``.
+
+        Raises a :class:`ValueError` if there is no such registered
+        serializer.
+
+        :param name: Name of serializer to remove
+        :type name: ``unicode`` or ``str``
+        :returns: serializer object
+
+        """
+        if name not in self._serializers:
+            raise ValueError('No such serializer registered : {0}'.format(
+                             name))
+
+        serializer = self._serializers[name]
+        del self._serializers[name]
+
+        return serializer
+
+    @property
+    def serializers(self):
+        """Return names of registered serializers."""
+        return sorted(self._serializers.keys())
+
+
+class JSONSerializer(object):
+    """Wrapper around :mod:`json`. Sets ``indent`` and ``encoding``.
+
+    .. versionadded:: 1.8
+
+    Use this serializer if you need readable data files. JSON doesn't
+    support Python objects as well as ``cPickle``/``pickle``, so be
+    careful which data you try to serialize as JSON.
+
+    """
+
+    @classmethod
+    def load(cls, file_obj):
+        """Load serialized object from open JSON file.
+
+        .. versionadded:: 1.8
+
+        :param file_obj: file handle
+        :type file_obj: ``file`` object
+        :returns: object loaded from JSON file
+        :rtype: object
+
+        """
+        return json.load(file_obj)
+
+    @classmethod
+    def dump(cls, obj, file_obj):
+        """Serialize object ``obj`` to open JSON file.
+
+        .. versionadded:: 1.8
+
+        :param obj: Python object to serialize
+        :type obj: JSON-serializable data structure
+        :param file_obj: file handle
+        :type file_obj: ``file`` object
+
+        """
+        return json.dump(obj, file_obj, indent=2, encoding='utf-8')
+
+
+class CPickleSerializer(object):
+    """Wrapper around :mod:`cPickle`. Sets ``protocol``.
+
+    .. versionadded:: 1.8
+
+    This is the default serializer and the best combination of speed and
+    flexibility.
+
+    """
+
+    @classmethod
+    def load(cls, file_obj):
+        """Load serialized object from open pickle file.
+
+        .. versionadded:: 1.8
+
+        :param file_obj: file handle
+        :type file_obj: ``file`` object
+        :returns: object loaded from pickle file
+        :rtype: object
+
+        """
+        return cPickle.load(file_obj)
+
+    @classmethod
+    def dump(cls, obj, file_obj):
+        """Serialize object ``obj`` to open pickle file.
+
+        .. versionadded:: 1.8
+
+        :param obj: Python object to serialize
+        :type obj: Python object
+        :param file_obj: file handle
+        :type file_obj: ``file`` object
+
+        """
+        return cPickle.dump(obj, file_obj, protocol=-1)
+
+
+class PickleSerializer(object):
+    """Wrapper around :mod:`pickle`. Sets ``protocol``.
+
+    .. versionadded:: 1.8
+
+    Use this serializer if you need to add custom pickling.
+
+    """
+
+    @classmethod
+    def load(cls, file_obj):
+        """Load serialized object from open pickle file.
+
+        .. versionadded:: 1.8
+
+        :param file_obj: file handle
+        :type file_obj: ``file`` object
+        :returns: object loaded from pickle file
+        :rtype: object
+
+        """
+        return pickle.load(file_obj)
+
+    @classmethod
+    def dump(cls, obj, file_obj):
+        """Serialize object ``obj`` to open pickle file.
+
+        .. versionadded:: 1.8
+
+        :param obj: Python object to serialize
+        :type obj: Python object
+        :param file_obj: file handle
+        :type file_obj: ``file`` object
+
+        """
+        return pickle.dump(obj, file_obj, protocol=-1)
+
+
+# Set up default manager and register built-in serializers
+manager = SerializerManager()
+manager.register('cpickle', CPickleSerializer)
+manager.register('pickle', PickleSerializer)
+manager.register('json', JSONSerializer)
+
+
+class Item(object):
+    """Represents a feedback item for Alfred.
+
+    Generates Alfred-compliant XML for a single item.
+
+    You probably shouldn't use this class directly, but via
+    :meth:`Workflow.add_item`. See :meth:`~Workflow.add_item`
+    for details of arguments.
+
+    """
+
+    def __init__(self, title, subtitle='', modifier_subtitles=None,
+                 arg=None, autocomplete=None, valid=False, uid=None,
+                 icon=None, icontype=None, type=None, largetext=None,
+                 copytext=None, quicklookurl=None):
+        """Same arguments as :meth:`Workflow.add_item`."""
+        self.title = title
+        self.subtitle = subtitle
+        self.modifier_subtitles = modifier_subtitles or {}
+        self.arg = arg
+        self.autocomplete = autocomplete
+        self.valid = valid
+        self.uid = uid
+        self.icon = icon
+        self.icontype = icontype
+        self.type = type
+        self.largetext = largetext
+        self.copytext = copytext
+        self.quicklookurl = quicklookurl
+
+    @property
+    def elem(self):
+        """Create and return feedback item for Alfred.
+
+        :returns: :class:`ElementTree.Element <xml.etree.ElementTree.Element>`
+            instance for this :class:`Item` instance.
+
+        """
+        # Attributes on <item> element
+        attr = {}
+        if self.valid:
+            attr['valid'] = 'yes'
+        else:
+            attr['valid'] = 'no'
+        # Allow empty string for autocomplete. This is a useful value,
+        # as TABing the result will revert the query back to just the
+        # keyword
+        if self.autocomplete is not None:
+            attr['autocomplete'] = self.autocomplete
+
+        # Optional attributes
+        for name in ('uid', 'type'):
+            value = getattr(self, name, None)
+            if value:
+                attr[name] = value
+
+        root = ET.Element('item', attr)
+        ET.SubElement(root, 'title').text = self.title
+        ET.SubElement(root, 'subtitle').text = self.subtitle
+
+        # Add modifier subtitles
+        for mod in ('cmd', 'ctrl', 'alt', 'shift', 'fn'):
+            if mod in self.modifier_subtitles:
+                ET.SubElement(root, 'subtitle',
+                              {'mod': mod}).text = self.modifier_subtitles[mod]
+
+        # Add arg as element instead of attribute on <item>, as it's more
+        # flexible (newlines aren't allowed in attributes)
+        if self.arg:
+            ET.SubElement(root, 'arg').text = self.arg
+
+        # Add icon if there is one
+        if self.icon:
+            if self.icontype:
+                attr = dict(type=self.icontype)
+            else:
+                attr = {}
+            ET.SubElement(root, 'icon', attr).text = self.icon
+
+        if self.largetext:
+            ET.SubElement(root, 'text',
+                          {'type': 'largetype'}).text = self.largetext
+
+        if self.copytext:
+            ET.SubElement(root, 'text',
+                          {'type': 'copy'}).text = self.copytext
+
+        if self.quicklookurl:
+            ET.SubElement(root, 'quicklookurl').text = self.quicklookurl
+
+        return root
+
+
+class LockFile(object):
+    """Context manager to create lock files."""
+
+    def __init__(self, protected_path, timeout=0, delay=0.05):
+        """Create new :class:`LockFile` object."""
+        self.lockfile = protected_path + '.lock'
+        self.timeout = timeout
+        self.delay = delay
+        self._locked = False
+        atexit.register(self.release)
+
+    @property
+    def locked(self):
+        """`True` if file is locked by this instance."""
+        return self._locked
+
+    def acquire(self, blocking=True):
+        """Acquire the lock if possible.
+
+        If the lock is in use and ``blocking`` is ``False``, return
+        ``False``.
+
+        Otherwise, check every `self.delay` seconds until it acquires
+        lock or exceeds `self.timeout` and raises an `~AcquisitionError`.
+
+        """
+        start = time.time()
+        while True:
+
+            self._validate_lockfile()
+
+            try:
+                fd = os.open(self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
+                with os.fdopen(fd, 'w') as fd:
+                    fd.write('{0}'.format(os.getpid()))
+                break
+            except OSError as err:
+                if err.errno != errno.EEXIST:  # pragma: no cover
+                    raise
+
+                if self.timeout and (time.time() - start) >= self.timeout:
+                    raise AcquisitionError('Lock acquisition timed out.')
+                if not blocking:
+                    return False
+                time.sleep(self.delay)
+
+        self._locked = True
+        return True
+
+    def _validate_lockfile(self):
+        """Check existence and validity of lockfile.
+
+        If the lockfile exists, but contains an invalid PID
+        or the PID of a non-existant process, it is removed.
+
+        """
+        try:
+            with open(self.lockfile) as fp:
+                s = fp.read()
+        except Exception:
+            return
+
+        try:
+            pid = int(s)
+        except ValueError:
+            return self.release()
+
+        from background import _process_exists
+        if not _process_exists(pid):
+            self.release()
+
+    def release(self):
+        """Release the lock by deleting `self.lockfile`."""
+        self._locked = False
+        try:
+            os.unlink(self.lockfile)
+        except (OSError, IOError) as err:  # pragma: no cover
+            if err.errno != 2:
+                raise err
+
+    def __enter__(self):
+        """Acquire lock."""
+        self.acquire()
+        return self
+
+    def __exit__(self, typ, value, traceback):
+        """Release lock."""
+        self.release()
+
+    def __del__(self):
+        """Clear up `self.lockfile`."""
+        if self._locked:  # pragma: no cover
+            self.release()
+
+
+@contextmanager
+def atomic_writer(file_path, mode):
+    """Atomic file writer.
+
+    :param file_path: path of file to write to.
+    :type file_path: ``unicode``
+    :param mode: sames as for `func:open`
+    :type mode: string
+
+    .. versionadded:: 1.12
+
+    Context manager that ensures the file is only written if the write
+    succeeds. The data is first written to a temporary file.
+
+    """
+    temp_suffix = '.aw.temp'
+    temp_file_path = file_path + temp_suffix
+    with open(temp_file_path, mode) as file_obj:
+        try:
+            yield file_obj
+            os.rename(temp_file_path, file_path)
+        finally:
+            try:
+                os.remove(temp_file_path)
+            except (OSError, IOError):
+                pass
+
+
+class uninterruptible(object):
+    """Decorator that postpones SIGTERM until wrapped function is complete.
+
+    .. versionadded:: 1.12
+
+    Since version 2.7, Alfred allows Script Filters to be killed. If
+    your workflow is killed in the middle of critical code (e.g.
+    writing data to disk), this may corrupt your workflow's data.
+
+    Use this decorator to wrap critical functions that *must* complete.
+    If the script is killed while a wrapped function is executing,
+    the SIGTERM will be caught and handled after your function has
+    finished executing.
+
+    Alfred-Workflow uses this internally to ensure its settings, data
+    and cache writes complete.
+
+    .. important::
+
+        This decorator is NOT thread-safe.
+
+    """
+
+    def __init__(self, func, class_name=''):
+        """Decorate `func`."""
+        self.func = func
+        self._caught_signal = None
+
+    def signal_handler(self, signum, frame):
+        """Called when process receives SIGTERM."""
+        self._caught_signal = (signum, frame)
+
+    def __call__(self, *args, **kwargs):
+        """Trap ``SIGTERM`` and call wrapped function."""
+        self._caught_signal = None
+        # Register handler for SIGTERM, then call `self.func`
+        self.old_signal_handler = signal.getsignal(signal.SIGTERM)
+        signal.signal(signal.SIGTERM, self.signal_handler)
+
+        self.func(*args, **kwargs)
+
+        # Restore old signal handler
+        signal.signal(signal.SIGTERM, self.old_signal_handler)
+
+        # Handle any signal caught during execution
+        if self._caught_signal is not None:
+            signum, frame = self._caught_signal
+            if callable(self.old_signal_handler):
+                self.old_signal_handler(signum, frame)
+            elif self.old_signal_handler == signal.SIG_DFL:
+                sys.exit(0)
+
+    def __get__(self, obj=None, klass=None):
+        """Decorator API."""
+        return self.__class__(self.func.__get__(obj, klass),
+                              klass.__name__)
+
+
+class Settings(dict):
+    """A dictionary that saves itself when changed.
+
+    Dictionary keys & values will be saved as a JSON file
+    at ``filepath``. If the file does not exist, the dictionary
+    (and settings file) will be initialised with ``defaults``.
+
+    :param filepath: where to save the settings
+    :type filepath: :class:`unicode`
+    :param defaults: dict of default settings
+    :type defaults: :class:`dict`
+
+
+    An appropriate instance is provided by :class:`Workflow` instances at
+    :attr:`Workflow.settings`.
+
+    """
+
+    def __init__(self, filepath, defaults=None):
+        """Create new :class:`Settings` object."""
+        super(Settings, self).__init__()
+        self._filepath = filepath
+        self._nosave = False
+        self._original = {}
+        if os.path.exists(self._filepath):
+            self._load()
+        elif defaults:
+            for key, val in defaults.items():
+                self[key] = val
+            self.save()  # save default settings
+
+    def _load(self):
+        """Load cached settings from JSON file `self._filepath`."""
+        self._nosave = True
+        d = {}
+        with open(self._filepath, 'rb') as file_obj:
+            for key, value in json.load(file_obj, encoding='utf-8').items():
+                d[key] = value
+        self.update(d)
+        self._original = deepcopy(d)
+        self._nosave = False
+
+    @uninterruptible
+    def save(self):
+        """Save settings to JSON file specified in ``self._filepath``.
+
+        If you're using this class via :attr:`Workflow.settings`, which
+        you probably are, ``self._filepath`` will be ``settings.json``
+        in your workflow's data directory (see :attr:`~Workflow.datadir`).
+        """
+        if self._nosave:
+            return
+        data = {}
+        data.update(self)
+        # for key, value in self.items():
+        #     data[key] = value
+        with LockFile(self._filepath):
+            with atomic_writer(self._filepath, 'wb') as file_obj:
+                json.dump(data, file_obj, sort_keys=True, indent=2,
+                          encoding='utf-8')
+
+    # dict methods
+    def __setitem__(self, key, value):
+        """Implement :class:`dict` interface."""
+        if self._original.get(key) != value:
+            super(Settings, self).__setitem__(key, value)
+            self.save()
+
+    def __delitem__(self, key):
+        """Implement :class:`dict` interface."""
+        super(Settings, self).__delitem__(key)
+        self.save()
+
+    def update(self, *args, **kwargs):
+        """Override :class:`dict` method to save on update."""
+        super(Settings, self).update(*args, **kwargs)
+        self.save()
+
+    def setdefault(self, key, value=None):
+        """Override :class:`dict` method to save on update."""
+        ret = super(Settings, self).setdefault(key, value)
+        self.save()
+        return ret
+
+
+class Workflow(object):
+    """Create new :class:`Workflow` instance.
+
+    :param default_settings: default workflow settings. If no settings file
+        exists, :class:`Workflow.settings` will be pre-populated with
+        ``default_settings``.
+    :type default_settings: :class:`dict`
+    :param update_settings: settings for updating your workflow from GitHub.
+        This must be a :class:`dict` that contains ``github_slug`` and
+        ``version`` keys. ``github_slug`` is of the form ``username/repo``
+        and ``version`` **must** correspond to the tag of a release. The
+        boolean ``prereleases`` key is optional and if ``True`` will
+        override the :ref:`magic argument <magic-arguments>` preference.
+        This is only recommended when the installed workflow is a pre-release.
+        See :ref:`updates` for more information.
+    :type update_settings: :class:`dict`
+    :param input_encoding: encoding of command line arguments
+    :type input_encoding: :class:`unicode`
+    :param normalization: normalisation to apply to CLI args.
+        See :meth:`Workflow.decode` for more details.
+    :type normalization: :class:`unicode`
+    :param capture_args: capture and act on ``workflow:*`` arguments. See
+        :ref:`Magic arguments <magic-arguments>` for details.
+    :type capture_args: :class:`Boolean`
+    :param libraries: sequence of paths to directories containing
+        libraries. These paths will be prepended to ``sys.path``.
+    :type libraries: :class:`tuple` or :class:`list`
+    :param help_url: URL to webpage where a user can ask for help with
+        the workflow, report bugs, etc. This could be the GitHub repo
+        or a page on AlfredForum.com. If your workflow throws an error,
+        this URL will be displayed in the log and Alfred's debugger. It can
+        also be opened directly in a web browser with the ``workflow:help``
+        :ref:`magic argument <magic-arguments>`.
+    :type help_url: :class:`unicode` or :class:`str`
+
+    """
+
+    # Which class to use to generate feedback items. You probably
+    # won't want to change this
+    item_class = Item
+
+    def __init__(self, default_settings=None, update_settings=None,
+                 input_encoding='utf-8', normalization='NFC',
+                 capture_args=True, libraries=None,
+                 help_url=None):
+        """Create new :class:`Workflow` object."""
+        self._default_settings = default_settings or {}
+        self._update_settings = update_settings or {}
+        self._input_encoding = input_encoding
+        self._normalizsation = normalization
+        self._capture_args = capture_args
+        self.help_url = help_url
+        self._workflowdir = None
+        self._settings_path = None
+        self._settings = None
+        self._bundleid = None
+        self._debugging = None
+        self._name = None
+        self._cache_serializer = 'cpickle'
+        self._data_serializer = 'cpickle'
+        self._info = None
+        self._info_loaded = False
+        self._logger = None
+        self._items = []
+        self._alfred_env = None
+        # Version number of the workflow
+        self._version = UNSET
+        # Version from last workflow run
+        self._last_version_run = UNSET
+        # Cache for regex patterns created for filter keys
+        self._search_pattern_cache = {}
+        # Magic arguments
+        #: The prefix for all magic arguments. Default is ``workflow:``
+        self.magic_prefix = 'workflow:'
+        #: Mapping of available magic arguments. The built-in magic
+        #: arguments are registered by default. To add your own magic arguments
+        #: (or override built-ins), add a key:value pair where the key is
+        #: what the user should enter (prefixed with :attr:`magic_prefix`)
+        #: and the value is a callable that will be called when the argument
+        #: is entered. If you would like to display a message in Alfred, the
+        #: function should return a ``unicode`` string.
+        #:
+        #: By default, the magic arguments documented
+        #: :ref:`here <magic-arguments>` are registered.
+        self.magic_arguments = {}
+
+        self._register_default_magic()
+
+        if libraries:
+            sys.path = libraries + sys.path
+
+    ####################################################################
+    # API methods
+    ####################################################################
+
+    # info.plist contents and alfred_* environment variables  ----------
+
+    @property
+    def alfred_version(self):
+        """Alfred version as :class:`~workflow.update.Version` object."""
+        from update import Version
+        return Version(self.alfred_env.get('version'))
+
+    @property
+    def alfred_env(self):
+        """Dict of Alfred's environmental variables minus ``alfred_`` prefix.
+
+        .. versionadded:: 1.7
+
+        The variables Alfred 2.4+ exports are:
+
+        ============================  =========================================
+        Variable                      Description
+        ============================  =========================================
+        alfred_debug                  Set to ``1`` if Alfred's debugger is
+                                      open, otherwise unset.
+        alfred_preferences            Path to Alfred.alfredpreferences
+                                      (where your workflows and settings are
+                                      stored).
+        alfred_preferences_localhash  Machine-specific preferences are stored
+                                      in ``Alfred.alfredpreferences/preferences/local/<hash>``
+                                      (see ``alfred_preferences`` above for
+                                      the path to ``Alfred.alfredpreferences``)
+        alfred_theme                  ID of selected theme
+        alfred_theme_background       Background colour of selected theme in
+                                      format ``rgba(r,g,b,a)``
+        alfred_theme_subtext          Show result subtext.
+                                      ``0`` = Always,
+                                      ``1`` = Alternative actions only,
+                                      ``2`` = Selected result only,
+                                      ``3`` = Never
+        alfred_version                Alfred version number, e.g. ``'2.4'``
+        alfred_version_build          Alfred build number, e.g. ``277``
+        alfred_workflow_bundleid      Bundle ID, e.g.
+                                      ``net.deanishe.alfred-mailto``
+        alfred_workflow_cache         Path to workflow's cache directory
+        alfred_workflow_data          Path to workflow's data directory
+        alfred_workflow_name          Name of current workflow
+        alfred_workflow_uid           UID of workflow
+        alfred_workflow_version       The version number specified in the
+                                      workflow configuration sheet/info.plist
+        ============================  =========================================
+
+        **Note:** all values are Unicode strings except ``version_build`` and
+        ``theme_subtext``, which are integers.
+
+        :returns: ``dict`` of Alfred's environmental variables without the
+            ``alfred_`` prefix, e.g. ``preferences``, ``workflow_data``.
+
+        """
+        if self._alfred_env is not None:
+            return self._alfred_env
+
+        data = {}
+
+        for key in (
+                'alfred_debug',
+                'alfred_preferences',
+                'alfred_preferences_localhash',
+                'alfred_theme',
+                'alfred_theme_background',
+                'alfred_theme_subtext',
+                'alfred_version',
+                'alfred_version_build',
+                'alfred_workflow_bundleid',
+                'alfred_workflow_cache',
+                'alfred_workflow_data',
+                'alfred_workflow_name',
+                'alfred_workflow_uid',
+                'alfred_workflow_version'):
+
+            value = os.getenv(key)
+
+            if isinstance(value, str):
+                if key in ('alfred_debug', 'alfred_version_build',
+                           'alfred_theme_subtext'):
+                    value = int(value)
+                else:
+                    value = self.decode(value)
+
+            data[key[7:]] = value
+
+        self._alfred_env = data
+
+        return self._alfred_env
+
+    @property
+    def info(self):
+        """:class:`dict` of ``info.plist`` contents."""
+        if not self._info_loaded:
+            self._load_info_plist()
+        return self._info
+
+    @property
+    def bundleid(self):
+        """Workflow bundle ID from environmental vars or ``info.plist``.
+
+        :returns: bundle ID
+        :rtype: ``unicode``
+
+        """
+        if not self._bundleid:
+            if self.alfred_env.get('workflow_bundleid'):
+                self._bundleid = self.alfred_env.get('workflow_bundleid')
+            else:
+                self._bundleid = unicode(self.info['bundleid'], 'utf-8')
+
+        return self._bundleid
+
+    @property
+    def debugging(self):
+        """Whether Alfred's debugger is open.
+
+        :returns: ``True`` if Alfred's debugger is open.
+        :rtype: ``bool``
+
+        """
+        if self._debugging is None:
+            if self.alfred_env.get('debug') == 1:
+                self._debugging = True
+            else:
+                self._debugging = False
+        return self._debugging
+
+    @property
+    def name(self):
+        """Workflow name from Alfred's environmental vars or ``info.plist``.
+
+        :returns: workflow name
+        :rtype: ``unicode``
+
+        """
+        if not self._name:
+            if self.alfred_env.get('workflow_name'):
+                self._name = self.decode(self.alfred_env.get('workflow_name'))
+            else:
+                self._name = self.decode(self.info['name'])
+
+        return self._name
+
+    @property
+    def version(self):
+        """Return the version of the workflow.
+
+        .. versionadded:: 1.9.10
+
+        Get the workflow version from environment variable,
+        the ``update_settings`` dict passed on
+        instantiation, the ``version`` file located in the workflow's
+        root directory or ``info.plist``. Return ``None`` if none
+        exists or :class:`ValueError` if the version number is invalid
+        (i.e. not semantic).
+
+        :returns: Version of the workflow (not Alfred-Workflow)
+        :rtype: :class:`~workflow.update.Version` object
+
+        """
+        if self._version is UNSET:
+
+            version = None
+            # environment variable has priority
+            if self.alfred_env.get('workflow_version'):
+                version = self.alfred_env['workflow_version']
+
+            # Try `update_settings`
+            elif self._update_settings:
+                version = self._update_settings.get('version')
+
+            # `version` file
+            if not version:
+                filepath = self.workflowfile('version')
+
+                if os.path.exists(filepath):
+                    with open(filepath, 'rb') as fileobj:
+                        version = fileobj.read()
+
+            # info.plist
+            if not version:
+                version = self.info.get('version')
+
+            if version:
+                from update import Version
+                version = Version(version)
+
+            self._version = version
+
+        return self._version
+
+    # Workflow utility methods -----------------------------------------
+
+    @property
+    def args(self):
+        """Return command line args as normalised unicode.
+
+        Args are decoded and normalised via :meth:`~Workflow.decode`.
+
+        The encoding and normalisation are the ``input_encoding`` and
+        ``normalization`` arguments passed to :class:`Workflow` (``UTF-8``
+        and ``NFC`` are the defaults).
+
+        If :class:`Workflow` is called with ``capture_args=True``
+        (the default), :class:`Workflow` will look for certain
+        ``workflow:*`` args and, if found, perform the corresponding
+        actions and exit the workflow.
+
+        See :ref:`Magic arguments <magic-arguments>` for details.
+
+        """
+        msg = None
+        args = [self.decode(arg) for arg in sys.argv[1:]]
+
+        # Handle magic args
+        if len(args) and self._capture_args:
+            for name in self.magic_arguments:
+                key = '{0}{1}'.format(self.magic_prefix, name)
+                if key in args:
+                    msg = self.magic_arguments[name]()
+
+            if msg:
+                self.logger.debug(msg)
+                if not sys.stdout.isatty():  # Show message in Alfred
+                    self.add_item(msg, valid=False, icon=ICON_INFO)
+                    self.send_feedback()
+                sys.exit(0)
+        return args
+
+    @property
+    def cachedir(self):
+        """Path to workflow's cache directory.
+
+        The cache directory is a subdirectory of Alfred's own cache directory
+        in ``~/Library/Caches``. The full path is:
+
+        ``~/Library/Caches/com.runningwithcrayons.Alfred-X/Workflow Data/<bundle id>``
+
+        ``Alfred-X`` may be ``Alfred-2`` or ``Alfred-3``.
+
+        :returns: full path to workflow's cache directory
+        :rtype: ``unicode``
+
+        """
+        if self.alfred_env.get('workflow_cache'):
+            dirpath = self.alfred_env.get('workflow_cache')
+
+        else:
+            dirpath = self._default_cachedir
+
+        return self._create(dirpath)
+
+    @property
+    def _default_cachedir(self):
+        """Alfred 2's default cache directory."""
+        return os.path.join(
+            os.path.expanduser(
+                '~/Library/Caches/com.runningwithcrayons.Alfred-2/'
+                'Workflow Data/'),
+            self.bundleid)
+
+    @property
+    def datadir(self):
+        """Path to workflow's data directory.
+
+        The data directory is a subdirectory of Alfred's own data directory in
+        ``~/Library/Application Support``. The full path is:
+
+        ``~/Library/Application Support/Alfred 2/Workflow Data/<bundle id>``
+
+        :returns: full path to workflow data directory
+        :rtype: ``unicode``
+
+        """
+        if self.alfred_env.get('workflow_data'):
+            dirpath = self.alfred_env.get('workflow_data')
+
+        else:
+            dirpath = self._default_datadir
+
+        return self._create(dirpath)
+
+    @property
+    def _default_datadir(self):
+        """Alfred 2's default data directory."""
+        return os.path.join(os.path.expanduser(
+            '~/Library/Application Support/Alfred 2/Workflow Data/'),
+            self.bundleid)
+
+    @property
+    def workflowdir(self):
+        """Path to workflow's root directory (where ``info.plist`` is).
+
+        :returns: full path to workflow root directory
+        :rtype: ``unicode``
+
+        """
+        if not self._workflowdir:
+            # Try the working directory first, then the directory
+            # the library is in. CWD will be the workflow root if
+            # a workflow is being run in Alfred
+            candidates = [
+                os.path.abspath(os.getcwdu()),
+                os.path.dirname(os.path.abspath(os.path.dirname(__file__)))]
+
+            # climb the directory tree until we find `info.plist`
+            for dirpath in candidates:
+
+                # Ensure directory path is Unicode
+                dirpath = self.decode(dirpath)
+
+                while True:
+                    if os.path.exists(os.path.join(dirpath, 'info.plist')):
+                        self._workflowdir = dirpath
+                        break
+
+                    elif dirpath == '/':
+                        # no `info.plist` found
+                        break
+
+                    # Check the parent directory
+                    dirpath = os.path.dirname(dirpath)
+
+                # No need to check other candidates
+                if self._workflowdir:
+                    break
+
+            if not self._workflowdir:
+                raise IOError("'info.plist' not found in directory tree")
+
+        return self._workflowdir
+
+    def cachefile(self, filename):
+        """Path to ``filename`` in workflow's cache directory.
+
+        Return absolute path to ``filename`` within your workflow's
+        :attr:`cache directory <Workflow.cachedir>`.
+
+        :param filename: basename of file
+        :type filename: ``unicode``
+        :returns: full path to file within cache directory
+        :rtype: ``unicode``
+
+        """
+        return os.path.join(self.cachedir, filename)
+
+    def datafile(self, filename):
+        """Path to ``filename`` in workflow's data directory.
+
+        Return absolute path to ``filename`` within your workflow's
+        :attr:`data directory <Workflow.datadir>`.
+
+        :param filename: basename of file
+        :type filename: ``unicode``
+        :returns: full path to file within data directory
+        :rtype: ``unicode``
+
+        """
+        return os.path.join(self.datadir, filename)
+
+    def workflowfile(self, filename):
+        """Return full path to ``filename`` in workflow's root directory.
+
+        :param filename: basename of file
+        :type filename: ``unicode``
+        :returns: full path to file within data directory
+        :rtype: ``unicode``
+
+        """
+        return os.path.join(self.workflowdir, filename)
+
+    @property
+    def logfile(self):
+        """Path to logfile.
+
+        :returns: path to logfile within workflow's cache directory
+        :rtype: ``unicode``
+
+        """
+        return self.cachefile('%s.log' % self.bundleid)
+
+    @property
+    def logger(self):
+        """Logger that logs to both console and a log file.
+
+        If Alfred's debugger is open, log level will be ``DEBUG``,
+        else it will be ``INFO``.
+
+        Use :meth:`open_log` to open the log file in Console.
+
+        :returns: an initialised :class:`~logging.Logger`
+
+        """
+        if self._logger:
+            return self._logger
+
+        # Initialise new logger and optionally handlers
+        logger = logging.getLogger('workflow')
+
+        if not len(logger.handlers):  # Only add one set of handlers
+
+            fmt = logging.Formatter(
+                '%(asctime)s %(filename)s:%(lineno)s'
+                ' %(levelname)-8s %(message)s',
+                datefmt='%H:%M:%S')
+
+            logfile = logging.handlers.RotatingFileHandler(
+                self.logfile,
+                maxBytes=1024 * 1024,
+                backupCount=1)
+            logfile.setFormatter(fmt)
+            logger.addHandler(logfile)
+
+            console = logging.StreamHandler()
+            console.setFormatter(fmt)
+            logger.addHandler(console)
+
+        if self.debugging:
+            logger.setLevel(logging.DEBUG)
+        else:
+            logger.setLevel(logging.INFO)
+
+        self._logger = logger
+
+        return self._logger
+
+    @logger.setter
+    def logger(self, logger):
+        """Set a custom logger.
+
+        :param logger: The logger to use
+        :type logger: `~logging.Logger` instance
+
+        """
+        self._logger = logger
+
+    @property
+    def settings_path(self):
+        """Path to settings file within workflow's data directory.
+
+        :returns: path to ``settings.json`` file
+        :rtype: ``unicode``
+
+        """
+        if not self._settings_path:
+            self._settings_path = self.datafile('settings.json')
+        return self._settings_path
+
+    @property
+    def settings(self):
+        """Return a dictionary subclass that saves itself when changed.
+
+        See :ref:`manual-settings` in the :ref:`user-manual` for more
+        information on how to use :attr:`settings` and **important
+        limitations** on what it can do.
+
+        :returns: :class:`~workflow.workflow.Settings` instance
+            initialised from the data in JSON file at
+            :attr:`settings_path` or if that doesn't exist, with the
+            ``default_settings`` :class:`dict` passed to
+            :class:`Workflow` on instantiation.
+        :rtype: :class:`~workflow.workflow.Settings` instance
+
+        """
+        if not self._settings:
+            self.logger.debug('Reading settings from `{0}` ...'.format(
+                              self.settings_path))
+            self._settings = Settings(self.settings_path,
+                                      self._default_settings)
+        return self._settings
+
+    @property
+    def cache_serializer(self):
+        """Name of default cache serializer.
+
+        .. versionadded:: 1.8
+
+        This serializer is used by :meth:`cache_data()` and
+        :meth:`cached_data()`
+
+        See :class:`SerializerManager` for details.
+
+        :returns: serializer name
+        :rtype: ``unicode``
+
+        """
+        return self._cache_serializer
+
+    @cache_serializer.setter
+    def cache_serializer(self, serializer_name):
+        """Set the default cache serialization format.
+
+        .. versionadded:: 1.8
+
+        This serializer is used by :meth:`cache_data()` and
+        :meth:`cached_data()`
+
+        The specified serializer must already by registered with the
+        :class:`SerializerManager` at `~workflow.workflow.manager`,
+        otherwise a :class:`ValueError` will be raised.
+
+        :param serializer_name: Name of default serializer to use.
+        :type serializer_name:
+
+        """
+        if manager.serializer(serializer_name) is None:
+            raise ValueError(
+                'Unknown serializer : `{0}`. Register your serializer '
+                'with `manager` first.'.format(serializer_name))
+
+        self.logger.debug(
+            'default cache serializer set to `{0}`'.format(serializer_name))
+
+        self._cache_serializer = serializer_name
+
+    @property
+    def data_serializer(self):
+        """Name of default data serializer.
+
+        .. versionadded:: 1.8
+
+        This serializer is used by :meth:`store_data()` and
+        :meth:`stored_data()`
+
+        See :class:`SerializerManager` for details.
+
+        :returns: serializer name
+        :rtype: ``unicode``
+
+        """
+        return self._data_serializer
+
+    @data_serializer.setter
+    def data_serializer(self, serializer_name):
+        """Set the default cache serialization format.
+
+        .. versionadded:: 1.8
+
+        This serializer is used by :meth:`store_data()` and
+        :meth:`stored_data()`
+
+        The specified serializer must already by registered with the
+        :class:`SerializerManager` at `~workflow.workflow.manager`,
+        otherwise a :class:`ValueError` will be raised.
+
+        :param serializer_name: Name of serializer to use by default.
+
+        """
+        if manager.serializer(serializer_name) is None:
+            raise ValueError(
+                'Unknown serializer : `{0}`. Register your serializer '
+                'with `manager` first.'.format(serializer_name))
+
+        self.logger.debug(
+            'default data serializer set to `{0}`'.format(serializer_name))
+
+        self._data_serializer = serializer_name
+
+    def stored_data(self, name):
+        """Retrieve data from data directory.
+
+        Returns ``None`` if there are no data stored under ``name``.
+
+        .. versionadded:: 1.8
+
+        :param name: name of datastore
+
+        """
+        metadata_path = self.datafile('.{0}.alfred-workflow'.format(name))
+
+        if not os.path.exists(metadata_path):
+            self.logger.debug('No data stored for `{0}`'.format(name))
+            return None
+
+        with open(metadata_path, 'rb') as file_obj:
+            serializer_name = file_obj.read().strip()
+
+        serializer = manager.serializer(serializer_name)
+
+        if serializer is None:
+            raise ValueError(
+                'Unknown serializer `{0}`. Register a corresponding '
+                'serializer with `manager.register()` '
+                'to load this data.'.format(serializer_name))
+
+        self.logger.debug('Data `{0}` stored in `{1}` format'.format(
+            name, serializer_name))
+
+        filename = '{0}.{1}'.format(name, serializer_name)
+        data_path = self.datafile(filename)
+
+        if not os.path.exists(data_path):
+            self.logger.debug('No data stored for `{0}`'.format(name))
+            if os.path.exists(metadata_path):
+                os.unlink(metadata_path)
+
+            return None
+
+        with open(data_path, 'rb') as file_obj:
+            data = serializer.load(file_obj)
+
+        self.logger.debug('Stored data loaded from : {0}'.format(data_path))
+
+        return data
+
+    def store_data(self, name, data, serializer=None):
+        """Save data to data directory.
+
+        .. versionadded:: 1.8
+
+        If ``data`` is ``None``, the datastore will be deleted.
+
+        Note that the datastore does NOT support mutliple threads.
+
+        :param name: name of datastore
+        :param data: object(s) to store. **Note:** some serializers
+            can only handled certain types of data.
+        :param serializer: name of serializer to use. If no serializer
+            is specified, the default will be used. See
+            :class:`SerializerManager` for more information.
+        :returns: data in datastore or ``None``
+
+        """
+        # Ensure deletion is not interrupted by SIGTERM
+        @uninterruptible
+        def delete_paths(paths):
+            """Clear one or more data stores"""
+            for path in paths:
+                if os.path.exists(path):
+                    os.unlink(path)
+                    self.logger.debug('Deleted data file : {0}'.format(path))
+
+        serializer_name = serializer or self.data_serializer
+
+        # In order for `stored_data()` to be able to load data stored with
+        # an arbitrary serializer, yet still have meaningful file extensions,
+        # the format (i.e. extension) is saved to an accompanying file
+        metadata_path = self.datafile('.{0}.alfred-workflow'.format(name))
+        filename = '{0}.{1}'.format(name, serializer_name)
+        data_path = self.datafile(filename)
+
+        if data_path == self.settings_path:
+            raise ValueError(
+                'Cannot save data to' +
+                '`{0}` with format `{1}`. '.format(name, serializer_name) +
+                "This would overwrite Alfred-Workflow's settings file.")
+
+        serializer = manager.serializer(serializer_name)
+
+        if serializer is None:
+            raise ValueError(
+                'Invalid serializer `{0}`. Register your serializer with '
+                '`manager.register()` first.'.format(serializer_name))
+
+        if data is None:  # Delete cached data
+            delete_paths((metadata_path, data_path))
+            return
+
+        # Ensure write is not interrupted by SIGTERM
+        @uninterruptible
+        def _store():
+            # Save file extension
+            with atomic_writer(metadata_path, 'wb') as file_obj:
+                file_obj.write(serializer_name)
+
+            with atomic_writer(data_path, 'wb') as file_obj:
+                serializer.dump(data, file_obj)
+
+        _store()
+
+        self.logger.debug('Stored data saved at : {0}'.format(data_path))
+
+    def cached_data(self, name, data_func=None, max_age=60):
+        """Return cached data if younger than ``max_age`` seconds.
+
+        Retrieve data from cache or re-generate and re-cache data if
+        stale/non-existant. If ``max_age`` is 0, return cached data no
+        matter how old.
+
+        :param name: name of datastore
+        :param data_func: function to (re-)generate data.
+        :type data_func: ``callable``
+        :param max_age: maximum age of cached data in seconds
+        :type max_age: ``int``
+        :returns: cached data, return value of ``data_func`` or ``None``
+            if ``data_func`` is not set
+
+        """
+        serializer = manager.serializer(self.cache_serializer)
+
+        cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer))
+        age = self.cached_data_age(name)
+
+        if (age < max_age or max_age == 0) and os.path.exists(cache_path):
+
+            with open(cache_path, 'rb') as file_obj:
+                self.logger.debug('Loading cached data from : %s',
+                                  cache_path)
+                return serializer.load(file_obj)
+
+        if not data_func:
+            return None
+
+        data = data_func()
+        self.cache_data(name, data)
+
+        return data
+
+    def cache_data(self, name, data):
+        """Save ``data`` to cache under ``name``.
+
+        If ``data`` is ``None``, the corresponding cache file will be
+        deleted.
+
+        :param name: name of datastore
+        :param data: data to store. This may be any object supported by
+                the cache serializer
+
+        """
+        serializer = manager.serializer(self.cache_serializer)
+
+        cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer))
+
+        if data is None:
+            if os.path.exists(cache_path):
+                os.unlink(cache_path)
+                self.logger.debug('Deleted cache file : %s', cache_path)
+            return
+
+        with atomic_writer(cache_path, 'wb') as file_obj:
+            serializer.dump(data, file_obj)
+
+        self.logger.debug('Cached data saved at : %s', cache_path)
+
+    def cached_data_fresh(self, name, max_age):
+        """Whether cache `name` is less than `max_age` seconds old.
+
+        :param name: name of datastore
+        :param max_age: maximum age of data in seconds
+        :type max_age: ``int``
+        :returns: ``True`` if data is less than ``max_age`` old, else
+            ``False``
+
+        """
+        age = self.cached_data_age(name)
+
+        if not age:
+            return False
+
+        return age < max_age
+
+    def cached_data_age(self, name):
+        """Return age in seconds of cache `name` or 0 if cache doesn't exist.
+
+        :param name: name of datastore
+        :type name: ``unicode``
+        :returns: age of datastore in seconds
+        :rtype: ``int``
+
+        """
+        cache_path = self.cachefile('%s.%s' % (name, self.cache_serializer))
+
+        if not os.path.exists(cache_path):
+            return 0
+
+        return time.time() - os.stat(cache_path).st_mtime
+
+    def filter(self, query, items, key=lambda x: x, ascending=False,
+               include_score=False, min_score=0, max_results=0,
+               match_on=MATCH_ALL, fold_diacritics=True):
+        """Fuzzy search filter. Returns list of ``items`` that match ``query``.
+
+        ``query`` is case-insensitive. Any item that does not contain the
+        entirety of ``query`` is rejected.
+
+        .. warning::
+
+            If ``query`` is an empty string or contains only whitespace,
+            a :class:`ValueError` will be raised.
+
+        :param query: query to test items against
+        :type query: ``unicode``
+        :param items: iterable of items to test
+        :type items: ``list`` or ``tuple``
+        :param key: function to get comparison key from ``items``.
+            Must return a ``unicode`` string. The default simply returns
+            the item.
+        :type key: ``callable``
+        :param ascending: set to ``True`` to get worst matches first
+        :type ascending: ``Boolean``
+        :param include_score: Useful for debugging the scoring algorithm.
+            If ``True``, results will be a list of tuples
+            ``(item, score, rule)``.
+        :type include_score: ``Boolean``
+        :param min_score: If non-zero, ignore results with a score lower
+            than this.
+        :type min_score: ``int``
+        :param max_results: If non-zero, prune results list to this length.
+        :type max_results: ``int``
+        :param match_on: Filter option flags. Bitwise-combined list of
+            ``MATCH_*`` constants (see below).
+        :type match_on: ``int``
+        :param fold_diacritics: Convert search keys to ASCII-only
+            characters if ``query`` only contains ASCII characters.
+        :type fold_diacritics: ``Boolean``
+        :returns: list of ``items`` matching ``query`` or list of
+            ``(item, score, rule)`` `tuples` if ``include_score`` is ``True``.
+            ``rule`` is the ``MATCH_*`` rule that matched the item.
+        :rtype: ``list``
+
+        **Matching rules**
+
+        By default, :meth:`filter` uses all of the following flags (i.e.
+        :const:`MATCH_ALL`). The tests are always run in the given order:
+
+        1. :const:`MATCH_STARTSWITH`
+            Item search key starts with ``query`` (case-insensitive).
+        2. :const:`MATCH_CAPITALS`
+            The list of capital letters in item search key starts with
+            ``query`` (``query`` may be lower-case). E.g., ``of``
+            would match ``OmniFocus``, ``gc`` would match ``Google Chrome``.
+        3. :const:`MATCH_ATOM`
+            Search key is split into "atoms" on non-word characters
+            (.,-,' etc.). Matches if ``query`` is one of these atoms
+            (case-insensitive).
+        4. :const:`MATCH_INITIALS_STARTSWITH`
+            Initials are the first characters of the above-described
+            "atoms" (case-insensitive).
+        5. :const:`MATCH_INITIALS_CONTAIN`
+            ``query`` is a substring of the above-described initials.
+        6. :const:`MATCH_INITIALS`
+            Combination of (4) and (5).
+        7. :const:`MATCH_SUBSTRING`
+            ``query`` is a substring of item search key (case-insensitive).
+        8. :const:`MATCH_ALLCHARS`
+            All characters in ``query`` appear in item search key in
+            the same order (case-insensitive).
+        9. :const:`MATCH_ALL`
+            Combination of all the above.
+
+
+        :const:`MATCH_ALLCHARS` is considerably slower than the other
+        tests and provides much less accurate results.
+
+        **Examples:**
+
+        To ignore :const:`MATCH_ALLCHARS` (tends to provide the worst
+        matches and is expensive to run), use
+        ``match_on=MATCH_ALL ^ MATCH_ALLCHARS``.
+
+        To match only on capitals, use ``match_on=MATCH_CAPITALS``.
+
+        To match only on startswith and substring, use
+        ``match_on=MATCH_STARTSWITH | MATCH_SUBSTRING``.
+
+        **Diacritic folding**
+
+        .. versionadded:: 1.3
+
+        If ``fold_diacritics`` is ``True`` (the default), and ``query``
+        contains only ASCII characters, non-ASCII characters in search keys
+        will be converted to ASCII equivalents (e.g. **ü** -> **u**,
+        **ß** -> **ss**, **é** -> **e**).
+
+        See :const:`ASCII_REPLACEMENTS` for all replacements.
+
+        If ``query`` contains non-ASCII characters, search keys will not be
+        altered.
+
+        """
+        if not query:
+            raise ValueError('Empty `query`')
+
+        # Remove preceding/trailing spaces
+        query = query.strip()
+
+        if not query:
+            raise ValueError('`query` contains only whitespace')
+
+        # Use user override if there is one
+        fold_diacritics = self.settings.get('__workflow_diacritic_folding',
+                                            fold_diacritics)
+
+        results = []
+
+        for item in items:
+            skip = False
+            score = 0
+            words = [s.strip() for s in query.split(' ')]
+            value = key(item).strip()
+            if value == '':
+                continue
+            for word in words:
+                if word == '':
+                    continue
+                s, rule = self._filter_item(value, word, match_on,
+                                            fold_diacritics)
+
+                if not s:  # Skip items that don't match part of the query
+                    skip = True
+                score += s
+
+            if skip:
+                continue
+
+            if score:
+                # use "reversed" `score` (i.e. highest becomes lowest) and
+                # `value` as sort key. This means items with the same score
+                # will be sorted in alphabetical not reverse alphabetical order
+                results.append(((100.0 / score, value.lower(), score),
+                                (item, score, rule)))
+
+        # sort on keys, then discard the keys
+        results.sort(reverse=ascending)
+        results = [t[1] for t in results]
+
+        if min_score:
+            results = [r for r in results if r[1] > min_score]
+
+        if max_results and len(results) > max_results:
+            results = results[:max_results]
+
+        # return list of ``(item, score, rule)``
+        if include_score:
+            return results
+        # just return list of items
+        return [t[0] for t in results]
+
+    def _filter_item(self, value, query, match_on, fold_diacritics):
+        """Filter ``value`` against ``query`` using rules ``match_on``.
+
+        :returns: ``(score, rule)``
+
+        """
+        query = query.lower()
+
+        if not isascii(query):
+            fold_diacritics = False
+
+        if fold_diacritics:
+            value = self.fold_to_ascii(value)
+
+        # pre-filter any items that do not contain all characters
+        # of ``query`` to save on running several more expensive tests
+        if not set(query) <= set(value.lower()):
+
+            return (0, None)
+
+        # item starts with query
+        if match_on & MATCH_STARTSWITH and value.lower().startswith(query):
+            score = 100.0 - (len(value) / len(query))
+
+            return (score, MATCH_STARTSWITH)
+
+        # query matches capitalised letters in item,
+        # e.g. of = OmniFocus
+        if match_on & MATCH_CAPITALS:
+            initials = ''.join([c for c in value if c in INITIALS])
+            if initials.lower().startswith(query):
+                score = 100.0 - (len(initials) / len(query))
+
+                return (score, MATCH_CAPITALS)
+
+        # split the item into "atoms", i.e. words separated by
+        # spaces or other non-word characters
+        if (match_on & MATCH_ATOM or
+                match_on & MATCH_INITIALS_CONTAIN or
+                match_on & MATCH_INITIALS_STARTSWITH):
+            atoms = [s.lower() for s in split_on_delimiters(value)]
+            # print('atoms : %s  -->  %s' % (value, atoms))
+            # initials of the atoms
+            initials = ''.join([s[0] for s in atoms if s])
+
+        if match_on & MATCH_ATOM:
+            # is `query` one of the atoms in item?
+            # similar to substring, but scores more highly, as it's
+            # a word within the item
+            if query in atoms:
+                score = 100.0 - (len(value) / len(query))
+
+                return (score, MATCH_ATOM)
+
+        # `query` matches start (or all) of the initials of the
+        # atoms, e.g. ``himym`` matches "How I Met Your Mother"
+        # *and* "how i met your mother" (the ``capitals`` rule only
+        # matches the former)
+        if (match_on & MATCH_INITIALS_STARTSWITH and
+                initials.startswith(query)):
+            score = 100.0 - (len(initials) / len(query))
+
+            return (score, MATCH_INITIALS_STARTSWITH)
+
+        # `query` is a substring of initials, e.g. ``doh`` matches
+        # "The Dukes of Hazzard"
+        elif (match_on & MATCH_INITIALS_CONTAIN and
+                query in initials):
+            score = 95.0 - (len(initials) / len(query))
+
+            return (score, MATCH_INITIALS_CONTAIN)
+
+        # `query` is a substring of item
+        if match_on & MATCH_SUBSTRING and query in value.lower():
+            score = 90.0 - (len(value) / len(query))
+
+            return (score, MATCH_SUBSTRING)
+
+        # finally, assign a score based on how close together the
+        # characters in `query` are in item.
+        if match_on & MATCH_ALLCHARS:
+            search = self._search_for_query(query)
+            match = search(value)
+            if match:
+                score = 100.0 / ((1 + match.start()) *
+                                 (match.end() - match.start() + 1))
+
+                return (score, MATCH_ALLCHARS)
+
+        # Nothing matched
+        return (0, None)
+
+    def _search_for_query(self, query):
+        if query in self._search_pattern_cache:
+            return self._search_pattern_cache[query]
+
+        # Build pattern: include all characters
+        pattern = []
+        for c in query:
+            # pattern.append('[^{0}]*{0}'.format(re.escape(c)))
+            pattern.append('.*?{0}'.format(re.escape(c)))
+        pattern = ''.join(pattern)
+        search = re.compile(pattern, re.IGNORECASE).search
+
+        self._search_pattern_cache[query] = search
+        return search
+
+    def run(self, func, text_errors=False):
+        """Call ``func`` to run your workflow.
+
+        :param func: Callable to call with ``self`` (i.e. the :class:`Workflow`
+            instance) as first argument.
+        :param text_errors: Emit error messages in plain text, not in
+            Alfred's XML/JSON feedback format. Use this when you're not
+            running Alfred-Workflow in a Script Filter and would like
+            to pass the error message to, say, a notification.
+        :type text_errors: ``Boolean``
+
+        ``func`` will be called with :class:`Workflow` instance as first
+        argument.
+
+        ``func`` should be the main entry point to your workflow.
+
+        Any exceptions raised will be logged and an error message will be
+        output to Alfred.
+
+        """
+        start = time.time()
+
+        # Call workflow's entry function/method within a try-except block
+        # to catch any errors and display an error message in Alfred
+        try:
+
+            if self.version:
+                self.logger.debug(
+                    'Workflow version : {0}'.format(self.version))
+
+            # Run update check if configured for self-updates.
+            # This call has to go in the `run` try-except block, as it will
+            # initialise `self.settings`, which will raise an exception
+            # if `settings.json` isn't valid.
+
+            if self._update_settings:
+                self.check_update()
+
+            # Run workflow's entry function/method
+            func(self)
+
+            # Set last version run to current version after a successful
+            # run
+            self.set_last_version()
+
+        except Exception as err:
+            self.logger.exception(err)
+            if self.help_url:
+                self.logger.info(
+                    'For assistance, see: {0}'.format(self.help_url))
+
+            if not sys.stdout.isatty():  # Show error in Alfred
+                if text_errors:
+                    print(unicode(err).encode('utf-8'), end='')
+                else:
+                    self._items = []
+                    if self._name:
+                        name = self._name
+                    elif self._bundleid:
+                        name = self._bundleid
+                    else:  # pragma: no cover
+                        name = os.path.dirname(__file__)
+                    self.add_item("Error in workflow '%s'" % name,
+                                  unicode(err),
+                                  icon=ICON_ERROR)
+                    self.send_feedback()
+            return 1
+
+        finally:
+            self.logger.debug('Workflow finished in {0:0.3f} seconds.'.format(
+                time.time() - start))
+
+        return 0
+
+    # Alfred feedback methods ------------------------------------------
+
+    def add_item(self, title, subtitle='', modifier_subtitles=None, arg=None,
+                 autocomplete=None, valid=False, uid=None, icon=None,
+                 icontype=None, type=None, largetext=None, copytext=None,
+                 quicklookurl=None):
+        """Add an item to be output to Alfred.
+
+        :param title: Title shown in Alfred
+        :type title: ``unicode``
+        :param subtitle: Subtitle shown in Alfred
+        :type subtitle: ``unicode``
+        :param modifier_subtitles: Subtitles shown when modifier
+            (CMD, OPT etc.) is pressed. Use a ``dict`` with the lowercase
+            keys ``cmd``, ``ctrl``, ``shift``, ``alt`` and ``fn``
+        :type modifier_subtitles: ``dict``
+        :param arg: Argument passed by Alfred as ``{query}`` when item is
+            actioned
+        :type arg: ``unicode``
+        :param autocomplete: Text expanded in Alfred when item is TABbed
+        :type autocomplete: ``unicode``
+        :param valid: Whether or not item can be actioned
+        :type valid: ``Boolean``
+        :param uid: Used by Alfred to remember/sort items
+        :type uid: ``unicode``
+        :param icon: Filename of icon to use
+        :type icon: ``unicode``
+        :param icontype: Type of icon. Must be one of ``None`` , ``'filetype'``
+           or ``'fileicon'``. Use ``'filetype'`` when ``icon`` is a filetype
+           such as ``'public.folder'``. Use ``'fileicon'`` when you wish to
+           use the icon of the file specified as ``icon``, e.g.
+           ``icon='/Applications/Safari.app', icontype='fileicon'``.
+           Leave as `None` if ``icon`` points to an actual
+           icon file.
+        :type icontype: ``unicode``
+        :param type: Result type. Currently only ``'file'`` is supported
+            (by Alfred). This will tell Alfred to enable file actions for
+            this item.
+        :type type: ``unicode``
+        :param largetext: Text to be displayed in Alfred's large text box
+            if user presses CMD+L on item.
+        :type largetext: ``unicode``
+        :param copytext: Text to be copied to pasteboard if user presses
+            CMD+C on item.
+        :type copytext: ``unicode``
+        :param quicklookurl: URL to be displayed using Alfred's Quick Look
+            feature (tapping ``SHIFT`` or ``⌘+Y`` on a result).
+        :type quicklookurl: ``unicode``
+        :returns: :class:`Item` instance
+
+        See the :ref:`script-filter-results` section of the documentation
+        for a detailed description of what the various parameters do and how
+        they interact with one another.
+
+        See :ref:`icons` for a list of the supported system icons.
+
+        .. note::
+
+            Although this method returns an :class:`Item` instance, you don't
+            need to hold onto it or worry about it. All generated :class:`Item`
+            instances are also collected internally and sent to Alfred when
+            :meth:`send_feedback` is called.
+
+            The generated :class:`Item` is only returned in case you want to
+            edit it or do something with it other than send it to Alfred.
+
+        """
+        item = self.item_class(title, subtitle, modifier_subtitles, arg,
+                               autocomplete, valid, uid, icon, icontype, type,
+                               largetext, copytext, quicklookurl)
+        self._items.append(item)
+        return item
+
+    def send_feedback(self):
+        """Print stored items to console/Alfred as XML."""
+        root = ET.Element('items')
+        for item in self._items:
+            root.append(item.elem)
+        sys.stdout.write('<?xml version="1.0" encoding="utf-8"?>\n')
+        sys.stdout.write(ET.tostring(root).encode('utf-8'))
+        sys.stdout.flush()
+
+    ####################################################################
+    # Updating methods
+    ####################################################################
+
+    @property
+    def first_run(self):
+        """Return ``True`` if it's the first time this version has run.
+
+        .. versionadded:: 1.9.10
+
+        Raises a :class:`ValueError` if :attr:`version` isn't set.
+
+        """
+        if not self.version:
+            raise ValueError('No workflow version set')
+
+        if not self.last_version_run:
+            return True
+
+        return self.version != self.last_version_run
+
+    @property
+    def last_version_run(self):
+        """Return version of last version to run (or ``None``).
+
+        .. versionadded:: 1.9.10
+
+        :returns: :class:`~workflow.update.Version` instance
+            or ``None``
+
+        """
+        if self._last_version_run is UNSET:
+
+            version = self.settings.get('__workflow_last_version')
+            if version:
+                from update import Version
+                version = Version(version)
+
+            self._last_version_run = version
+
+        self.logger.debug('Last run version : {0}'.format(
+                          self._last_version_run))
+
+        return self._last_version_run
+
+    def set_last_version(self, version=None):
+        """Set :attr:`last_version_run` to current version.
+
+        .. versionadded:: 1.9.10
+
+        :param version: version to store (default is current version)
+        :type version: :class:`~workflow.update.Version` instance
+            or ``unicode``
+        :returns: ``True`` if version is saved, else ``False``
+
+        """
+        if not version:
+            if not self.version:
+                self.logger.warning(
+                    "Can't save last version: workflow has no version")
+                return False
+
+            version = self.version
+
+        if isinstance(version, basestring):
+            from update import Version
+            version = Version(version)
+
+        self.settings['__workflow_last_version'] = str(version)
+
+        self.logger.debug('Set last run version : {0}'.format(version))
+
+        return True
+
+    @property
+    def update_available(self):
+        """Whether an update is available.
+
+        .. versionadded:: 1.9
+
+        See :ref:`manual-updates` in the :ref:`user-manual` for detailed
+        information on how to enable your workflow to update itself.
+
+        :returns: ``True`` if an update is available, else ``False``
+
+        """
+        # Create a new workflow object to ensure standard serialiser
+        # is used (update.py is called without the user's settings)
+        update_data = Workflow().cached_data('__workflow_update_status',
+                                             max_age=0)
+
+        self.logger.debug('update_data : {0}'.format(update_data))
+
+        if not update_data or not update_data.get('available'):
+            return False
+
+        return update_data['available']
+
+    @property
+    def prereleases(self):
+        """Whether workflow should update to pre-release versions.
+
+        .. versionadded:: 1.16
+
+        :returns: ``True`` if pre-releases are enabled with the :ref:`magic
+            argument <magic-arguments>` or the ``update_settings`` dict, else
+            ``False``.
+
+        """
+        if self._update_settings.get('prereleases'):
+            return True
+
+        return self.settings.get('__workflow_prereleases') or False
+
+    def check_update(self, force=False):
+        """Call update script if it's time to check for a new release.
+
+        .. versionadded:: 1.9
+
+        The update script will be run in the background, so it won't
+        interfere in the execution of your workflow.
+
+        See :ref:`manual-updates` in the :ref:`user-manual` for detailed
+        information on how to enable your workflow to update itself.
+
+        :param force: Force update check
+        :type force: ``Boolean``
+
+        """
+        frequency = self._update_settings.get('frequency',
+                                              DEFAULT_UPDATE_FREQUENCY)
+
+        if not force and not self.settings.get('__workflow_autoupdate', True):
+            self.logger.debug('Auto update turned off by user')
+            return
+
+        # Check for new version if it's time
+        if (force or not self.cached_data_fresh(
+                '__workflow_update_status', frequency * 86400)):
+
+            github_slug = self._update_settings['github_slug']
+            # version = self._update_settings['version']
+            version = str(self.version)
+
+            from background import run_in_background
+
+            # update.py is adjacent to this file
+            update_script = os.path.join(os.path.dirname(__file__),
+                                         b'update.py')
+
+            cmd = ['/usr/bin/python', update_script, 'check', github_slug,
+                   version]
+
+            if self.prereleases:
+                cmd.append('--prereleases')
+
+            self.logger.info('Checking for update ...')
+
+            run_in_background('__workflow_update_check', cmd)
+
+        else:
+            self.logger.debug('Update check not due')
+
+    def start_update(self):
+        """Check for update and download and install new workflow file.
+
+        .. versionadded:: 1.9
+
+        See :ref:`manual-updates` in the :ref:`user-manual` for detailed
+        information on how to enable your workflow to update itself.
+
+        :returns: ``True`` if an update is available and will be
+            installed, else ``False``
+
+        """
+        import update
+
+        github_slug = self._update_settings['github_slug']
+        # version = self._update_settings['version']
+        version = str(self.version)
+
+        if not update.check_update(github_slug, version, self.prereleases):
+            return False
+
+        from background import run_in_background
+
+        # update.py is adjacent to this file
+        update_script = os.path.join(os.path.dirname(__file__),
+                                     b'update.py')
+
+        cmd = ['/usr/bin/python', update_script, 'install', github_slug,
+               version]
+
+        if self.prereleases:
+            cmd.append('--prereleases')
+
+        self.logger.debug('Downloading update ...')
+        run_in_background('__workflow_update_install', cmd)
+
+        return True
+
+    ####################################################################
+    # Keychain password storage methods
+    ####################################################################
+
+    def save_password(self, account, password, service=None):
+        """Save account credentials.
+
+        If the account exists, the old password will first be deleted
+        (Keychain throws an error otherwise).
+
+        If something goes wrong, a :class:`KeychainError` exception will
+        be raised.
+
+        :param account: name of the account the password is for, e.g.
+            "Pinboard"
+        :type account: ``unicode``
+        :param password: the password to secure
+        :type password: ``unicode``
+        :param service: Name of the service. By default, this is the
+            workflow's bundle ID
+        :type service: ``unicode``
+
+        """
+        if not service:
+            service = self.bundleid
+
+        try:
+            self._call_security('add-generic-password', service, account,
+                                '-w', password)
+            self.logger.debug('Saved password : %s:%s', service, account)
+
+        except PasswordExists:
+            self.logger.debug('Password exists : %s:%s', service, account)
+            current_password = self.get_password(account, service)
+
+            if current_password == password:
+                self.logger.debug('Password unchanged')
+
+            else:
+                self.delete_password(account, service)
+                self._call_security('add-generic-password', service,
+                                    account, '-w', password)
+                self.logger.debug('save_password : %s:%s', service, account)
+
+    def get_password(self, account, service=None):
+        """Retrieve the password saved at ``service/account``.
+
+        Raise :class:`PasswordNotFound` exception if password doesn't exist.
+
+        :param account: name of the account the password is for, e.g.
+            "Pinboard"
+        :type account: ``unicode``
+        :param service: Name of the service. By default, this is the workflow's
+                        bundle ID
+        :type service: ``unicode``
+        :returns: account password
+        :rtype: ``unicode``
+
+        """
+        if not service:
+            service = self.bundleid
+
+        output = self._call_security('find-generic-password', service,
+                                     account, '-g')
+
+        # Parsing of `security` output is adapted from python-keyring
+        # by Jason R. Coombs
+        # https://pypi.python.org/pypi/keyring
+        m = re.search(
+            r'password:\s*(?:0x(?P<hex>[0-9A-F]+)\s*)?(?:"(?P<pw>.*)")?',
+            output)
+
+        if m:
+            groups = m.groupdict()
+            h = groups.get('hex')
+            password = groups.get('pw')
+            if h:
+                password = unicode(binascii.unhexlify(h), 'utf-8')
+
+        self.logger.debug('Got password : %s:%s', service, account)
+
+        return password
+
+    def delete_password(self, account, service=None):
+        """Delete the password stored at ``service/account``.
+
+        Raise :class:`PasswordNotFound` if account is unknown.
+
+        :param account: name of the account the password is for, e.g.
+            "Pinboard"
+        :type account: ``unicode``
+        :param service: Name of the service. By default, this is the workflow's
+                        bundle ID
+        :type service: ``unicode``
+
+        """
+        if not service:
+            service = self.bundleid
+
+        self._call_security('delete-generic-password', service, account)
+
+        self.logger.debug('Deleted password : %s:%s', service, account)
+
+    ####################################################################
+    # Methods for workflow:* magic args
+    ####################################################################
+
+    def _register_default_magic(self):
+        """Register the built-in magic arguments."""
+        # TODO: refactor & simplify
+        # Wrap callback and message with callable
+        def callback(func, msg):
+            def wrapper():
+                func()
+                return msg
+
+            return wrapper
+
+        self.magic_arguments['delcache'] = callback(self.clear_cache,
+                                                    'Deleted workflow cache')
+        self.magic_arguments['deldata'] = callback(self.clear_data,
+                                                   'Deleted workflow data')
+        self.magic_arguments['delsettings'] = callback(
+            self.clear_settings, 'Deleted workflow settings')
+        self.magic_arguments['reset'] = callback(self.reset,
+                                                 'Reset workflow')
+        self.magic_arguments['openlog'] = callback(self.open_log,
+                                                   'Opening workflow log file')
+        self.magic_arguments['opencache'] = callback(
+            self.open_cachedir, 'Opening workflow cache directory')
+        self.magic_arguments['opendata'] = callback(
+            self.open_datadir, 'Opening workflow data directory')
+        self.magic_arguments['openworkflow'] = callback(
+            self.open_workflowdir, 'Opening workflow directory')
+        self.magic_arguments['openterm'] = callback(
+            self.open_terminal, 'Opening workflow root directory in Terminal')
+
+        # Diacritic folding
+        def fold_on():
+            self.settings['__workflow_diacritic_folding'] = True
+            return 'Diacritics will always be folded'
+
+        def fold_off():
+            self.settings['__workflow_diacritic_folding'] = False
+            return 'Diacritics will never be folded'
+
+        def fold_default():
+            if '__workflow_diacritic_folding' in self.settings:
+                del self.settings['__workflow_diacritic_folding']
+            return 'Diacritics folding reset'
+
+        self.magic_arguments['foldingon'] = fold_on
+        self.magic_arguments['foldingoff'] = fold_off
+        self.magic_arguments['foldingdefault'] = fold_default
+
+        # Updates
+        def update_on():
+            self.settings['__workflow_autoupdate'] = True
+            return 'Auto update turned on'
+
+        def update_off():
+            self.settings['__workflow_autoupdate'] = False
+            return 'Auto update turned off'
+
+        def prereleases_on():
+            self.settings['__workflow_prereleases'] = True
+            return 'Prerelease updates turned on'
+
+        def prereleases_off():
+            self.settings['__workflow_prereleases'] = False
+            return 'Prerelease updates turned off'
+
+        def do_update():
+            if self.start_update():
+                return 'Downloading and installing update ...'
+            else:
+                return 'No update available'
+
+        self.magic_arguments['autoupdate'] = update_on
+        self.magic_arguments['noautoupdate'] = update_off
+        self.magic_arguments['prereleases'] = prereleases_on
+        self.magic_arguments['noprereleases'] = prereleases_off
+        self.magic_arguments['update'] = do_update
+
+        # Help
+        def do_help():
+            if self.help_url:
+                self.open_help()
+                return 'Opening workflow help URL in browser'
+            else:
+                return 'Workflow has no help URL'
+
+        def show_version():
+            if self.version:
+                return 'Version: {0}'.format(self.version)
+            else:
+                return 'This workflow has no version number'
+
+        def list_magic():
+            """Display all available magic args in Alfred."""
+            isatty = sys.stderr.isatty()
+            for name in sorted(self.magic_arguments.keys()):
+                if name == 'magic':
+                    continue
+                arg = '{0}{1}'.format(self.magic_prefix, name)
+                self.logger.debug(arg)
+
+                if not isatty:
+                    self.add_item(arg, icon=ICON_INFO)
+
+            if not isatty:
+                self.send_feedback()
+
+        self.magic_arguments['help'] = do_help
+        self.magic_arguments['magic'] = list_magic
+        self.magic_arguments['version'] = show_version
+
+    def clear_cache(self, filter_func=lambda f: True):
+        """Delete all files in workflow's :attr:`cachedir`.
+
+        :param filter_func: Callable to determine whether a file should be
+            deleted or not. ``filter_func`` is called with the filename
+            of each file in the data directory. If it returns ``True``,
+            the file will be deleted.
+            By default, *all* files will be deleted.
+        :type filter_func: ``callable``
+        """
+        self._delete_directory_contents(self.cachedir, filter_func)
+
+    def clear_data(self, filter_func=lambda f: True):
+        """Delete all files in workflow's :attr:`datadir`.
+
+        :param filter_func: Callable to determine whether a file should be
+            deleted or not. ``filter_func`` is called with the filename
+            of each file in the data directory. If it returns ``True``,
+            the file will be deleted.
+            By default, *all* files will be deleted.
+        :type filter_func: ``callable``
+        """
+        self._delete_directory_contents(self.datadir, filter_func)
+
+    def clear_settings(self):
+        """Delete workflow's :attr:`settings_path`."""
+        if os.path.exists(self.settings_path):
+            os.unlink(self.settings_path)
+            self.logger.debug('Deleted : %r', self.settings_path)
+
+    def reset(self):
+        """Delete workflow settings, cache and data.
+
+        File :attr:`settings <settings_path>` and directories
+        :attr:`cache <cachedir>` and :attr:`data <datadir>` are deleted.
+
+        """
+        self.clear_cache()
+        self.clear_data()
+        self.clear_settings()
+
+    def open_log(self):
+        """Open :attr:`logfile` in default app (usually Console.app)."""
+        subprocess.call(['open', self.logfile])
+
+    def open_cachedir(self):
+        """Open the workflow's :attr:`cachedir` in Finder."""
+        subprocess.call(['open', self.cachedir])
+
+    def open_datadir(self):
+        """Open the workflow's :attr:`datadir` in Finder."""
+        subprocess.call(['open', self.datadir])
+
+    def open_workflowdir(self):
+        """Open the workflow's :attr:`workflowdir` in Finder."""
+        subprocess.call(['open', self.workflowdir])
+
+    def open_terminal(self):
+        """Open a Terminal window at workflow's :attr:`workflowdir`."""
+        subprocess.call(['open', '-a', 'Terminal',
+                        self.workflowdir])
+
+    def open_help(self):
+        """Open :attr:`help_url` in default browser."""
+        subprocess.call(['open', self.help_url])
+
+        return 'Opening workflow help URL in browser'
+
+    ####################################################################
+    # Helper methods
+    ####################################################################
+
+    def decode(self, text, encoding=None, normalization=None):
+        """Return ``text`` as normalised unicode.
+
+        If ``encoding`` and/or ``normalization`` is ``None``, the
+        ``input_encoding``and ``normalization`` parameters passed to
+        :class:`Workflow` are used.
+
+        :param text: string
+        :type text: encoded or Unicode string. If ``text`` is already a
+            Unicode string, it will only be normalised.
+        :param encoding: The text encoding to use to decode ``text`` to
+            Unicode.
+        :type encoding: ``unicode`` or ``None``
+        :param normalization: The nomalisation form to apply to ``text``.
+        :type normalization: ``unicode`` or ``None``
+        :returns: decoded and normalised ``unicode``
+
+        :class:`Workflow` uses "NFC" normalisation by default. This is the
+        standard for Python and will work well with data from the web (via
+        :mod:`~workflow.web` or :mod:`json`).
+
+        OS X, on the other hand, uses "NFD" normalisation (nearly), so data
+        coming from the system (e.g. via :mod:`subprocess` or
+        :func:`os.listdir`/:mod:`os.path`) may not match. You should either
+        normalise this data, too, or change the default normalisation used by
+        :class:`Workflow`.
+
+        """
+        encoding = encoding or self._input_encoding
+        normalization = normalization or self._normalizsation
+        if not isinstance(text, unicode):
+            text = unicode(text, encoding)
+        return unicodedata.normalize(normalization, text)
+
+    def fold_to_ascii(self, text):
+        """Convert non-ASCII characters to closest ASCII equivalent.
+
+        .. versionadded:: 1.3
+
+        .. note:: This only works for a subset of European languages.
+
+        :param text: text to convert
+        :type text: ``unicode``
+        :returns: text containing only ASCII characters
+        :rtype: ``unicode``
+
+        """
+        if isascii(text):
+            return text
+        text = ''.join([ASCII_REPLACEMENTS.get(c, c) for c in text])
+        return unicode(unicodedata.normalize('NFKD',
+                       text).encode('ascii', 'ignore'))
+
+    def dumbify_punctuation(self, text):
+        """Convert non-ASCII punctuation to closest ASCII equivalent.
+
+        This method replaces "smart" quotes and n- or m-dashes with their
+        workaday ASCII equivalents. This method is currently not used
+        internally, but exists as a helper method for workflow authors.
+
+        .. versionadded: 1.9.7
+
+        :param text: text to convert
+        :type text: ``unicode``
+        :returns: text with only ASCII punctuation
+        :rtype: ``unicode``
+
+        """
+        if isascii(text):
+            return text
+
+        text = ''.join([DUMB_PUNCTUATION.get(c, c) for c in text])
+        return text
+
+    def _delete_directory_contents(self, dirpath, filter_func):
+        """Delete all files in a directory.
+
+        :param dirpath: path to directory to clear
+        :type dirpath: ``unicode`` or ``str``
+        :param filter_func function to determine whether a file shall be
+            deleted or not.
+        :type filter_func ``callable``
+
+        """
+        if os.path.exists(dirpath):
+            for filename in os.listdir(dirpath):
+                if not filter_func(filename):
+                    continue
+                path = os.path.join(dirpath, filename)
+                if os.path.isdir(path):
+                    shutil.rmtree(path)
+                else:
+                    os.unlink(path)
+                self.logger.debug('Deleted : %r', path)
+
+    def _load_info_plist(self):
+        """Load workflow info from ``info.plist``."""
+        # info.plist should be in the directory above this one
+        self._info = plistlib.readPlist(self.workflowfile('info.plist'))
+        self._info_loaded = True
+
+    def _create(self, dirpath):
+        """Create directory `dirpath` if it doesn't exist.
+
+        :param dirpath: path to directory
+        :type dirpath: ``unicode``
+        :returns: ``dirpath`` argument
+        :rtype: ``unicode``
+
+        """
+        if not os.path.exists(dirpath):
+            os.makedirs(dirpath)
+        return dirpath
+
+    def _call_security(self, action, service, account, *args):
+        """Call ``security`` CLI program that provides access to keychains.
+
+        May raise `PasswordNotFound`, `PasswordExists` or `KeychainError`
+        exceptions (the first two are subclasses of `KeychainError`).
+
+        :param action: The ``security`` action to call, e.g.
+                           ``add-generic-password``
+        :type action: ``unicode``
+        :param service: Name of the service.
+        :type service: ``unicode``
+        :param account: name of the account the password is for, e.g.
+            "Pinboard"
+        :type account: ``unicode``
+        :param password: the password to secure
+        :type password: ``unicode``
+        :param *args: list of command line arguments to be passed to
+                      ``security``
+        :type *args: `list` or `tuple`
+        :returns: ``(retcode, output)``. ``retcode`` is an `int`, ``output`` a
+                  ``unicode`` string.
+        :rtype: `tuple` (`int`, ``unicode``)
+
+        """
+        cmd = ['security', action, '-s', service, '-a', account] + list(args)
+        p = subprocess.Popen(cmd, stdout=subprocess.PIPE,
+                             stderr=subprocess.STDOUT)
+        stdout, _ = p.communicate()
+        if p.returncode == 44:  # password does not exist
+            raise PasswordNotFound()
+        elif p.returncode == 45:  # password already exists
+            raise PasswordExists()
+        elif p.returncode > 0:
+            err = KeychainError('Unknown Keychain error : %s' % stdout)
+            err.retcode = p.returncode
+            raise err
+        return stdout.strip().decode('utf-8')

+ 532 - 0
scripts/workflow/workflow3.py

@@ -0,0 +1,532 @@
+# encoding: utf-8
+#
+# Copyright (c) 2016 Dean Jackson <[email protected]>
+#
+# MIT Licence. See http://opensource.org/licenses/MIT
+#
+# Created on 2016-06-25
+#
+
+"""
+:class:`Workflow3` supports Alfred 3's new features.
+
+It is an Alfred 3-only version of :class:`~workflow.workflow.Workflow`.
+
+It supports setting :ref:`workflow-variables` and
+:class:`the more advanced modifiers <Modifier>` supported by Alfred 3.
+
+In order for the feedback mechanism to work correctly, it's important
+to create :class:`Item3` and :class:`Modifier` objects via the
+:meth:`Workflow3.add_item()` and :meth:`Item3.add_modifier()` methods
+respectively. If you instantiate :class:`Item3` or :class:`Modifier`
+objects directly, the current :class:`~workflow.workflow3.Workflow3`
+object won't be aware of them, and they won't be sent to Alfred when
+you call :meth:`~workflow.workflow3.Workflow3.send_feedback()`.
+"""
+
+from __future__ import print_function, unicode_literals, absolute_import
+
+import json
+import os
+import sys
+
+from .workflow import Workflow
+
+
+class Modifier(object):
+    """Modify ``Item3`` values for when specified modifier keys are pressed.
+
+    Valid modifiers (i.e. values for ``key``) are:
+
+     * cmd
+     * alt
+     * shift
+     * ctrl
+     * fn
+
+    Attributes:
+        arg (unicode): Arg to pass to following action.
+        key (unicode): Modifier key (see above).
+        subtitle (unicode): Override item subtitle.
+        valid (bool): Override item validity.
+        variables (dict): Workflow variables set by this modifier.
+    """
+
+    def __init__(self, key, subtitle=None, arg=None, valid=None):
+        """Create a new :class:`Modifier`.
+
+        You probably don't want to use this class directly, but rather
+        use :meth:`Item3.add_modifier()` to add modifiers to results.
+
+        Args:
+            key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc.
+            subtitle (unicode, optional): Override default subtitle.
+            arg (unicode, optional): Argument to pass for this modifier.
+            valid (bool, optional): Override item's validity.
+        """
+        self.key = key
+        self.subtitle = subtitle
+        self.arg = arg
+        self.valid = valid
+
+        self.config = {}
+        self.variables = {}
+
+    def setvar(self, name, value):
+        """Set a workflow variable for this Item.
+
+        Args:
+            name (unicode): Name of variable.
+            value (unicode): Value of variable.
+        """
+        self.variables[name] = value
+
+    def getvar(self, name, default=None):
+        """Return value of workflow variable for ``name`` or ``default``.
+
+        Args:
+            name (unicode): Variable name.
+            default (None, optional): Value to return if variable is unset.
+
+        Returns:
+            unicode or ``default``: Value of variable if set or ``default``.
+        """
+        return self.variables.get(name, default)
+
+    @property
+    def obj(self):
+        """Modifier formatted for JSON serialization for Alfred 3.
+
+        Returns:
+            dict: Modifier for serializing to JSON.
+        """
+        o = {}
+
+        if self.subtitle is not None:
+            o['subtitle'] = self.subtitle
+
+        if self.arg is not None:
+            o['arg'] = self.arg
+
+        if self.valid is not None:
+            o['valid'] = self.valid
+
+        # Variables and config
+        if self.variables or self.config:
+            d = {}
+            if self.variables:
+                d['variables'] = self.variables
+
+            if self.config:
+                d['config'] = self.config
+
+            if self.arg is not None:
+                d['arg'] = self.arg
+
+            o['arg'] = json.dumps({'alfredworkflow': d})
+
+        return o
+
+
+class Item3(object):
+    """Represents a feedback item for Alfred 3.
+
+    Generates Alfred-compliant JSON for a single item.
+
+    You probably shouldn't use this class directly, but via
+    :meth:`Workflow3.add_item`. See :meth:`~Workflow3.add_item`
+    for details of arguments.
+    """
+
+    def __init__(self, title, subtitle='', arg=None, autocomplete=None,
+                 valid=False, uid=None, icon=None, icontype=None,
+                 type=None, largetext=None, copytext=None, quicklookurl=None):
+        """Use same arguments as for :meth:`Workflow.add_item`.
+
+        Argument ``subtitle_modifiers`` is not supported.
+        """
+        self.title = title
+        self.subtitle = subtitle
+        self.arg = arg
+        self.autocomplete = autocomplete
+        self.valid = valid
+        self.uid = uid
+        self.icon = icon
+        self.icontype = icontype
+        self.type = type
+        self.quicklookurl = quicklookurl
+        self.largetext = largetext
+        self.copytext = copytext
+
+        self.modifiers = {}
+
+        self.config = {}
+        self.variables = {}
+
+    def setvar(self, name, value):
+        """Set a workflow variable for this Item.
+
+        Args:
+            name (unicode): Name of variable.
+            value (unicode): Value of variable.
+
+        """
+        self.variables[name] = value
+
+    def getvar(self, name, default=None):
+        """Return value of workflow variable for ``name`` or ``default``.
+
+        Args:
+            name (unicode): Variable name.
+            default (None, optional): Value to return if variable is unset.
+
+        Returns:
+            unicode or ``default``: Value of variable if set or ``default``.
+        """
+        return self.variables.get(name, default)
+
+    def add_modifier(self, key, subtitle=None, arg=None, valid=None):
+        """Add alternative values for a modifier key.
+
+        Args:
+            key (unicode): Modifier key, e.g. ``"cmd"`` or ``"alt"``
+            subtitle (unicode, optional): Override item subtitle.
+            arg (unicode, optional): Input for following action.
+            valid (bool, optional): Override item validity.
+
+        Returns:
+            Modifier: Configured :class:`Modifier`.
+        """
+        mod = Modifier(key, subtitle, arg, valid)
+
+        for k in self.variables:
+            mod.setvar(k, self.variables[k])
+
+        self.modifiers[key] = mod
+
+        return mod
+
+    @property
+    def obj(self):
+        """Item formatted for JSON serialization.
+
+        Returns:
+            dict: Data suitable for Alfred 3 feedback.
+        """
+        # Basic values
+        o = {'title': self.title,
+             'subtitle': self.subtitle,
+             'valid': self.valid}
+
+        icon = {}
+
+        # Optional values
+        if self.arg is not None:
+            o['arg'] = self.arg
+
+        if self.autocomplete is not None:
+            o['autocomplete'] = self.autocomplete
+
+        if self.uid is not None:
+            o['uid'] = self.uid
+
+        if self.type is not None:
+            o['type'] = self.type
+
+        if self.quicklookurl is not None:
+            o['quicklookurl'] = self.quicklookurl
+
+        # Largetype and copytext
+        text = self._text()
+        if text:
+            o['text'] = text
+
+        icon = self._icon()
+        if icon:
+            o['icon'] = icon
+
+        # Variables and config
+        js = self._vars_and_config()
+        if js:
+            o['arg'] = js
+
+        # Modifiers
+        mods = self._modifiers()
+        if mods:
+            o['mods'] = mods
+
+        return o
+
+    def _icon(self):
+        """Return `icon` object for item.
+
+        Returns:
+            dict: Mapping for item `icon` (may be empty).
+        """
+        icon = {}
+        if self.icon is not None:
+            icon['path'] = self.icon
+
+        if self.icontype is not None:
+            icon['type'] = self.icontype
+
+        return icon
+
+    def _text(self):
+        """Return `largetext` and `copytext` object for item.
+
+        Returns:
+            dict: `text` mapping (may be empty)
+        """
+        text = {}
+        if self.largetext is not None:
+            text['largetype'] = self.largetext
+
+        if self.copytext is not None:
+            text['copy'] = self.copytext
+
+        return text
+
+    def _vars_and_config(self):
+        """Build `arg` including workflow variables and configuration.
+
+        Returns:
+            str: JSON string value for `arg` (or `None`)
+        """
+        if self.variables or self.config:
+            d = {}
+            if self.variables:
+                d['variables'] = self.variables
+
+            if self.config:
+                d['config'] = self.config
+
+            if self.arg is not None:
+                d['arg'] = self.arg
+
+            return json.dumps({'alfredworkflow': d})
+
+        return None
+
+    def _modifiers(self):
+        """Build `mods` dictionary for JSON feedback.
+
+        Returns:
+            dict: Modifier mapping or `None`.
+        """
+        if self.modifiers:
+            mods = {}
+            for k, mod in self.modifiers.items():
+                mods[k] = mod.obj
+
+            return mods
+
+        return None
+
+
+class Workflow3(Workflow):
+    """Workflow class that generates Alfred 3 feedback.
+
+    Attributes:
+        item_class (class): Class used to generate feedback items.
+        variables (dict): Top level workflow variables.
+    """
+
+    item_class = Item3
+
+    def __init__(self, **kwargs):
+        """Create a new :class:`Workflow3` object.
+
+        See :class:`~workflow.workflow.Workflow` for documentation.
+        """
+        Workflow.__init__(self, **kwargs)
+        self.variables = {}
+        self._rerun = 0
+        self._session_id = None
+
+    @property
+    def _default_cachedir(self):
+        """Alfred 3's default cache directory."""
+        return os.path.join(
+            os.path.expanduser(
+                '~/Library/Caches/com.runningwithcrayons.Alfred-3/'
+                'Workflow Data/'),
+            self.bundleid)
+
+    @property
+    def _default_datadir(self):
+        """Alfred 3's default data directory."""
+        return os.path.join(os.path.expanduser(
+            '~/Library/Application Support/Alfred 3/Workflow Data/'),
+            self.bundleid)
+
+    @property
+    def rerun(self):
+        """How often (in seconds) Alfred should re-run the Script Filter."""
+        return self._rerun
+
+    @rerun.setter
+    def rerun(self, seconds):
+        """Interval at which Alfred should re-run the Script Filter.
+
+        Args:
+            seconds (int): Interval between runs.
+        """
+        self._rerun = seconds
+
+    @property
+    def session_id(self):
+        """A unique session ID every time the user uses the workflow.
+
+        .. versionadded:: 1.25
+
+        The session ID persists while the user is using this workflow.
+        It expires when the user runs a different workflow or closes
+        Alfred.
+
+        """
+        if not self._session_id:
+            sid = os.getenv('_WF_SESSION_ID')
+            if not sid:
+                from uuid import uuid4
+                sid = uuid4().hex
+                self.setvar('_WF_SESSION_ID', sid)
+
+            self._session_id = sid
+
+        return self._session_id
+
+    def setvar(self, name, value):
+        """Set a "global" workflow variable.
+
+        These variables are always passed to downstream workflow objects.
+
+        If you have set :attr:`rerun`, these variables are also passed
+        back to the script when Alfred runs it again.
+
+        Args:
+            name (unicode): Name of variable.
+            value (unicode): Value of variable.
+        """
+        self.variables[name] = value
+
+    def getvar(self, name, default=None):
+        """Return value of workflow variable for ``name`` or ``default``.
+
+        Args:
+            name (unicode): Variable name.
+            default (None, optional): Value to return if variable is unset.
+
+        Returns:
+            unicode or ``default``: Value of variable if set or ``default``.
+        """
+        return self.variables.get(name, default)
+
+    def add_item(self, title, subtitle='', arg=None, autocomplete=None,
+                 valid=False, uid=None, icon=None, icontype=None,
+                 type=None, largetext=None, copytext=None, quicklookurl=None):
+        """Add an item to be output to Alfred.
+
+        See :meth:`~workflow.workflow.Workflow.add_item` for the main
+        documentation.
+
+        The key difference is that this method does not support the
+        ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()`
+        method instead on the returned item instead.
+
+        Returns:
+            Item3: Alfred feedback item.
+        """
+        item = self.item_class(title, subtitle, arg,
+                               autocomplete, valid, uid, icon, icontype, type,
+                               largetext, copytext, quicklookurl)
+
+        self._items.append(item)
+        return item
+
+    def _mk_session_name(self, name):
+        """New cache name/key based on session ID."""
+        return '_wfsess-{0}-{1}'.format(self.session_id, name)
+
+    def cache_data(self, name, data, session=False):
+        """Cache API with session-scoped expiry.
+
+        .. versionadded:: 1.25
+
+        Args:
+            name (str): Cache key
+            data (object): Data to cache
+            session (bool, optional): Whether to scope the cache
+                to the current session.
+
+        ``name`` and ``data`` are as for the
+        :meth:`~workflow.workflow.Workflow.cache_data` on
+        :class:`~workflow.workflow.Workflow`.
+
+        If ``session`` is ``True``, the ``name`` variable is prefixed
+        with :attr:`session_id`.
+
+        """
+        if session:
+            name = self._mk_session_name(name)
+
+        return super(Workflow3, self).cache_data(name, data)
+
+    def cached_data(self, name, data_func=None, max_age=60, session=False):
+        """Cache API with session-scoped expiry.
+
+        .. versionadded:: 1.25
+
+        Args:
+            name (str): Cache key
+            data_func (callable): Callable that returns fresh data. It
+                is called if the cache has expired or doesn't exist.
+            max_age (int): Maximum allowable age of cache in seconds.
+            session (bool, optional): Whether to scope the cache
+                to the current session.
+
+        ``name``, ``data_func`` and ``max_age`` are as for the
+        :meth:`~workflow.workflow.Workflow.cached_data` on
+        :class:`~workflow.workflow.Workflow`.
+
+        If ``session`` is ``True``, the ``name`` variable is prefixed
+        with :attr:`session_id`.
+
+        """
+        if session:
+            name = self._mk_session_name(name)
+
+        return super(Workflow3, self).cached_data(name, data_func, max_age)
+
+    def clear_session_cache(self):
+        """Remove *all* session data from the cache.
+
+        .. versionadded:: 1.25
+        """
+        def _is_session_file(filename):
+            return filename.startswith('_wfsess-')
+
+        self.clear_cache(_is_session_file)
+
+    @property
+    def obj(self):
+        """Feedback formatted for JSON serialization.
+
+        Returns:
+            dict: Data suitable for Alfred 3 feedback.
+        """
+        items = []
+        for item in self._items:
+            items.append(item.obj)
+
+        o = {'items': items}
+        if self.variables:
+            o['variables'] = self.variables
+        if self.rerun:
+            o['rerun'] = self.rerun
+        return o
+
+    def send_feedback(self):
+        """Print stored items to console/Alfred as JSON."""
+        json.dump(self.obj, sys.stdout)
+        sys.stdout.flush()