# This is a port to Python 3 of the Alfred-Workflow library created by
# Dean Jackson <deanishe@deanishe.net>.
# See https://github.com/deanishe/alfred-workflow
#
# Copyright (c) 2022 Arthur Pinheiro

"""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.

"""

import binascii
import json
import logging
import logging.handlers
import os
import pickle
import plistlib
import re
import shutil
import string
import subprocess
import sys
import time
import unicodedata
from contextlib import contextmanager
from copy import deepcopy
from typing import Optional
from uuid import uuid4

from .util import LockFile, atomic_writer, set_config, uninterruptible

#: Sentinel for properties that haven't been set yet (that might
#: correctly have the value ``None``)
UNSET = object()

####################################################################
# Standard system icons
####################################################################

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_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 for unknown Keychain errors.

    Raised by methods :meth:`Workflow.save_password`,
    :meth:`Workflow.get_password` and :meth:`Workflow.delete_password`
    when ``security`` CLI app returns an unknown error code.

    """


class PasswordNotFound(KeychainError):
    """Password not in Keychain.

    Raised by method :meth:`Workflow.get_password` when ``account``
    is unknown to the Keychain.

    """


class PasswordExists(KeychainError):
    """Raised when trying to overwrite an existing account password.

    You should never receive this error: it is used internally
    by the :meth:`Workflow.save_password` method to know if it needs
    to delete the old password first (a Keychain implementation detail).

    """


####################################################################
# Helper functions
####################################################################


def isascii(text):
    """Test if ``text`` contains only ASCII characters.
    :param text: text to test for ASCII-ness
    :type text: ``str``
    :returns: ``True`` if ``text`` contains only ASCII characters
    :rtype: ``Boolean``
    """
    try:
        text.encode("ascii")
    except UnicodeEncodeError:
        return False
    return True


####################################################################
# Implementation classes
####################################################################


class SerializerManager:
    """Contains registered serializers.

    A configured instance of this class is available at
    :attr:`workflow.manager`.

    Use :meth:`register()` to register new (or replace
    existing) serializers, which you can specify by name when calling
    :class:`~workflow.Workflow` data storage methods.

    See :ref:`guide-serialization` and :ref:`guide-persistent-data`
    for further information.

    """

    def __init__(self):
        """Create new SerializerManager object."""
        self._serializers = {}

    def register(self, name, serializer):
        """Register ``serializer`` object under ``name``.

        Raises :class:`AttributeError` if ``serializer`` in invalid.

        .. note::

            ``name`` will be used as the file extension of the saved files.

        :param name: Name to register ``serializer`` under
        :type name: ``str``
        :param serializer: object with ``load()`` and ``dump()``
            methods

        """
        # Basic validation
        serializer.load  # pylint: disable=pointless-statement
        serializer.dump  # pylint: disable=pointless-statement

        self._serializers[name] = serializer

    def serializer(self, name):
        """Return serializer object for ``name``.

        :param name: Name of serializer to return
        :type name: ``str``
        :returns: serializer object or ``None`` if no such serializer
            is registered.

        """
        return self._serializers.get(name)

    def unregister(self, name):
        """Remove registered serializer with ``name``.

        Raises a :class:`ValueError` if there is no such registered
        serializer.

        :param name: Name of serializer to remove
        :type name: ``str``
        :returns: serializer object

        """
        if name not in self._serializers:
            raise ValueError(f"No such serializer registered : {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 BaseSerializer:
    """Base class for serializers."""

    is_binary: Optional[bool] = None

    @classmethod
    def binary_mode(cls):  # pylint: disable=missing-function-docstring
        return "b" if cls.is_binary else ""

    @classmethod
    def _opener(cls, opener, path, mode="r"):
        with opener(path, mode + cls.binary_mode()) as f:
            yield f

    @classmethod
    @contextmanager
    def atomic_writer(cls, path, mode):  # pylint: disable=missing-function-docstring
        yield from cls._opener(atomic_writer, path, mode)

    @classmethod
    @contextmanager
    def open(cls, path, mode):  # pylint: disable=missing-function-docstring
        yield from cls._opener(open, path, mode)


class JSONSerializer(BaseSerializer):
    """Wrapper around :mod:`json`. Sets ``indent`` and ``encoding``.

    Use this serializer if you need readable data files. JSON doesn't
    support Python objects as well as ``pickle``, so be
    careful which data you try to serialize as JSON.

    """

    is_binary = False

    @classmethod
    def load(cls, file_obj):
        """Load serialized object from open JSON file.

        :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.

        :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)


class PickleSerializer(BaseSerializer):
    """Wrapper around :mod:`pickle`. Sets ``protocol``.

    Use this serializer if you need to add custom pickling.

    """

    is_binary = True

    @classmethod
    def load(cls, file_obj):
        """Load serialized object from open pickle file.

        :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.

        :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("pickle", PickleSerializer)
manager.register("json", JSONSerializer)


class Item:
    """Represents a feedback item for Alfred.

    Generates Alfred-compliant JSON for a single item.

    Don'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="",
        arg=None,
        autocomplete=None,
        match=None,
        valid=False,
        uid=None,
        icon=None,
        icontype=None,
        type=None,  # pylint: disable=redefined-builtin
        largetext=None,
        copytext=None,
        quicklookurl=None,
    ):
        """Create a new :class:`Item` object.

        Use same arguments as for
        :class:`Workflow.Item <workflow.Workflow.Item>`.

        Argument ``subtitle_modifiers`` is not supported.

        """
        self.title = title
        self.subtitle = subtitle
        self.arg = arg
        self.autocomplete = autocomplete
        self.match = match
        self.valid = valid
        self.uid = uid
        self.icon = icon
        self.icontype = icontype
        self.type = type
        self.quicklookurl = quicklookurl
        self.largetext = largetext
        self.copytext = copytext

        self.modifiers = {}

        self.config = {}
        self.variables = {}

    def setvar(self, name, value):
        """Set a workflow variable for this Item.

        Args:
            name (str): Name of variable.
            value (str): Value of variable.

        """
        self.variables[name] = value

    def getvar(self, name, default=None):
        """Return value of workflow variable for ``name`` or ``default``.

        Args:
            name (str): Variable name.
            default (None, optional): Value to return if variable is unset.

        Returns:
            str or ``default``: Value of variable if set or ``default``.

        """
        return self.variables.get(name, default)

    def add_modifier(
        self, key, subtitle=None, arg=None, valid=None, icon=None, icontype=None
    ):
        """Add alternative values for a modifier key.

        Args:
            key (str): Modifier key, e.g. ``"cmd"`` or ``"alt"``
            subtitle (str, optional): Override item subtitle.
            arg (str, optional): Input for following action.
            valid (bool, optional): Override item validity.
            icon (str, optional): Filepath/UTI of icon.
            icontype (str, optional): Type of icon.  See
                :meth:`Workflow.add_item() <workflow.Workflow.add_item>`
                for valid values.

        In Alfred 4.1+, ``arg`` may also be a :class:`list` or :class:`tuple`.

        Returns:
            Modifier: Configured :class:`Modifier`.

        """
        mod = Modifier(key, subtitle, arg, valid, icon, icontype)

        # Add Item variables to Modifier
        mod.variables.update(self.variables)

        self.modifiers[key] = mod

        return mod

    @property
    def obj(self):
        """Item formatted for JSON serialization.

        Returns:
            dict: Data suitable for Alfred feedback.

        """
        # Required values
        obj_ = {
            "title": self.title,
            "subtitle": self.subtitle,
            "valid": self.valid,
        }

        # Optional values
        if self.arg is not None:
            obj_["arg"] = self.arg

        if self.autocomplete is not None:
            obj_["autocomplete"] = self.autocomplete

        if self.match is not None:
            obj_["match"] = self.match

        if self.uid is not None:
            obj_["uid"] = self.uid

        if self.type is not None:
            obj_["type"] = self.type

        if self.quicklookurl is not None:
            obj_["quicklookurl"] = self.quicklookurl

        if self.variables:
            obj_["variables"] = self.variables

        if self.config:
            obj_["config"] = self.config

        # Largetype and copytext
        text = self._text()

        if text:
            obj_["text"] = text

        icon = self._icon()

        if icon:
            obj_["icon"] = icon

        # Modifiers
        mods = self._modifiers()

        if mods:
            obj_["mods"] = mods

        return obj_

    def _icon(self):
        """Return `icon` object for item.

        Returns:
            dict: Mapping for item `icon` (may be empty).

        """
        icon = {}

        if self.icon is not None:
            icon["path"] = self.icon

        if self.icontype is not None:
            icon["type"] = self.icontype

        return icon

    def _text(self):
        """Return `largetext` and `copytext` object for item.

        Returns:
            dict: `text` mapping (may be empty)

        """
        text = {}

        if self.largetext is not None:
            text["largetype"] = self.largetext

        if self.copytext is not None:
            text["copy"] = self.copytext

        return text

    def _modifiers(self):
        """Build `mods` dictionary for JSON feedback.

        Returns:
            dict: Modifier mapping or `None`.

        """
        if self.modifiers:
            mods = {}
            for k, mod in self.modifiers.items():
                mods[k] = mod.obj

            return mods

        return None


class Variables(dict):
    """Workflow variables for Run Script actions.

    This class allows you to set workflow variables from
    Run Script actions.

    It is a subclass of :class:`dict`.

    >>> v = Variables(username="deanishe", password="hunter2")
    >>> v.arg = "output value"
    >>> print(v)

    See :ref:`variables-run-script` in the User Guide for more
    information.

    Args:
        arg (str or list, optional): Main output/``{query}``.
        **variables: Workflow variables to set.

    In Alfred 4.1+ ``arg`` may also be a :class:`list` or :class:`tuple`.

    Attributes:
        arg (str or list): Output value (``{query}``).
            In Alfred 4.1+ ``arg`` may also be a :class:`list` or
            :class:`tuple`.
        config (dict): Configuration for downstream workflow element.

    """

    def __init__(self, arg=None, **variables):
        """Create a new `Variables` object."""
        self.arg = arg
        self.config = {}
        super().__init__(**variables)

    @property
    def obj(self):
        """``alfredworkflow`` :class:`dict`."""
        obj_ = {}

        if self:
            variables = {}

            for k, v in self.items():
                variables[k] = v

            obj_["variables"] = variables

        if self.config:
            obj_["config"] = self.config

        if self.arg is not None:
            obj_["arg"] = self.arg

        return {"alfredworkflow": obj_}

    def __str__(self):
        """Convert to ``alfredworkflow`` JSON object.

        Returns:
            unicode: ``alfredworkflow`` JSON object

        """
        if not self and not self.config:
            if not self.arg:
                return ""
            if isinstance(self.arg, str):
                return self.arg

        return json.dumps(self.obj)


class Modifier:
    """Modify :class:`Item` arg/icon/variables when modifier key is pressed.

    Don't use this class directly (as it won't be associated with any
    :class:`Item`), but rather use :meth:`Item.add_modifier()`
    to add modifiers to results.

    >>> it = wf.add_item('Title', 'Subtitle', valid=True)
    >>> it.setvar('name', 'default')
    >>> m = it.add_modifier('cmd')
    >>> m.setvar('name', 'alternate')

    See :ref:`workflow-variables` in the User Guide for more information
    and :ref:`example usage <example-variables>`.

    Args:
        key (str): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc.
        subtitle (str, optional): Override default subtitle.
        arg (str, optional): Argument to pass for this modifier.
        valid (bool, optional): Override item's validity.
        icon (str, optional): Filepath/UTI of icon to use
        icontype (str, optional): Type of icon. See
            :meth:`Workflow.add_item() <workflow.Workflow.add_item>`
            for valid values.

    Attributes:
        arg (str): Arg to pass to following action.
        config (dict): Configuration for a downstream element, such as
            a File Filter.
        icon (str): Filepath/UTI of icon.
        icontype (str): Type of icon. See
            :meth:`Workflow.add_item() <workflow.Workflow.add_item>`
            for valid values.
        key (str): Modifier key (see above).
        subtitle (str): Override item subtitle.
        valid (bool): Override item validity.
        variables (dict): Workflow variables set by this modifier.

    """

    def __init__(
        self, key, subtitle=None, arg=None, valid=None, icon=None, icontype=None
    ):
        """Create a new :class:`Modifier`.

        Don't use this class directly (as it won't be associated with any
        :class:`Item`), but rather use :meth:`Item.add_modifier()`
        to add modifiers to results.

        Args:
            key (str): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc.
            subtitle (str, optional): Override default subtitle.
            arg (str, optional): Argument to pass for this modifier.
            valid (bool, optional): Override item's validity.
            icon (str, optional): Filepath/UTI of icon to use
            icontype (str, optional): Type of icon. See
                :meth:`Workflow.add_item() <workflow.Workflow.add_item>`
                for valid values.

        """
        self.key = key
        self.subtitle = subtitle
        self.arg = arg
        self.valid = valid
        self.icon = icon
        self.icontype = icontype

        self.config = {}
        self.variables = {}

    def setvar(self, name, value):
        """Set a workflow variable for this Item.

        Args:
            name (str): Name of variable.
            value (str): Value of variable.

        """
        self.variables[name] = value

    def getvar(self, name, default=None):
        """Return value of workflow variable for ``name`` or ``default``.

        Args:
            name (str): Variable name.
            default (None, optional): Value to return if variable is unset.

        Returns:
            str or ``default``: Value of variable if set or ``default``.

        """
        return self.variables.get(name, default)

    @property
    def obj(self):
        """Modifier formatted for JSON serialization.

        Returns:
            dict: Modifier for serializing to JSON.

        """
        obj_ = {}

        if self.subtitle is not None:
            obj_["subtitle"] = self.subtitle

        if self.arg is not None:
            obj_["arg"] = self.arg

        if self.valid is not None:
            obj_["valid"] = self.valid

        if self.variables:
            obj_["variables"] = self.variables

        if self.config:
            obj_["config"] = self.config

        icon = self._icon()

        if icon:
            obj_["icon"] = icon

        return obj_

    def _icon(self):
        """Return `icon` object for item.

        Returns:
            dict: Mapping for item `icon` (may be empty).

        """
        icon = {}

        if self.icon is not None:
            icon["path"] = self.icon

        if self.icontype is not None:
            icon["type"] = self.icontype

        return icon


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:`str`
    :param defaults: dict of default settings
    :type defaults: :class:`dict`


    An appropriate instance is provided by :class:`Workflow` instances at
    :attr:`Workflow.settings`.

    """

    def __init__(self, filepath, defaults=None):
        """Create new :class:`Settings` object."""
        super().__init__()
        self._filepath = filepath
        self._nosave = False
        self._original = {}

        if os.path.exists(self._filepath):
            self._load()
        elif defaults:
            for key, val in defaults.items():
                self[key] = val

            self.save()  # save default settings

    def _load(self):
        """Load cached settings from JSON file `self._filepath`."""
        data = {}

        with LockFile(self._filepath, 0.5):
            with open(self._filepath, "rb") as f:
                data.update(json.load(f))

        self._original = deepcopy(data)

        self._nosave = True
        self.update(data)
        self._nosave = False

    @uninterruptible
    def save(self):
        """Save settings to JSON file specified in ``self._filepath``.

        If you're using this class via :attr:`Workflow.settings`, which
        you probably are, ``self._filepath`` will be ``settings.json``
        in your workflow's data directory (see :attr:`~Workflow.datadir`).
        """
        if self._nosave:
            return

        data = {}
        data.update(self)

        with LockFile(self._filepath, 0.5):
            with atomic_writer(self._filepath, "w") as f:
                json.dump(data, f, sort_keys=True, indent=2)

    # dict methods
    def __setitem__(self, key, value):
        """Implement :class:`dict` interface."""
        if self._original.get(key) != value:
            super().__setitem__(key, value)
            self.save()

    def __delitem__(self, key):
        """Implement :class:`dict` interface."""
        super().__delitem__(key)
        self.save()

    def update(self, *args, **kwargs):
        """Override :class:`dict` method to save on update."""
        super().update(*args, **kwargs)
        self.save()

    def setdefault(self, key, value=None):
        """Override :class:`dict` method to save on update."""
        ret = super().setdefault(key, value)
        self.save()
        return ret


class Workflow:
    """The ``Workflow`` object is the main interface to Alfred-Workflow.
    It provides APIs for accessing the Alfred/workflow environment,
    storing & caching data, using Keychain, and generating Script
    Filter feedback.
    :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 releases. The only required key is ``github_slug``,
        whose value must take the form of ``username/repo``.
        If specified, ``Workflow`` will check the repo's releases
        for updates. Your workflow must also have a semantic version
        number. Please see the :ref:`User Manual <user-manual>` and
        `update API docs <api-updates>` for more information.
    :type update_settings: :class:`dict`
    :param input_encoding: encoding of command line arguments. You
        should probably leave this as the default (``utf-8``), which
        is the encoding Alfred uses.
    :type input_encoding: :class:`str`
    :param normalization: normalization to apply to CLI args.
        See :meth:`Workflow.decode` for more details.
    :type normalization: :class:`str`
    :param capture_args: Capture and act on ``workflow:*`` arguments. See
        :ref:`Magic arguments <magic-arguments>` for details.
    :type capture_args: :class:`Boolean`
    :param libraries: sequence of paths to directories containing
        libraries. These paths will be prepended to ``sys.path``.
    :type libraries: :class:`tuple` or :class:`list`
    :param help_url: URL to webpage where a user can ask for help with
        the workflow, report bugs, etc. This could be the GitHub repo
        or a page on AlfredForum.com. If your workflow throws an error,
        this URL will be displayed in the log and Alfred's debugger. It can
        also be opened directly in a web browser with the ``workflow:help``
        :ref:`magic argument <magic-arguments>`.
    :type help_url: :class:`str`
    """

    item_class = Item

    def __init__(
        self,
        default_settings=None,
        update_settings=None,
        input_encoding="utf-8",
        normalization="NFC",
        capture_args=True,
        libraries=None,
        help_url=None,
    ):
        """Create new :class:`Workflow` object."""
        self._default_settings = default_settings or {}
        self._update_settings = update_settings or {}
        self._input_encoding = input_encoding
        self._normalization = normalization
        self._capture_args = capture_args
        self.help_url = help_url
        self._workflowdir = None
        self._settings_path = None
        self._settings = None
        self._bundleid = None
        self._debugging = None
        self._name = None
        self._cache_serializer = "pickle"
        self._data_serializer = "pickle"
        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 = {}
        #: Prefix for all magic arguments.
        #: The default value is ``workflow:`` so keyword
        #: ``config`` would match user query ``workflow:config``.
        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 ``str``
        #:
        #: By default, the magic arguments documented
        #: :ref:`here <magic-arguments>` are registered.
        self.magic_arguments = {}
        self._register_default_magic()
        self.variables = {}
        self._rerun = 0
        # Get session ID from environment if present
        self._session_id = os.getenv("_WF_SESSION_ID") or None

        if self._session_id:
            self.setvar("_WF_SESSION_ID", self._session_id)

        if libraries:
            sys.path = libraries + sys.path

    ####################################################################
    # API methods
    ####################################################################

    # info.plist contents and alfred_* environment variables  ----------

    @property
    def alfred_version(self):
        """Alfred version as :class:`~workflow.update.Version` object."""
        from .update import Version

        return Version(self.alfred_env.get("version"))

    @property
    def alfred_env(self):
        """Dict of Alfred's environmental variables minus ``alfred_`` prefix.

        The variables Alfred exports are:

        ============================  =========================================
        Variable                      Description
        ============================  =========================================
        debug                         Set to ``1`` if Alfred's debugger is
                                      open, otherwise unset.
        preferences                   Path to Alfred.alfredpreferences
                                      (where your workflows and settings are
                                      stored).
        preferences_localhash         Machine-specific preferences are stored
                                      in ``Alfred.alfredpreferences/preferences/local/<hash>``
                                      (see ``preferences`` above for
                                      the path to ``Alfred.alfredpreferences``)
        theme                         ID of selected theme
        theme_background              Background colour of selected theme in
                                      format ``rgba(r,g,b,a)``
        theme_subtext                 Show result subtext.
                                      ``0`` = Always,
                                      ``1`` = Alternative actions only,
                                      ``2`` = Selected result only,
                                      ``3`` = Never
        version                       Alfred version number, e.g. ``'2.4'``
        version_build                 Alfred build number, e.g. ``277``
        workflow_bundleid             Bundle ID, e.g.
                                      ``net.deanishe.alfred-mailto``
        workflow_cache                Path to workflow's cache directory
        workflow_data                 Path to workflow's data directory
        workflow_name                 Name of current workflow
        workflow_uid                  UID of workflow
        workflow_version              The version number specified in the
                                      workflow configuration sheet/info.plist
        ============================  =========================================

        **Note:** all values are 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 (
            "debug",
            "preferences",
            "preferences_localhash",
            "theme",
            "theme_background",
            "theme_subtext",
            "version",
            "version_build",
            "workflow_bundleid",
            "workflow_cache",
            "workflow_data",
            "workflow_name",
            "workflow_uid",
            "workflow_version",
        ):
            value = os.getenv("alfred_" + key, "")

            if value:
                if key in ("debug", "version_build", "theme_subtext"):
                    value = int(value)

            data[key] = 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: ``str``

        """
        if not self._bundleid:
            if self.alfred_env.get("workflow_bundleid"):
                self._bundleid = self.alfred_env.get("workflow_bundleid")
            else:
                self._bundleid = self.info["bundleid"]

        return self._bundleid

    @property
    def debugging(self):
        """Whether Alfred's debugger is open.

        :returns: ``True`` if Alfred's debugger is open.
        :rtype: ``bool``

        """
        return self.alfred_env.get("debug") == 1

    @property
    def name(self):
        """Workflow name from Alfred's environmental vars or ``info.plist``.

        :returns: workflow name
        :rtype: ``str``

        """
        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.

        Get the workflow version from environment variable,
        the ``update_settings`` dict passed on
        instantiation, the ``version`` file located in the workflow's
        root directory or ``info.plist``. Return ``None`` if none
        exists or :class:`ValueError` if the version number is invalid
        (i.e. not semantic).

        :returns: Version of the workflow (not Alfred-Workflow)
        :rtype: :class:`~workflow.update.Version` object

        """
        if self._version is UNSET:
            version = None
            # environment variable has priority
            if self.alfred_env.get("workflow_version"):
                version = self.alfred_env["workflow_version"]
            # Try `update_settings`
            elif self._update_settings:
                version = self._update_settings.get("version")

            # `version` file
            if not version:
                filepath = self.workflowfile("version")

                if os.path.exists(filepath):
                    with open(filepath, "r", encoding="utf-8") as fileobj:
                        version = fileobj.read()

            # info.plist
            if not version:
                version = self.info.get("version")

            if version:
                from .update import Version

                version = Version(version)

            self._version = version

        return self._version

    # Workflow utility methods -----------------------------------------

    @property
    def args(self):
        """Return command line args as normalised unicode.

        Args are decoded and normalised via :meth:`~Workflow.decode`.

        The encoding and normalization are the ``input_encoding`` and
        ``normalization`` arguments passed to :class:`Workflow` (``UTF-8``
        and ``NFC`` are the defaults).

        If :class:`Workflow` is called with ``capture_args=True``
        (the default), :class:`Workflow` will look for certain
        ``workflow:*`` args and, if found, perform the corresponding
        actions and exit the workflow.

        See :ref:`Magic arguments <magic-arguments>` for details.

        """
        msg = None
        args = [self.decode(arg) for arg in sys.argv[1:]]

        # Handle magic args
        if len(args) and self._capture_args:
            for k, v in self.magic_arguments.items():
                key = f"{self.magic_prefix}{k}"

                if key in args:
                    msg = v()

            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/Workflow Data/<bundle id>``

        Returns:
            str: full path to workflow's cache directory

        """
        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/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/Workflow Data/<bundle id>``

        Returns:
            str: full path to workflow data directory

        """
        if self.alfred_env.get("workflow_data"):
            dirpath = self.alfred_env.get("workflow_data")
        else:
            dirpath = self._default_datadir

        return self._create(dirpath)

    @property
    def _default_datadir(self):
        """Alfred's default data directory."""
        return os.path.join(
            os.path.expanduser("~/Library/Application Support/Alfred/Workflow Data/"),
            self.bundleid,
        )

    @property
    def workflowdir(self):
        """Path to workflow's root directory (where ``info.plist`` is).

        Returns:
            str: full path to workflow root directory

        """
        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.getcwd()),
                os.path.dirname(os.path.abspath(os.path.dirname(__file__))),
            ]

            # climb the directory tree until we find `info.plist`
            for dirpath in candidates:
                while True:
                    if os.path.exists(os.path.join(dirpath, "info.plist")):
                        self._workflowdir = dirpath
                        break

                    if dirpath == "/":
                        # no `info.plist` found
                        break

                    # Check the parent directory
                    dirpath = os.path.dirname(dirpath)

                # No need to check other candidates
                if self._workflowdir:
                    break

            if not self._workflowdir:
                raise IOError("'info.plist' not found in directory tree")

        return self._workflowdir

    def cachefile(self, filename):
        """Path to ``filename`` in workflow's cache directory.

        Return absolute path to ``filename`` within your workflow's
        :attr:`cache directory <Workflow.cachedir>`.

        :param filename: basename of file
        :type filename: ``str``
        :returns: full path to file within cache directory
        :rtype: ``str``

        """
        return os.path.join(self.cachedir, filename)

    def datafile(self, filename):
        """Path to ``filename`` in workflow's data directory.

        Return absolute path to ``filename`` within your workflow's
        :attr:`data directory <Workflow.datadir>`.

        :param filename: basename of file
        :type filename: ``str``
        :returns: full path to file within data directory
        :rtype: ``str``

        """
        return os.path.join(self.datadir, filename)

    def workflowfile(self, filename):
        """Return full path to ``filename`` in workflow's root directory.

        :param filename: basename of file
        :type filename: ``str``
        :returns: full path to file within data directory
        :rtype: ``str``

        """
        return os.path.join(self.workflowdir, filename)

    @property
    def logfile(self):
        """Path to logfile.

        :returns: path to logfile within workflow's cache directory
        :rtype: ``str``

        """
        return self.cachefile(f"{self.bundleid}.log")

    @property
    def logger(self):
        """Logger that logs to both console and a log file.

        If Alfred's debugger is open, log level will be ``DEBUG``,
        else it will be ``INFO``.

        Use :meth:`open_log` to open the log file in Console.

        :returns: an initialised :class:`~logging.Logger`

        """
        if self._logger:
            return self._logger

        # Initialise new logger and optionally handlers
        logger = logging.getLogger("")

        # Only add one set of handlers
        # Exclude from coverage, as pytest will have configured the
        # root logger already
        if not logger.handlers:  # pragma: no cover
            fmt = logging.Formatter(
                "%(asctime)s %(filename)s:%(lineno)s %(levelname)-8s %(message)s",
                datefmt="%H:%M:%S",
            )

            logfile = logging.handlers.RotatingFileHandler(
                self.logfile, maxBytes=1024 * 1024, backupCount=1
            )
            logfile.setFormatter(fmt)
            logger.addHandler(logfile)

            console = logging.StreamHandler()
            console.setFormatter(fmt)
            logger.addHandler(console)

        if self.debugging:
            logger.setLevel(logging.DEBUG)
        else:
            logger.setLevel(logging.INFO)

        self._logger = logger

        return self._logger

    @logger.setter
    def logger(self, logger):
        """Set a custom logger.

        :param logger: The logger to use
        :type logger: `~logging.Logger` instance

        """
        self._logger = logger

    @property
    def settings_path(self):
        """Path to settings file within workflow's data directory.

        :returns: path to ``settings.json`` file
        :rtype: ``str``

        """
        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:`guide-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 %s", 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.

        This serializer is used by :meth:`cache_data()` and
        :meth:`cached_data()`

        See :class:`SerializerManager` for details.

        :returns: serializer name
        :rtype: ``str``

        """
        return self._cache_serializer

    @cache_serializer.setter
    def cache_serializer(self, serializer_name):
        """Set the default cache serialization format.

        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(
                f"Unknown serializer : `{serializer_name}`. "
                "Register your serializer with `manager` first."
            )

        self.logger.debug("default cache serializer: %s", serializer_name)

        self._cache_serializer = serializer_name

    @property
    def data_serializer(self):
        """Name of default data serializer.

        This serializer is used by :meth:`store_data()` and
        :meth:`stored_data()`

        See :class:`SerializerManager` for details.

        :returns: serializer name
        :rtype: ``str``

        """
        return self._data_serializer

    @data_serializer.setter
    def data_serializer(self, serializer_name):
        """Set the default cache serialization format.

        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(
                f"Unknown serializer : `{serializer_name}`. "
                "Register your serializer with `manager` first."
            )

        self.logger.debug("default data serializer: %s", serializer_name)

        self._data_serializer = serializer_name

    def store_data(self, name, data, serializer=None):
        """Save data to data directory.

        If ``data`` is ``None``, the datastore will be deleted.

        Note that the datastore does NOT support mutliple threads.

        :param name: name of datastore
        :param data: object(s) to store. **Note:** some serializers
            can only handled certain types of data.
        :param serializer: name of serializer to use. If no serializer
            is specified, the default will be used. See
            :class:`SerializerManager` for more information.
        :returns: data in datastore or ``None``

        """

        # Ensure deletion is not interrupted by SIGTERM
        @uninterruptible
        def delete_paths(paths):
            """Clear one or more data stores"""
            for path in paths:
                if os.path.exists(path):
                    os.unlink(path)
                    self.logger.debug("deleted data file: %s", path)

        serializer_name = serializer or self.data_serializer

        # In order for `stored_data()` to be able to load data stored with
        # an arbitrary serializer, yet still have meaningful file extensions,
        # the format (i.e. extension) is saved to an accompanying file
        metadata_path = self.datafile(f".{name}.alfred-workflow")
        filename = f"{name}.{serializer_name}"
        data_path = self.datafile(filename)

        if data_path == self.settings_path:
            raise ValueError(
                "Cannot save data to"
                + f"`{name}` with format `{serializer_name}`. "
                + "This would overwrite Alfred-Workflow's settings file."
            )

        serializer = manager.serializer(serializer_name)

        if serializer is None:
            raise ValueError(
                f"Invalid serializer `{serializer_name}`. "
                "Register your serializer with `manager.register()` first."
            )

        if data is None:  # Delete cached data
            delete_paths((metadata_path, data_path))
            return

        if isinstance(data, str):
            data = bytearray(data)

        # Ensure write is not interrupted by SIGTERM
        @uninterruptible
        def _store():
            # Save file extension
            with atomic_writer(metadata_path, "w") as file_obj:
                file_obj.write(serializer_name)

            with serializer.atomic_writer(data_path, "w") as file_obj:
                serializer.dump(data, file_obj)

        _store()

        self.logger.debug("saved data: %s", data_path)

    def stored_data(self, name):
        """Retrieve data from data directory.

        Returns ``None`` if there are no data stored under ``name``.

        :param name: name of datastore

        """
        metadata_path = self.datafile(f".{name}.alfred-workflow")

        if not os.path.exists(metadata_path):
            self.logger.debug("no data stored for `%s`", name)
            return None

        with open(metadata_path, "r", encoding="utf-8") as file_obj:
            serializer_name = file_obj.read().strip()

        serializer = manager.serializer(serializer_name)

        if serializer is None:
            raise ValueError(
                f"Unknown serializer `{serializer_name}`. "
                "Register a corresponding serializer with `manager.register()`"
                " to load this data."
            )

        self.logger.debug("data `%s` stored as `%s`", name, serializer_name)

        filename = f"{name}.{serializer_name}"
        data_path = self.datafile(filename)

        if not os.path.exists(data_path):
            self.logger.debug("no data stored: %s", 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: %s", data_path)

        return data

    @property
    def _session_prefix(self):
        """Filename prefix for current session."""
        return f"_wfsess-{self.session_id}-"

    def _mk_session_name(self, name):
        """New cache name/key based on session ID."""
        return self._session_prefix + name

    def clear_session_cache(self, current=False):
        """Remove session data from the cache.

        By default, data belonging to the current session won't be
        deleted. Set ``current=True`` to also clear current session.
        Args:
            current (bool, optional): If ``True``, also remove data for
                current session.
        """

        def _is_session_file(filename):
            if current:
                return filename.startswith("_wfsess-")
            return filename.startswith("_wfsess-") and not filename.startswith(
                self._session_prefix
            )

        self.clear_cache(_is_session_file)

    def cache_data(self, name, data, session=False):
        """Save ``data`` to cache under ``name``.

        If ``data`` is ``None``, the corresponding cache file will be
        deleted.

        If ``session`` is ``True``, then ``name`` is prefixed
        with :attr:`session_id`.

        :param name: name of datastore
        :param data: data to store. This may be any object supported by
                the cache serializer
        :param session: Whether to scope the cache to the current session.

        """
        if session:
            name = self._mk_session_name(name)

        serializer = manager.serializer(self.cache_serializer)

        cache_path = self.cachefile(f"{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 serializer.atomic_writer(cache_path, "w") as file_obj:
            serializer.dump(data, file_obj)

        self.logger.debug("cached data: %s", cache_path)

    def cached_data(self, name, data_func=None, max_age=60, session=False):
        """Return cached data if younger than ``max_age`` seconds.

        Retrieve data from cache or re-generate and re-cache data if
        stale/non-existant. If ``max_age`` is 0, return cached data no
        matter how old.

        If ``session`` is ``True``, then ``name`` is prefixed
        with :attr:`session_id`.

        :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

        """
        if session:
            name = self._mk_session_name(name)

        serializer = manager.serializer(self.cache_serializer)

        cache_path = self.cachefile(f"{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: %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 cached_data_fresh(self, name, max_age):
        """Whether cache `name` is less than `max_age` seconds old.

        :param name: name of datastore
        :param max_age: maximum age of data in seconds
        :type max_age: ``int``
        :returns: ``True`` if data is less than ``max_age`` old, else
            ``False``

        """
        age = self.cached_data_age(name)

        if not age:
            return False

        return age < max_age

    def cached_data_age(self, name):
        """Return age in seconds of cache `name` or 0 if cache doesn't exist.

        :param name: name of datastore
        :type name: ``str``
        :returns: age of datastore in seconds
        :rtype: ``int``

        """
        cache_path = self.cachefile(f"{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.

        If ``query`` is an empty string or contains only whitespace,
        all items will match.

        :param query: query to test items against
        :type query: ``str``
        :param items: iterable of items to test
        :type items: ``list`` or ``tuple``
        :param key: function to get comparison key from ``items``.
            Must return ``str``. The default simply returns
            the item.
        :type key: ``callable``
        :param ascending: set to ``True`` to get worst matches first
        :type ascending: ``Boolean``
        :param include_score: Useful for debugging the scoring algorithm.
            If ``True``, results will be a list of tuples
            ``(item, score, rule)``.
        :type include_score: ``Boolean``
        :param min_score: If non-zero, ignore results with a score lower
            than this.
        :type min_score: ``int``
        :param max_results: If non-zero, prune results list to this length.
        :type max_results: ``int``
        :param match_on: Filter option flags. Bitwise-combined list of
            ``MATCH_*`` constants (see below).
        :type match_on: ``int``
        :param fold_diacritics: Convert search keys to ASCII-only
            characters if ``query`` only contains ASCII characters.
        :type fold_diacritics: ``Boolean``
        :returns: list of ``items`` matching ``query`` or list of
            ``(item, score, rule)`` `tuples` if ``include_score`` is ``True``.
            ``rule`` is the ``MATCH_*`` rule that matched the item.
        :rtype: ``list``

        **Matching rules**

        By default, :meth:`filter` uses all of the following flags (i.e.
        :const:`MATCH_ALL`). The tests are always run in the given order:

        1. :const:`MATCH_STARTSWITH`
            Item search key starts with ``query`` (case-insensitive).
        2. :const:`MATCH_CAPITALS`
            The list of capital letters in item search key starts with
            ``query`` (``query`` may be lower-case). E.g., ``of``
            would match ``OmniFocus``, ``gc`` would match ``Google Chrome``.
        3. :const:`MATCH_ATOM`
            Search key is split into "atoms" on non-word characters
            (.,-,' etc.). Matches if ``query`` is one of these atoms
            (case-insensitive).
        4. :const:`MATCH_INITIALS_STARTSWITH`
            Initials are the first characters of the above-described
            "atoms" (case-insensitive).
        5. :const:`MATCH_INITIALS_CONTAIN`
            ``query`` is a substring of the above-described initials.
        6. :const:`MATCH_INITIALS`
            Combination of (4) and (5).
        7. :const:`MATCH_SUBSTRING`
            ``query`` is a substring of item search key (case-insensitive).
        8. :const:`MATCH_ALLCHARS`
            All characters in ``query`` appear in item search key in
            the same order (case-insensitive).
        9. :const:`MATCH_ALL`
            Combination of all the above.


        :const:`MATCH_ALLCHARS` is considerably slower than the other
        tests and provides much less accurate results.

        **Examples:**

        To ignore :const:`MATCH_ALLCHARS` (tends to provide the worst
        matches and is expensive to run), use
        ``match_on=MATCH_ALL ^ MATCH_ALLCHARS``.

        To match only on capitals, use ``match_on=MATCH_CAPITALS``.

        To match only on startswith and substring, use
        ``match_on=MATCH_STARTSWITH | MATCH_SUBSTRING``.

        **Diacritic folding**

        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:
            return items

        # Remove preceding/trailing spaces
        query = query.strip()

        if not query:
            return items

        # 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

                score_, rule = self._filter_item(value, word, match_on, fold_diacritics)

                if not score_:  # Skip items that don't match part of the query
                    skip = True

                score += score_

            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(key=lambda x: x[0], reverse=ascending)
        results = [result[1] for result in results]

        if min_score:
            results = [result for result in results if result[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 [result[0] for result 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)]
            # 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"
        if 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 char in query:
            pattern.append(f".*?{re.escape(char)}")

        pattern = "".join(pattern)
        search = re.compile(pattern, re.IGNORECASE).search

        self._search_pattern_cache[query] = search
        return search

    def run(self, func, text_errors=False):
        """Call ``func`` to run your workflow.

        :param func: Callable to call with ``self`` (i.e. the :class:`Workflow`
            instance) as first argument.
        :param text_errors: Emit error messages in plain text, not in
            Alfred's XML/JSON feedback format. Use this when you're not
            running Alfred-Workflow in a Script Filter and would like
            to pass the error message to, say, a notification.
        :type text_errors: ``Boolean``

        ``func`` will be called with :class:`Workflow` instance as first
        argument.

        ``func`` should be the main entry point to your workflow.

        Any exceptions raised will be logged and an error message will be
        output to Alfred.

        """
        start = time.time()

        # Write to debugger to ensure "real" output starts on a new line
        print(".", file=sys.stderr)

        # 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(
                    "---------- %s (%s) ----------", self.name, self.version
                )
            else:
                self.logger.debug("---------- %s ----------", self.name)

            # 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:  # pylint: disable=broad-except
            self.logger.exception(err)

            if self.help_url:
                self.logger.info("for assistance, see: %s", self.help_url)

            if not sys.stdout.isatty():  # Show error in Alfred
                if text_errors:
                    print(str(err).encode("utf-8"), end="")
                else:
                    self._items = []

                    if self._name:
                        name = self._name
                    elif self._bundleid:  # pragma: no cover
                        name = self._bundleid
                    else:  # pragma: no cover
                        name = os.path.dirname(__file__)

                    self.add_item(
                        f"Error in workflow '{name}'", str(err), icon=ICON_ERROR
                    )
                    self.send_feedback()
            return 1
        finally:
            self.logger.debug(
                "---------- finished in %0.3fs ----------", time.time() - start
            )

        return 0

    # Alfred feedback methods ------------------------------------------

    @property
    def rerun(self):
        """How often (in seconds) Alfred should re-run the Script Filter."""
        return self._rerun

    @rerun.setter
    def rerun(self, seconds):
        """Interval at which Alfred should re-run the Script Filter.

        Args:
            seconds (int): Interval between runs.
        """
        self._rerun = seconds

    @property
    def session_id(self):
        """A unique session ID every time the user uses the workflow.

        The session ID persists while the user is using this workflow.
        It expires when the user runs a different workflow or closes
        Alfred.

        """
        if not self._session_id:
            self._session_id = uuid4().hex
            self.setvar("_WF_SESSION_ID", self._session_id)

        return self._session_id

    def setvar(self, name, value, persist=False):
        """Set a "global" workflow variable.

        .. versionchanged:: 1.33

        These variables are always passed to downstream workflow objects.

        If you have set :attr:`rerun`, these variables are also passed
        back to the script when Alfred runs it again.

        Args:
            name (str): Name of variable.
            value (str): Value of variable.
            persist (bool, optional): Also save variable to ``info.plist``?

        """
        self.variables[name] = value
        if persist:
            set_config(name, value, self.bundleid)
            self.logger.debug(
                "saved variable %r with value %r to info.plist", name, value
            )

    def getvar(self, name, default=None):
        """Return value of workflow variable for ``name`` or ``default``.

        Args:
            name (str): Variable name.
            default (None, optional): Value to return if variable is unset.

        Returns:
            str or ``default``: Value of variable if set or ``default``.

        """
        return self.variables.get(name, default)

    def add_item(
        self,
        title,
        subtitle="",
        arg=None,
        autocomplete=None,
        valid=False,
        uid=None,
        icon=None,
        icontype=None,
        type=None,  # pylint: disable=redefined-builtin
        largetext=None,
        copytext=None,
        quicklookurl=None,
        match=None,
    ):
        """Add an item to be output to Alfred.

        :param title: Title shown in Alfred.
        :type title: ``str``
        :param subtitle: Subtitle shown in Alfred.
        :type subtitle: ``str``
        :param arg: Argument passed by Alfred as ``{query}`` when item is
            actioned.
        :type arg: ``str``, ``list`` or ``tuple``
        :param autocomplete: Text expanded in Alfred when item is TABbed.
        :type autocomplete: ``str``
        :param valid: Whether or not item can be actioned.
        :type valid: ``Boolean``
        :param uid: Used by Alfred to remember/sort items.
        :type uid: ``str``
        :param icon: Filename of icon to use.
        :type icon: ``str``
        :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: ``str``
        :param type: Result type. Currently only ``'file'`` is supported
            (by Alfred). This will tell Alfred to enable file actions for
            this item.
        :type type: ``str``
        :param largetext: Text to be displayed in Alfred's large text box
            if user presses CMD+L on item.
        :type largetext: ``str``
        :param copytext: Text to be copied to pasteboard if user presses
            CMD+C on item.
        :type copytext: ``str``
        :param quicklookurl: URL to be displayed using Alfred's Quick Look
            feature (tapping ``SHIFT`` or ``⌘+Y`` on a result).
        :type quicklookurl: ``str``
        :param match: If you have "Alfred filters results" turned on for
            your Script Filter, Alfred will filter against this field, not
            ``title``.
        :type match: ``str``
        :returns: :class:`Item` instance

        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,
            arg,
            autocomplete,
            match,
            valid,
            uid,
            icon,
            icontype,
            type,
            largetext,
            copytext,
            quicklookurl,
        )

        # Add variables to child item
        item.variables.update(self.variables)

        self._items.append(item)
        return item

    @property
    def obj(self):
        """Feedback formatted for JSON serialization.

        Returns:
            dict: Data suitable for Alfred feedback.

        """
        items = []

        for item in self._items:
            items.append(item.obj)

        obj_ = {"items": items}

        if self.variables:
            obj_["variables"] = self.variables

        if self.rerun:
            obj_["rerun"] = self.rerun

        return obj_

    def warn_empty(self, title, subtitle="", icon=None):
        """Add a warning to feedback if there are no items.

        Add a warning item to Alfred feedback if no other items
        have been added. This is a handy shortcut to prevent Alfred
        from showing its fallback searches, which it does if no
        items are returned.

        Args:
            title (str): Title of feedback item.
            subtitle (str, optional): Subtitle of feedback item.
            icon (str, optional): Icon for feedback item. If not
                specified, ``ICON_ERROR`` is used.

        Returns:
            Item: Newly-created item.

        """
        if self._items:
            return None

        icon = icon or ICON_ERROR
        return self.add_item(title, subtitle, icon=icon)

    def send_feedback(self):
        """Print stored items to console/Alfred as JSON."""
        if self.debugging:
            json.dump(self.obj, sys.stdout, indent=2, separators=(",", ": "))
        else:
            json.dump(self.obj, sys.stdout)

        sys.stdout.flush()

    ####################################################################
    # Updating methods
    ####################################################################

    @property
    def first_run(self):
        """Return ``True`` if it's the first time this version has run.

        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``).

        :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: %s", self._last_version_run)

        return self._last_version_run

    def set_last_version(self, version=None):
        """Set :attr:`last_version_run` to current version.

        :param version: version to store (default is current version)
        :type version: :class:`~workflow.update.Version` instance
            or ``str``
        :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, str):
            from .update import Version

            version = Version(version)

        self.settings["__workflow_last_version"] = str(version)

        self.logger.debug("set last run version: %s", version)

        return True

    @property
    def update_available(self):
        """Whether an update is available.

        See :ref:`guide-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``

        """
        key = "__workflow_latest_version"
        # Create a new workflow object to ensure standard serialiser
        # is used (update.py is called without the user's settings)
        status = Workflow().cached_data(key, max_age=0)

        if not status or not status.get("available"):
            return False

        return status["available"]

    @property
    def prereleases(self):
        """Whether workflow should update to pre-release versions.

        :returns: ``True`` if pre-releases are enabled with the :ref:`magic
            argument <magic-arguments>` or the ``update_settings`` dict, else
            ``False``.

        """
        if self._update_settings.get("prereleases"):
            return True

        return self.settings.get("__workflow_prereleases") or False

    def check_update(self, force=False):
        """Call update script if it's time to check for a new release.

        The update script will be run in the background, so it won't
        interfere in the execution of your workflow.

        See :ref:`guide-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``

        """
        key = "__workflow_latest_version"
        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(key, frequency * 86400):
            repo = self._update_settings["github_slug"]
            version = str(self.version)

            from .background import run_in_background

            cmd = ["python3", "-m", "workflow.update", "check", repo, version]

            if self.prereleases:
                cmd.append("--prereleases")

            self.logger.info("checking for update ...")

            run_in_background("__workflow_update_check", cmd)
        else:
            self.logger.debug("update check not due")

    def start_update(self):
        """Check for update and download and install new workflow file.

        See :ref:`guide-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``

        """
        from . import update

        repo = self._update_settings["github_slug"]
        version = str(self.version)

        if not update.check_update(repo, version, self.prereleases):
            return False

        from .background import run_in_background

        cmd = ["python3", "-m", "workflow.update", "install", repo, version]

        if self.prereleases:
            cmd.append("--prereleases")

        self.logger.debug("downloading update ...")
        run_in_background("__workflow_update_install", cmd)

        return True

    ####################################################################
    # Keychain password storage methods
    ####################################################################

    def save_password(self, account, password, service=None):
        """Save account credentials.
        If the account exists, the old password will first be deleted
        (Keychain throws an error otherwise).
        If something goes wrong, a :class:`KeychainError` exception will
        be raised.
        :param account: name of the account the password is for, e.g.
            "Pinboard"
        :type account: ``str``
        :param password: the password to secure
        :type password: ``str``
        :param service: Name of the service. By default, this is the
            workflow's bundle ID
        :type service: ``str``
        """
        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: ``str``
        :param service: Name of the service. By default, this is the workflow's
                        bundle ID
        :type service: ``str``
        :returns: account password
        :rtype: ``str``
        """
        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
        match = re.search(
            r'password:\s*(?:0x(?P<hex>[0-9A-F]+)\s*)?(?:"(?P<pw>.*)")?', output
        )

        if match:
            groups = match.groupdict()
            hex_ = groups.get("hex")
            password = groups.get("pw")

            if hex_:
                password = str(binascii.unhexlify(hex_), "utf-8")

        self.logger.debug("got password : %s:%s", service, account)

        return password

    def delete_password(self, account, service=None):
        """Delete the password stored at ``service/account``.
        Raise :class:`PasswordNotFound` if account is unknown.
        :param account: name of the account the password is for, e.g.
            "Pinboard"
        :type account: ``str``
        :param service: Name of the service. By default, this is the workflow's
                        bundle ID
        :type service: ``str``
        """
        if not service:
            service = self.bundleid

        self._call_security("delete-generic-password", service, account)

        self.logger.debug("deleted password : %s:%s", service, account)

    ####################################################################
    # Methods for workflow:* magic args
    ####################################################################

    def _register_default_magic(self):
        """Register the built-in magic arguments."""

        # TODO: refactor & simplify
        # Wrap callback and message with callable
        def callback(func, msg):
            def wrapper():
                func()
                return msg

            return wrapper

        self.magic_arguments["delcache"] = callback(
            self.clear_cache, "Deleted workflow cache"
        )
        self.magic_arguments["deldata"] = callback(
            self.clear_data, "Deleted workflow data"
        )
        self.magic_arguments["delsettings"] = callback(
            self.clear_settings, "Deleted workflow settings"
        )
        self.magic_arguments["reset"] = callback(self.reset, "Reset workflow")
        self.magic_arguments["openlog"] = callback(
            self.open_log, "Opening workflow log file"
        )
        self.magic_arguments["opencache"] = callback(
            self.open_cachedir, "Opening workflow cache directory"
        )
        self.magic_arguments["opendata"] = callback(
            self.open_datadir, "Opening workflow data directory"
        )
        self.magic_arguments["openworkflow"] = callback(
            self.open_workflowdir, "Opening workflow directory"
        )
        self.magic_arguments["openterm"] = callback(
            self.open_terminal, "Opening workflow root directory in Terminal"
        )

        # Diacritic folding
        def fold_on():
            self.settings["__workflow_diacritic_folding"] = True
            return "Diacritics will always be folded"

        def fold_off():
            self.settings["__workflow_diacritic_folding"] = False
            return "Diacritics will never be folded"

        def fold_default():
            if "__workflow_diacritic_folding" in self.settings:
                del self.settings["__workflow_diacritic_folding"]
            return "Diacritics folding reset"

        self.magic_arguments["foldingon"] = fold_on
        self.magic_arguments["foldingoff"] = fold_off
        self.magic_arguments["foldingdefault"] = fold_default

        # Updates
        def update_on():
            self.settings["__workflow_autoupdate"] = True
            return "Auto update turned on"

        def update_off():
            self.settings["__workflow_autoupdate"] = False
            return "Auto update turned off"

        def prereleases_on():
            self.settings["__workflow_prereleases"] = True
            return "Prerelease updates turned on"

        def prereleases_off():
            self.settings["__workflow_prereleases"] = False
            return "Prerelease updates turned off"

        def do_update():
            if self.start_update():
                return "Downloading and installing update ..."

            return "No update available"

        self.magic_arguments["autoupdate"] = update_on
        self.magic_arguments["noautoupdate"] = update_off
        self.magic_arguments["prereleases"] = prereleases_on
        self.magic_arguments["noprereleases"] = prereleases_off
        self.magic_arguments["update"] = do_update

        # Help
        def do_help():
            if self.help_url:
                self.open_help()
                return "Opening workflow help URL in browser"

            return "Workflow has no help URL"

        def show_version():
            if self.version:
                return f"Version: {self.version}"

            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 = self.magic_prefix + name
                self.logger.debug(arg)

                if not isatty:
                    self.add_item(arg, icon=ICON_INFO)

            if not isatty:
                self.send_feedback()

        self.magic_arguments["help"] = do_help
        self.magic_arguments["magic"] = list_magic
        self.magic_arguments["version"] = show_version

    def clear_cache(self, filter_func=lambda f: True):
        """Delete all files in workflow's :attr:`cachedir`.

        :param filter_func: Callable to determine whether a file should be
            deleted or not. ``filter_func`` is called with the filename
            of each file in the data directory. If it returns ``True``,
            the file will be deleted.
            By default, *all* files will be deleted.
        :type filter_func: ``callable``
        """
        self._delete_directory_contents(self.cachedir, filter_func)

    def clear_data(self, filter_func=lambda f: True):
        """Delete all files in workflow's :attr:`datadir`.

        :param filter_func: Callable to determine whether a file should be
            deleted or not. ``filter_func`` is called with the filename
            of each file in the data directory. If it returns ``True``,
            the file will be deleted.
            By default, *all* files will be deleted.
        :type filter_func: ``callable``
        """
        self._delete_directory_contents(self.datadir, filter_func)

    def clear_settings(self):
        """Delete workflow's :attr:`settings_path`."""
        if os.path.exists(self.settings_path):
            os.unlink(self.settings_path)
            self.logger.debug("deleted : %r", self.settings_path)

    def reset(self):
        """Delete workflow settings, cache and data.

        File :attr:`settings <settings_path>` and directories
        :attr:`cache <cachedir>` and :attr:`data <datadir>` are deleted.

        """
        self.clear_cache()
        self.clear_data()
        self.clear_settings()

    def open_log(self):
        """Open :attr:`logfile` in default app (usually Console.app)."""
        subprocess.run(["/usr/bin/open", self.logfile], check=True)

    def open_cachedir(self):
        """Open the workflow's :attr:`cachedir` in Finder."""
        subprocess.run(["/usr/bin/open", self.cachedir], check=True)

    def open_datadir(self):
        """Open the workflow's :attr:`datadir` in Finder."""
        subprocess.run(["/usr/bin/open", self.datadir], check=True)

    def open_workflowdir(self):
        """Open the workflow's :attr:`workflowdir` in Finder."""
        subprocess.run(["/usr/bin/open", self.workflowdir], check=True)

    def open_terminal(self):
        """Open a Terminal window at workflow's :attr:`workflowdir`."""
        subprocess.run(
            ["/usr/bin/open", "-a", "Terminal", self.workflowdir], check=True
        )

    def open_help(self):
        """Open :attr:`help_url` in default browser."""
        subprocess.run(["/usr/bin/open", self.help_url], check=True)

        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: ``str`` or ``None``
        :param normalization: The nomalisation form to apply to ``text``.
        :type normalization: ``str`` or ``None``
        :returns: decoded and normalised ``str``
        :class:`Workflow` uses "NFC" normalization by default. This is the
        standard for Python and will work well with data from the web (via
        :mod:`~workflow.web` or :mod:`json`).
        macOS, on the other hand, uses "NFD" normalization (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 normalization used by
        :class:`Workflow`.
        """
        encoding = encoding or self._input_encoding
        normalization = normalization or self._normalization

        if not isinstance(text, str):
            text = str(text, encoding)

        return unicodedata.normalize(normalization, text)

    @staticmethod
    def fold_to_ascii(text):
        """Convert non-ASCII characters to closest ASCII equivalent.
        .. note:: This only works for a subset of European languages.
        :param text: text to convert
        :type text: ``str``
        :returns: text containing only ASCII characters
        :rtype: ``str``
        """
        if isascii(text):
            return text

        text = "".join([ASCII_REPLACEMENTS.get(c, c) for c in text])
        return unicodedata.normalize("NFKD", text)

    @staticmethod
    def dumbify_punctuation(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.
        :param text: text to convert
        :type text: ``str``
        :returns: text with only ASCII punctuation
        :rtype: ``str``
        """
        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: ``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: %s", path)

    def _load_info_plist(self):
        """Load workflow info from ``info.plist``."""
        # info.plist should be in the directory above this one
        with open(self.workflowfile("info.plist"), "rb") as file_obj:
            self._info = plistlib.load(file_obj)
        self._info_loaded = True

    @staticmethod
    def _create(dirpath):
        """Create directory `dirpath` if it doesn't exist.
        :param dirpath: path to directory
        :type dirpath: ``str``
        :returns: ``dirpath`` argument
        :rtype: ``str``
        """
        if not os.path.exists(dirpath):
            os.makedirs(dirpath)

        return dirpath

    @staticmethod
    def _call_security(action, service, account, *args):
        """Call ``security`` CLI program that provides access to keychains.
        May raise `PasswordNotFound`, `PasswordExists` or `KeychainError`
        exceptions (the first two are subclasses of `KeychainError`).
        :param action: The ``security`` action to call, e.g.
                           ``add-generic-password``
        :type action: ``str``
        :param service: Name of the service.
        :type service: ``str``
        :param account: name of the account the password is for, e.g.
            "Pinboard"
        :type account: ``str``
        :param password: the password to secure
        :type password: ``str``
        :param *args: list of command line arguments to be passed to
                      ``security``
        :type *args: `list` or `tuple`
        :returns: Data from stdout.
        :rtype: ``str``
        """
        cmd = ["security", action, "-s", service, "-a", account] + list(args)
        with subprocess.Popen(
            cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
        ) as proc:
            stdout, _ = proc.communicate()
            if proc.returncode == 44:  # password does not exist
                raise PasswordNotFound()
            if proc.returncode == 45:  # password already exists
                raise PasswordExists()
            if proc.returncode > 0:
                err = KeychainError(f"Unknown Keychain error : {stdout}")
                raise err
            return stdout.strip().decode("utf-8")
