diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..fd20fdd
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+
+*.pyc
diff --git a/com.whyliam.workflows.youdao.alfredworkflow b/com.whyliam.workflows.youdao.alfredworkflow
new file mode 100644
index 0000000..0c1afdb
Binary files /dev/null and b/com.whyliam.workflows.youdao.alfredworkflow differ
diff --git a/icon.png b/icon.png
new file mode 100755
index 0000000..bbaeb96
Binary files /dev/null and b/icon.png differ
diff --git a/icon_basic.png b/icon_basic.png
new file mode 100755
index 0000000..8b54148
Binary files /dev/null and b/icon_basic.png differ
diff --git a/icon_web.png b/icon_web.png
new file mode 100755
index 0000000..31d785c
Binary files /dev/null and b/icon_web.png differ
diff --git a/info.plist b/info.plist
new file mode 100755
index 0000000..b8beebb
--- /dev/null
+++ b/info.plist
@@ -0,0 +1,106 @@
+
+
+
+
+ bundleid
+ com.whyliam.workflows.youdao
+ category
+ Tools
+ connections
+
+ 91C343E7-50D8-4B0D-9034-1C16C20DA8D4
+
+
+ destinationuid
+ E5E934DB-DBAC-4D0C-9AB0-ADAF62C5640B
+ modifiers
+ 0
+ modifiersubtext
+
+
+
+
+ createdby
+ whyliam
+ description
+ 使用有道翻译你想知道的单词和语句
+ disabled
+
+ name
+ Youdao
+ objects
+
+
+ config
+
+ argumenttype
+ 0
+ escaping
+ 127
+ keyword
+ yd
+ queuedelaycustom
+ 3
+ queuedelayimmediatelyinitially
+
+ queuedelaymode
+ 0
+ queuemode
+ 1
+ runningsubtext
+ 正在获取中...
+ script
+ /usr/bin/python youdao.py "{query}"
+ subtext
+ 使用有道翻译你想知道的单词和语句 {query}
+ title
+ 有道翻译
+ type
+ 0
+ withspace
+
+
+ type
+ alfred.workflow.input.scriptfilter
+ uid
+ 91C343E7-50D8-4B0D-9034-1C16C20DA8D4
+ version
+ 0
+
+
+ config
+
+ plusspaces
+
+ url
+ {query}
+ utf8
+
+
+ type
+ alfred.workflow.action.openurl
+ uid
+ E5E934DB-DBAC-4D0C-9AB0-ADAF62C5640B
+ version
+ 0
+
+
+ readme
+
+ uidata
+
+ 91C343E7-50D8-4B0D-9034-1C16C20DA8D4
+
+ ypos
+ 10
+
+ E5E934DB-DBAC-4D0C-9AB0-ADAF62C5640B
+
+ ypos
+ 10
+
+
+ webaddress
+ youdao.com
+
+
diff --git a/workflow/__init__.py b/workflow/__init__.py
new file mode 100755
index 0000000..5de1a96
--- /dev/null
+++ b/workflow/__init__.py
@@ -0,0 +1,108 @@
+#!/usr/bin/env python
+# encoding: utf-8
+#
+# Copyright (c) 2014 Dean Jackson
+#
+# MIT Licence. See http://opensource.org/licenses/MIT
+#
+# Created on 2014-02-15
+#
+
+"""
+A Python helper library for `Alfred 2 `_ Workflow
+authors.
+"""
+
+import os
+
+__title__ = 'Alfred-Workflow'
+__version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read()
+__author__ = 'Dean Jackson'
+__licence__ = 'MIT'
+__copyright__ = 'Copyright 2014 Dean Jackson'
+
+
+# Workflow objects
+from .workflow import Workflow, manager
+
+# 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,
+)
+
+__all__ = [
+ 'Workflow',
+ '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',
+]
diff --git a/workflow/background.py b/workflow/background.py
new file mode 100755
index 0000000..bcfa74d
--- /dev/null
+++ b/workflow/background.py
@@ -0,0 +1,252 @@
+#!/usr/bin/env python
+# encoding: utf-8
+#
+# Copyright © 2014 deanishe@deanishe.net
+#
+# 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
+
+ """
+
+ # Do first fork.
+ try:
+ pid = os.fork()
+ if pid > 0:
+ sys.exit(0) # Exit first parent.
+ except OSError as e:
+ wf().logger.critical("fork #1 failed: ({0:d}) {1}".format(
+ e.errno, e.strerror))
+ sys.exit(1)
+ # Decouple from parent environment.
+ os.chdir(wf().workflowdir)
+ os.umask(0)
+ os.setsid()
+ # Do second fork.
+ try:
+ pid = os.fork()
+ if pid > 0:
+ sys.exit(0) # Exit second parent.
+ except OSError as e:
+ wf().logger.critical("fork #2 failed: ({0:d}) {1}".format(
+ e.errno, e.strerror))
+ sys.exit(1)
+ # Now I am a daemon!
+ # Redirect standard file descriptors.
+ si = file(stdin, 'r', 0)
+ so = file(stdout, 'a+', 0)
+ se = file(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):
+ """Pickle arguments to cache file, 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
+ """
+ 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)
diff --git a/workflow/update.py b/workflow/update.py
new file mode 100755
index 0000000..b946e79
--- /dev/null
+++ b/workflow/update.py
@@ -0,0 +1,363 @@
+#!/usr/bin/env python
+# encoding: utf-8
+#
+# Copyright © 2014 Fabio Niephaus ,
+# Dean Jackson
+#
+# 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():
+ 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 `
+ is that this implementation doesn't require a minor or patch version.
+ """
+
+ #: Match version and pre-release/build information in version strings
+ match_version = re.compile(r'([0-9\.]+)(.+)?').match
+
+ def __init__(self, vstr):
+ 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):
+ """Return version number as a tuple of major, minor, patch, pre-release
+ """
+
+ return (self.major, self.minor, self.patch, self.suffix)
+
+ def __lt__(self, other):
+ 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):
+ if not isinstance(other, Version):
+ raise ValueError('Not a Version instance: {0!r}'.format(other))
+ return self.tuple == other.tuple
+
+ def __ne__(self, other):
+ return not self.__eq__(other)
+
+ def __gt__(self, other):
+ if not isinstance(other, Version):
+ raise ValueError('Not a Version instance: {0!r}'.format(other))
+ return other.__lt__(self)
+
+ def __le__(self, other):
+ if not isinstance(other, Version):
+ raise ValueError('Not a Version instance: {0!r}'.format(other))
+ return not other.__lt__(self)
+
+ def __ge__(self, other):
+ return not self.__lt__(other)
+
+ def __str__(self):
+ 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 "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 `{}` not a workflow'.format(filename))
+
+ local_path = os.path.join(tempfile.gettempdir(), filename)
+
+ wf().logger.debug(
+ 'Downloading updated workflow from `{0}` to `{1}` ...'.format(
+ 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 get_valid_releases(github_slug):
+ """Return list of all valid releases
+
+ :param github_slug: ``username/repo`` for workflow's GitHub repo
+ :returns: list of dicts. Each :class:`dict` has the form
+ ``{'version': '1.1', 'download_url': 'http://github.com/...'}``
+
+
+ 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 `{0}` ...'.format(
+ api_url))
+
+ def retrieve_releases():
+ wf().logger.info(
+ 'Retrieving releases for `{0}` ...'.format(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):
+ version = release['tag_name']
+ download_urls = []
+ for asset in release.get('assets', []):
+ url = asset.get('browser_download_url')
+ if not url or not url.endswith('.alfredworkflow'):
+ continue
+ download_urls.append(url)
+
+ # Validate release
+ if release['prerelease']:
+ wf().logger.warning(
+ 'Invalid release {0} : pre-release detected'.format(version))
+ continue
+ if not download_urls:
+ wf().logger.warning(
+ 'Invalid release {0} : No workflow file'.format(version))
+ continue
+ if len(download_urls) > 1:
+ wf().logger.warning(
+ 'Invalid release {0} : multiple workflow files'.format(version))
+ continue
+
+ wf().logger.debug('Release `{0}` : {1}'.format(version, url))
+ releases.append({'version': version, 'download_url': download_urls[0]})
+
+ return releases
+
+
+def check_update(github_slug, current_version):
+ """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 ` is required.
+ :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)
+
+ wf().logger.info('{0} releases for {1}'.format(len(releases),
+ github_slug))
+
+ if not len(releases):
+ raise ValueError('No valid releases for {0}'.format(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 : {0!r} Installed : {1!r}'.format(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(github_slug, current_version):
+ """If a newer release is available, download and install it
+
+ :param github_slug: ``username/repo`` for workflow's GitHub repo
+ :param current_version: the currently installed version of the
+ workflow. :ref:`Semantic versioning ` is required.
+ :type current_version: ``unicode``
+
+ If an update is available, it will be downloaded and installed.
+
+ :returns: ``True`` if an update is installed, else ``False``
+
+ """
+ # TODO: `github_slug` and `current_version` are both unusued.
+
+ 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('Usage : update.py (check|install) github_slug version')
+ sys.exit(1)
+
+ if len(sys.argv) != 4:
+ show_help()
+
+ action, github_slug, version = sys.argv[1:]
+
+ if action not in ('check', 'install'):
+ show_help()
+
+ if action == 'check':
+ check_update(github_slug, version)
+ elif action == 'install':
+ install_update(github_slug, version)
diff --git a/workflow/version b/workflow/version
new file mode 100755
index 0000000..b0f61c5
--- /dev/null
+++ b/workflow/version
@@ -0,0 +1 @@
+1.11.1
\ No newline at end of file
diff --git a/workflow/web.py b/workflow/web.py
new file mode 100755
index 0000000..b67bbcb
--- /dev/null
+++ b/workflow/web.py
@@ -0,0 +1,633 @@
+# encoding: utf-8
+#
+# Copyright (c) 2014 Dean Jackson
+#
+# MIT Licence. See http://opensource.org/licenses/MIT
+#
+# Created on 2014-02-15
+#
+
+"""
+A lightweight HTTP library with a requests-like interface.
+"""
+
+from __future__ import print_function
+
+import codecs
+import json
+import mimetypes
+import os
+import random
+import re
+import socket
+import string
+import unicodedata
+import urllib
+import urllib2
+import zlib
+
+
+USER_AGENT = u'Alfred-Workflow/1.11 (http://www.deanishe.net)'
+
+# 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 that 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):
+
+ 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.
+
+ A 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
+ ...
+ >>> r.text # unicode, decoded according to charset in HTTP header/meta tag
+ u' ...'
+ >>> r.json() # content parsed as JSON
+
+ """
+
+ def __init__(self, request):
+ """Call `request` with :mod:`urllib2` and process results.
+
+ :param request: :class:`urllib2.Request` instance
+
+ """
+
+ self.request = request
+ 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._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
+
+ 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()
+
+ 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
+
+ """
+
+ 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:
+ yield data # pragma: nocover
+
+ 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)
+
+ 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
+
+ # Encoding declared in document should override HTTP headers
+ if self.mimetype == 'text/html': # sniff HTML headers
+ m = re.search("""""",
+ 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("""]*\?>""",
+ 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):
+ """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``
+ :returns: :class:`Response` object
+
+
+ The ``files`` argument is a dictionary::
+
+ {'fieldname' : { 'filename': 'blah.txt',
+ 'content': '',
+ '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
+ # TODO: any way to force GET or POST?
+ 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)
+
+ 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)
+ url = url + '?' + urllib.urlencode(str_dict(params))
+
+ req = urllib2.Request(url, data, headers)
+ return Response(req)
+
+
+def get(url, params=None, headers=None, cookies=None, auth=None,
+ timeout=60, allow_redirects=True):
+ """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)
+
+
+def post(url, params=None, data=None, headers=None, cookies=None, files=None,
+ auth=None, timeout=60, allow_redirects=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)
+
+
+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': '',
+ '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)
diff --git a/workflow/workflow.py b/workflow/workflow.py
new file mode 100755
index 0000000..14c1d87
--- /dev/null
+++ b/workflow/workflow.py
@@ -0,0 +1,2711 @@
+# encoding: utf-8
+#
+# Copyright (c) 2014 Dean Jackson
+#
+# MIT Licence. See http://opensource.org/licenses/MIT
+#
+# Created on 2014-02-15
+#
+
+"""
+The :class:`Workflow` object is the main interface to this library.
+
+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 binascii
+import os
+import sys
+import string
+import re
+import plistlib
+import subprocess
+import unicodedata
+import shutil
+import json
+import cPickle
+import pickle
+import time
+import logging
+import logging.handlers
+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
+
+
+####################################################################
+# Keychain access errors
+####################################################################
+
+class KeychainError(Exception):
+ """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):
+ """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):
+ 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`` or ``None`` if no such
+ serializer is registered
+
+ :param name: Name of serializer to return
+ :type name: ``unicode`` or ``str``
+ :returns: serializer object or ``None``
+
+ """
+
+ 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):
+ """Arguments the same as for :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
+
+ @property
+ def elem(self):
+ """Create and return feedback item for Alfred.
+
+ :returns: :class:`ElementTree.Element `
+ instance for this :class:`Item` instance.
+
+ """
+
+ # Attributes on - 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
- , 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
+
+ return root
+
+
+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):
+
+ super(Settings, self).__init__()
+ self._filepath = filepath
+ self._nosave = False
+ 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
+ with open(self._filepath, 'rb') as file_obj:
+ for key, value in json.load(file_obj, encoding='utf-8').items():
+ self[key] = value
+ self._nosave = False
+
+ 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 = {}
+ for key, value in self.items():
+ data[key] = value
+ with open(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):
+ super(Settings, self).__setitem__(key, value)
+ self.save()
+
+ def __delitem__(self, key):
+ 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.
+ 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 ` 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 `.
+ :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):
+
+ 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._name = None
+ self._cache_serializer = 'cpickle'
+ self._data_serializer = 'cpickle'
+ # info.plist should be in the directory above this one
+ self._info_plist = self.workflowfile('info.plist')
+ 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 ` 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_env(self):
+ """Alfred's environmental variables minus the ``alfred_`` prefix.
+
+ .. versionadded:: 1.7
+
+ The variables Alfred 2.4+ exports are:
+
+ ============================ =========================================
+ Variable Description
+ ============================ =========================================
+ 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/``
+ (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
+ ============================ =========================================
+
+ **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_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'):
+
+ value = os.getenv(key)
+
+ if isinstance(value, str):
+ if key in ('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 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 version from the ``update_settings`` dict passed on
+ instantiation or the ``version`` file located in the workflow's
+ root directory. Return ``None`` if neither exist 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
+ # First check `update_settings`
+ if self._update_settings:
+ version = self._update_settings.get('version')
+
+ # Fallback to `version` file
+ if not version:
+ filepath = self.workflowfile('version')
+
+ if os.path.exists(filepath):
+ with open(filepath, 'rb') as fileobj:
+ version = fileobj.read()
+
+ 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 ` 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-2/Workflow Data/``
+
+ :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 = os.path.join(
+ os.path.expanduser(
+ '~/Library/Caches/com.runningwithcrayons.Alfred-2/'
+ 'Workflow Data/'),
+ self.bundleid)
+
+ return self._create(dirpath)
+
+ @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/``
+
+ :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 = os.path.join(os.path.expanduser(
+ '~/Library/Application Support/Alfred 2/Workflow Data/'),
+ self.bundleid)
+
+ return self._create(dirpath)
+
+ @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):
+ """Return full path to ``filename`` within your workflow's
+ :attr:`cache directory `.
+
+ :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):
+ """Return full path to ``filename`` within your workflow's
+ :attr:`data directory `.
+
+ :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 dir
+ (where ``info.plist`` is).
+
+ :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):
+ """Return 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):
+ """Create and return a logger that logs to both console and
+ a log file.
+
+ 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
+ logfile = logging.handlers.RotatingFileHandler(
+ self.logfile,
+ maxBytes=1024*1024,
+ backupCount=0)
+
+ console = logging.StreamHandler()
+
+ fmt = logging.Formatter(
+ '%(asctime)s %(filename)s:%(lineno)s'
+ ' %(levelname)-8s %(message)s',
+ datefmt='%H:%M:%S')
+
+ logfile.setFormatter(fmt)
+ console.setFormatter(fmt)
+
+ logger.addHandler(logfile)
+ logger.addHandler(console)
+
+ logger.setLevel(logging.DEBUG)
+ 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.
+
+ .. 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.
+
+ :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``
+
+ """
+
+ 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
+ for path in (metadata_path, data_path):
+ if os.path.exists(path):
+ os.unlink(path)
+ self.logger.debug('Deleted data file : {0}'.format(path))
+
+ return
+
+ # Save file extension
+ with open(metadata_path, 'wb') as file_obj:
+ file_obj.write(serializer_name)
+
+ with open(data_path, 'wb') as file_obj:
+ serializer.dump(data, file_obj)
+
+ self.logger.debug('Stored data saved at : {0}'.format(data_path))
+
+ def cached_data(self, name, data_func=None, max_age=60):
+ """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 open(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):
+ """Is data cached at `name` less than `max_age` 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 of data cached at `name` in seconds 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 startswith
+ ``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` : Match if ``query`` is a substring
+ of item search key (case-insensitive).
+ 8. :const:`MATCH_ALLCHARS` : Matches if 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):
+ """Call ``func`` to run your workflow
+
+ :param func: Callable to call with ``self`` (i.e. the :class:`Workflow`
+ instance) as first argument.
+
+ ``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
+ 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):
+ """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``
+ :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)
+ 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('\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):
+ """Is an update 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``
+
+ """
+
+ update_data = self.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']
+
+ 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]
+
+ 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):
+ 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]
+
+ 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[0-9A-F]+)\s*)?(?:"(?P.*)")?',
+ 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``. Raises
+ :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 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['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 :attr:`settings `, :attr:`cache `
+ and :attr:`data `
+
+ """
+
+ self.clear_cache()
+ self.clear_data()
+ self.clear_settings()
+
+ def open_log(self):
+ """Open workflows :attr:`logfile` in standard
+ application (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``
+
+ """
+
+ self._info = plistlib.readPlist(self._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 the ``security`` CLI app 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)
+ retcode, output = p.wait(), p.stdout.read().strip().decode('utf-8')
+ if retcode == 44: # password does not exist
+ raise PasswordNotFound()
+ elif retcode == 45: # password already exists
+ raise PasswordExists()
+ elif retcode > 0:
+ err = KeychainError('Unknown Keychain error : %s' % output)
+ err.retcode = retcode
+ raise err
+ return output
diff --git a/youdao.py b/youdao.py
new file mode 100755
index 0000000..dd0ef61
--- /dev/null
+++ b/youdao.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+
+import re
+import urllib
+from workflow import Workflow, ICON_WEB, web
+import sys
+
+reload(sys)
+sys.setdefaultencoding('utf8')
+
+apikey = 1331254833
+keyfrom = 'whyliam'
+ICON_DEFAULT = 'icon.png'
+ICON_BASIC = 'icon_basic.png'
+ICON_WEB = 'icon_web.png'
+
+def get_web_data(query):
+ query = urllib.quote(query)
+ url = 'http://fanyi.youdao.com/openapi.do?keyfrom=' + keyfrom + \
+ '&key=' + str(apikey) + \
+ '&type=data&doctype=json&version=1.1&q=' + query
+ return web.get(url).json()
+
+
+def main(wf):
+
+ query = wf.args[0].strip()
+
+ if not query:
+ wf.add_item('有道翻译')
+ wf.send_feedback()
+ return 0
+
+ s = get_web_data(query)
+ # '翻译结果'
+ title = s["translation"]
+ title = ''.join(title).encode("UTF-8")
+ url = u'http://dict.youdao.com/search?q=' + query
+
+ if title != query:
+ subtitle = '翻译结果'
+ wf.add_item(
+ title=title, subtitle=subtitle, arg=url, valid=True, icon=ICON_DEFAULT)
+
+ # '简明释意'
+ if u'basic' in s.keys():
+ for be in range(len(s["basic"]["explains"])):
+ title = s["basic"]["explains"][be]
+ subtitle = '简明释意'
+ wf.add_item(
+ title=title, subtitle=subtitle, arg=url, valid=True, icon=ICON_BASIC)
+
+ # '网络翻译'
+ if u'web' in s.keys():
+ for w in range(len(s["web"])):
+ title = s["web"][w]["key"]
+ title = ''.join(title).encode("UTF-8")
+ subtitle = '网络翻译: ' + s["web"][w]["key"]
+ wf.add_item(
+ title=title, subtitle=subtitle, arg=url, valid=True, icon=ICON_WEB)
+
+ else:
+ title = '有道也翻译不出来了'
+ subtitle = '尝试一下去网站搜索'
+ wf.add_item(
+ title=title, subtitle=subtitle, arg=url, valid=True, icon=ICON_DEFAULT)
+
+ wf.send_feedback()
+
+if __name__ == '__main__':
+ wf = Workflow()
+ sys.exit(wf.run(main))