├── TODO ├── icon.png ├── src ├── workflow │ ├── version │ ├── Notify.tgz │ ├── __init__.py │ ├── background.py │ ├── notify.py │ ├── update.py │ ├── util.py │ ├── workflow3.py │ └── web.py ├── Icon.png ├── docopt.py ├── info.plist └── ff.py ├── demo.gif ├── OS X Folder - Search.icns ├── Fuzzy-Folders-2.4.0.alfredworkflow ├── LICENCE ├── benchmarks.py ├── .gitignore └── README.md /TODO: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- 1 | src/icon.png -------------------------------------------------------------------------------- /src/workflow/version: -------------------------------------------------------------------------------- 1 | 1.40.0 -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-fuzzyfolders/HEAD/demo.gif -------------------------------------------------------------------------------- /src/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-fuzzyfolders/HEAD/src/Icon.png -------------------------------------------------------------------------------- /src/workflow/Notify.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-fuzzyfolders/HEAD/src/workflow/Notify.tgz -------------------------------------------------------------------------------- /OS X Folder - Search.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-fuzzyfolders/HEAD/OS X Folder - Search.icns -------------------------------------------------------------------------------- /Fuzzy-Folders-2.4.0.alfredworkflow: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-fuzzyfolders/HEAD/Fuzzy-Folders-2.4.0.alfredworkflow -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Dean Jackson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /benchmarks.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright © 2014 deanishe@deanishe.net 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2014-04-02 9 | # 10 | 11 | """ 12 | """ 13 | 14 | from __future__ import print_function, unicode_literals 15 | 16 | import os 17 | import subprocess 18 | from time import time 19 | 20 | 21 | DIRS = [os.path.expanduser('~/Documents'), '/Volumes/Media/Video'] 22 | 23 | 24 | def get_num_results(cmd): 25 | s = time() 26 | output = subprocess.check_output(cmd).decode('utf-8') 27 | print('\t`mdfind` finished in {:0.4f} seconds'.format(time() - s)) 28 | lines = [l.strip() for l in output.split('\n') if l.strip()] 29 | return len(lines) 30 | 31 | for root in DIRS: 32 | for query in ('i', 'in', 'inl'): 33 | basecmd = ['mdfind', '-onlyin', root] 34 | file_cmd = basecmd + ["(kMDItemFSName == '*{}*'c)".format(query)] 35 | dir_cmd = basecmd + ["(kMDItemFSName == '*{}*'c) && (kMDItemContentType == 'public.folder')".format(query)] 36 | s = time() 37 | print('{} files found for `{}` in `{}` in {:0.4f} seconds'.format( 38 | get_num_results(file_cmd), query, root, time() - s)) 39 | s = time() 40 | print('{} folders found for `{}` in `{}` in {:0.4f} seconds'.format( 41 | get_num_results(dir_cmd), query, root, time() - s)) 42 | -------------------------------------------------------------------------------- /src/workflow/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 Dean Jackson 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2014-02-15 9 | # 10 | 11 | """A helper library for `Alfred `_ workflows.""" 12 | 13 | import os 14 | 15 | # Workflow objects 16 | from .workflow import Workflow, manager 17 | from .workflow3 import Variables, Workflow3 18 | 19 | # Exceptions 20 | from .workflow import PasswordNotFound, KeychainError 21 | 22 | # Icons 23 | from .workflow import ( 24 | ICON_ACCOUNT, 25 | ICON_BURN, 26 | ICON_CLOCK, 27 | ICON_COLOR, 28 | ICON_COLOUR, 29 | ICON_EJECT, 30 | ICON_ERROR, 31 | ICON_FAVORITE, 32 | ICON_FAVOURITE, 33 | ICON_GROUP, 34 | ICON_HELP, 35 | ICON_HOME, 36 | ICON_INFO, 37 | ICON_NETWORK, 38 | ICON_NOTE, 39 | ICON_SETTINGS, 40 | ICON_SWIRL, 41 | ICON_SWITCH, 42 | ICON_SYNC, 43 | ICON_TRASH, 44 | ICON_USER, 45 | ICON_WARNING, 46 | ICON_WEB, 47 | ) 48 | 49 | # Filter matching rules 50 | from .workflow import ( 51 | MATCH_ALL, 52 | MATCH_ALLCHARS, 53 | MATCH_ATOM, 54 | MATCH_CAPITALS, 55 | MATCH_INITIALS, 56 | MATCH_INITIALS_CONTAIN, 57 | MATCH_INITIALS_STARTSWITH, 58 | MATCH_STARTSWITH, 59 | MATCH_SUBSTRING, 60 | ) 61 | 62 | 63 | __title__ = 'Alfred-Workflow' 64 | __version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read() 65 | __author__ = 'Dean Jackson' 66 | __licence__ = 'MIT' 67 | __copyright__ = 'Copyright 2014-2019 Dean Jackson' 68 | 69 | __all__ = [ 70 | 'Variables', 71 | 'Workflow', 72 | 'Workflow3', 73 | 'manager', 74 | 'PasswordNotFound', 75 | 'KeychainError', 76 | 'ICON_ACCOUNT', 77 | 'ICON_BURN', 78 | 'ICON_CLOCK', 79 | 'ICON_COLOR', 80 | 'ICON_COLOUR', 81 | 'ICON_EJECT', 82 | 'ICON_ERROR', 83 | 'ICON_FAVORITE', 84 | 'ICON_FAVOURITE', 85 | 'ICON_GROUP', 86 | 'ICON_HELP', 87 | 'ICON_HOME', 88 | 'ICON_INFO', 89 | 'ICON_NETWORK', 90 | 'ICON_NOTE', 91 | 'ICON_SETTINGS', 92 | 'ICON_SWIRL', 93 | 'ICON_SWITCH', 94 | 'ICON_SYNC', 95 | 'ICON_TRASH', 96 | 'ICON_USER', 97 | 'ICON_WARNING', 98 | 'ICON_WEB', 99 | 'MATCH_ALL', 100 | 'MATCH_ALLCHARS', 101 | 'MATCH_ATOM', 102 | 'MATCH_CAPITALS', 103 | 'MATCH_INITIALS', 104 | 'MATCH_INITIALS_CONTAIN', 105 | 'MATCH_INITIALS_STARTSWITH', 106 | 'MATCH_STARTSWITH', 107 | 'MATCH_SUBSTRING', 108 | ] 109 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/sublimetext,python,vim 3 | 4 | ### SublimeText ### 5 | # cache files for sublime text 6 | *.tmlanguage.cache 7 | *.tmPreferences.cache 8 | *.stTheme.cache 9 | 10 | # workspace files are user-specific 11 | *.sublime-workspace 12 | 13 | # project files should be checked into the repository, unless a significant 14 | # proportion of contributors will probably not be using SublimeText 15 | # *.sublime-project 16 | 17 | # sftp configuration file 18 | sftp-config.json 19 | 20 | # Package control specific files 21 | Package Control.last-run 22 | Package Control.ca-list 23 | Package Control.ca-bundle 24 | Package Control.system-ca-bundle 25 | Package Control.cache/ 26 | Package Control.ca-certs/ 27 | bh_unicode_properties.cache 28 | 29 | # Sublime-github package stores a github token in this file 30 | # https://packagecontrol.io/packages/sublime-github 31 | GitHub.sublime-settings 32 | 33 | 34 | ### Python ### 35 | # Byte-compiled / optimized / DLL files 36 | __pycache__/ 37 | *.py[cod] 38 | *$py.class 39 | 40 | # C extensions 41 | *.so 42 | 43 | # Distribution / packaging 44 | .Python 45 | env/ 46 | build/ 47 | develop-eggs/ 48 | dist/ 49 | downloads/ 50 | eggs/ 51 | .eggs/ 52 | lib/ 53 | lib64/ 54 | parts/ 55 | sdist/ 56 | var/ 57 | *.egg-info/ 58 | .installed.cfg 59 | *.egg 60 | 61 | # PyInstaller 62 | # Usually these files are written by a python script from a template 63 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 64 | *.manifest 65 | *.spec 66 | 67 | # Installer logs 68 | pip-log.txt 69 | pip-delete-this-directory.txt 70 | 71 | # Unit test / coverage reports 72 | htmlcov/ 73 | .tox/ 74 | .coverage 75 | .coverage.* 76 | .cache 77 | nosetests.xml 78 | coverage.xml 79 | *,cover 80 | .hypothesis/ 81 | 82 | # Translations 83 | *.mo 84 | *.pot 85 | 86 | # Django stuff: 87 | *.log 88 | local_settings.py 89 | 90 | # Flask stuff: 91 | instance/ 92 | .webassets-cache 93 | 94 | # Scrapy stuff: 95 | .scrapy 96 | 97 | # Sphinx documentation 98 | docs/_build/ 99 | 100 | # PyBuilder 101 | target/ 102 | 103 | # IPython Notebook 104 | .ipynb_checkpoints 105 | 106 | # pyenv 107 | .python-version 108 | 109 | # celery beat schedule file 110 | celerybeat-schedule 111 | 112 | # dotenv 113 | .env 114 | 115 | # virtualenv 116 | .venv/ 117 | venv/ 118 | ENV/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | 127 | ### Vim ### 128 | # swap 129 | [._]*.s[a-w][a-z] 130 | [._]s[a-w][a-z] 131 | # session 132 | Session.vim 133 | # temporary 134 | .netrwhist 135 | *~ 136 | # auto-generated tag files 137 | tags 138 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fuzzy Folders Alfred Workflow # 2 | 3 | Fuzzy search across folder subtrees. Add your own keywords to directly search specific folders. 4 | 5 | ![](https://github.com/deanishe/alfred-fuzzyfolders/raw/master/demo.gif "") 6 | 7 | This Workflow provides partial matching of path components, allowing you to drill down into your filesystem with a space-separated query. Each "word" of the query will be matched against the components of a directory or file's path, so a three-word query will only match at least three levels down from the specified root directory. 8 | 9 | You can use a **File Action** to intiate a fuzzy search on a folder or to assign a keyword to perform a fuzzy search on that folder. 10 | 11 | ## Download ## 12 | 13 | Get the Workflow from [GitHub](https://github.com/deanishe/alfred-fuzzyfolders/releases/latest) or [Packal](http://www.packal.org/workflow/fuzzy-folders). 14 | 15 | ## Commands ## 16 | 17 | - `fuzzy` — List your Fuzzy Folders 18 | + `↩` — Edit Fuzzy Folder settings 19 | + `⌘+↩` — Start a Fuzzy Folder search with the associated keyword 20 | + `⌥+↩` — Delete the keyword–Fuzzy Folder combination 21 | - `fzyup` — Recreate the Script Filters from your saved configuration (useful after an update) 22 | - `fzyhelp` — Open the help file in your browser 23 | 24 | ## Settings ## 25 | 26 | You can specify these settings globally as defaults for all Fuzzy Folders or on a per-folder basis. For ad-hoc searches via the `Fuzzy Search Here` file action, the default settings always apply. 27 | 28 | Use keyword `fuzzy` to view and edit settings. 29 | 30 | - **Minimum query length** — The last "word" of a query must be this long to trigger a search. Default is `1`, but increase this number if the search is too slow. This is very often the case if you're searching a large subtree and/or also choose to search files. 31 | - **Search scope** — Choose to search only for folders, files or both. **Note:** In most cases, searches including files are significantly slower. Consider increasing the **minimum query length** to speed up slow searches. 32 | 33 | ## File Actions ## 34 | 35 | - `Fuzzy Search Here` — Fuzzy search this folder 36 | - `Add Fuzzy Folder` — Set a keyword for this folder for faster fuzzy searching 37 | 38 | ## Search result actions ## 39 | 40 | - `↩` — Open folder in Finder 41 | - `⌘+↩` — Browse folder in Alfred 42 | 43 | ## Excludes ## 44 | 45 | Fuzzy Folders supports global and folder-specific glob-style excludes (similar to `.gitignore`). 46 | 47 | Currently, these can only be configured by editing the `settings.json` file by hand. To open `settings.json`, enter the query `fuzzy workflow:opendata` into Alfred. This will open the workflow's data directory in Finder and reveal the `settings.json` file. 48 | 49 | The `settings.json` file will look something like this: 50 | 51 | ```json 52 | { 53 | "defaults": { 54 | "min": 2, 55 | "scope": 1 56 | }, 57 | "profiles": { 58 | "1": { 59 | "dirpath": "/Users/dean/Documents", 60 | "keyword": "docs" 61 | }, 62 | "2": { 63 | "dirpath": "/Users/dean/Code", 64 | "excludes": [ 65 | "*.pyc", 66 | "/alfred-*" 67 | ], 68 | "keyword": "code", 69 | "scope": 3 70 | }, 71 | "3": { 72 | "dirpath": "/Volumes/Media/Video", 73 | "keyword": "vids", 74 | "scope": 3 75 | }, 76 | "4": { 77 | "dirpath": "/Users/dean/Documents/Translations", 78 | "excludes": [], 79 | "keyword": "trans" 80 | } 81 | } 82 | } 83 | ``` 84 | 85 | The `defaults` key may not be present if you haven't changed the default settings and won't have an `excludes` member. Any `profiles` (saved fuzzy folders) added using a version of the workflow with support for excludes will be created with an empty list for the key `excludes`, but `profiles` created with earlier versions won't have an `excludes` member. Add it by hand. 86 | 87 | `excludes` should be a list of strings containing `.gitignore`/shell-style patterns, e.g.: 88 | 89 | ```json 90 | "excludes": [ 91 | "*.pyc", 92 | "/alfred-*" 93 | ], 94 | ``` 95 | 96 | In contrast to the other default settings, `min` (minimum query length) and `scope` (search folders (1), files (2) or both (3)), the default `excludes` will be added to folder-specific ones, not replaced by them. 97 | 98 | So to exclude compiled Python files from all fuzzy folders, add `*.pyc` to the `excludes` list under `defaults`: 99 | 100 | ```json 101 | { 102 | "defaults": { 103 | "min": 2, 104 | "scope": 1, 105 | "excludes": ["*.pyc"] 106 | } 107 | } 108 | ``` 109 | 110 | ## Bugs, questions, feedback ## 111 | 112 | You can [open an issue on GitHub](https://github.com/deanishe/alfred-fuzzyfolders/issues), or post on the [Alfred Forum](http://www.alfredforum.com/topic/4042-fuzzy-folders/). 113 | 114 | ## Licensing, other stuff ## 115 | 116 | This Workflow is made available under the [MIT Licence](http://opensource.org/licenses/MIT). 117 | 118 | The icon was made by [Jono Hunt](http://iconaholic.com/). 119 | 120 | It uses [docopt](https://github.com/docopt/docopt) and [Alfred-Workflow](https://github.com/deanishe/alfred-workflow). 121 | -------------------------------------------------------------------------------- /src/workflow/background.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 deanishe@deanishe.net 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2014-04-06 9 | # 10 | 11 | """This module provides an API to run commands in background processes. 12 | 13 | Combine with the :ref:`caching API ` to work from cached data 14 | while you fetch fresh data in the background. 15 | 16 | See :ref:`the User Manual ` for more information 17 | and examples. 18 | """ 19 | 20 | from __future__ import print_function, unicode_literals 21 | 22 | import signal 23 | import sys 24 | import os 25 | import subprocess 26 | import pickle 27 | 28 | from workflow import Workflow 29 | 30 | __all__ = ['is_running', 'run_in_background'] 31 | 32 | _wf = None 33 | 34 | 35 | def wf(): 36 | global _wf 37 | if _wf is None: 38 | _wf = Workflow() 39 | return _wf 40 | 41 | 42 | def _log(): 43 | return wf().logger 44 | 45 | 46 | def _arg_cache(name): 47 | """Return path to pickle cache file for arguments. 48 | 49 | :param name: name of task 50 | :type name: ``unicode`` 51 | :returns: Path to cache file 52 | :rtype: ``unicode`` filepath 53 | 54 | """ 55 | return wf().cachefile(name + '.argcache') 56 | 57 | 58 | def _pid_file(name): 59 | """Return path to PID file for ``name``. 60 | 61 | :param name: name of task 62 | :type name: ``unicode`` 63 | :returns: Path to PID file for task 64 | :rtype: ``unicode`` filepath 65 | 66 | """ 67 | return wf().cachefile(name + '.pid') 68 | 69 | 70 | def _process_exists(pid): 71 | """Check if a process with PID ``pid`` exists. 72 | 73 | :param pid: PID to check 74 | :type pid: ``int`` 75 | :returns: ``True`` if process exists, else ``False`` 76 | :rtype: ``Boolean`` 77 | 78 | """ 79 | try: 80 | os.kill(pid, 0) 81 | except OSError: # not running 82 | return False 83 | return True 84 | 85 | 86 | def _job_pid(name): 87 | """Get PID of job or `None` if job does not exist. 88 | 89 | Args: 90 | name (str): Name of job. 91 | 92 | Returns: 93 | int: PID of job process (or `None` if job doesn't exist). 94 | """ 95 | pidfile = _pid_file(name) 96 | if not os.path.exists(pidfile): 97 | return 98 | 99 | with open(pidfile, 'rb') as fp: 100 | pid = int(fp.read()) 101 | 102 | if _process_exists(pid): 103 | return pid 104 | 105 | os.unlink(pidfile) 106 | 107 | 108 | def is_running(name): 109 | """Test whether task ``name`` is currently running. 110 | 111 | :param name: name of task 112 | :type name: unicode 113 | :returns: ``True`` if task with name ``name`` is running, else ``False`` 114 | :rtype: bool 115 | 116 | """ 117 | if _job_pid(name) is not None: 118 | return True 119 | 120 | return False 121 | 122 | 123 | def _background(pidfile, stdin='/dev/null', stdout='/dev/null', 124 | stderr='/dev/null'): # pragma: no cover 125 | """Fork the current process into a background daemon. 126 | 127 | :param pidfile: file to write PID of daemon process to. 128 | :type pidfile: filepath 129 | :param stdin: where to read input 130 | :type stdin: filepath 131 | :param stdout: where to write stdout output 132 | :type stdout: filepath 133 | :param stderr: where to write stderr output 134 | :type stderr: filepath 135 | 136 | """ 137 | def _fork_and_exit_parent(errmsg, wait=False, write=False): 138 | try: 139 | pid = os.fork() 140 | if pid > 0: 141 | if write: # write PID of child process to `pidfile` 142 | tmp = pidfile + '.tmp' 143 | with open(tmp, 'wb') as fp: 144 | fp.write(str(pid)) 145 | os.rename(tmp, pidfile) 146 | if wait: # wait for child process to exit 147 | os.waitpid(pid, 0) 148 | os._exit(0) 149 | except OSError as err: 150 | _log().critical('%s: (%d) %s', errmsg, err.errno, err.strerror) 151 | raise err 152 | 153 | # Do first fork and wait for second fork to finish. 154 | _fork_and_exit_parent('fork #1 failed', wait=True) 155 | 156 | # Decouple from parent environment. 157 | os.chdir(wf().workflowdir) 158 | os.setsid() 159 | 160 | # Do second fork and write PID to pidfile. 161 | _fork_and_exit_parent('fork #2 failed', write=True) 162 | 163 | # Now I am a daemon! 164 | # Redirect standard file descriptors. 165 | si = open(stdin, 'r', 0) 166 | so = open(stdout, 'a+', 0) 167 | se = open(stderr, 'a+', 0) 168 | if hasattr(sys.stdin, 'fileno'): 169 | os.dup2(si.fileno(), sys.stdin.fileno()) 170 | if hasattr(sys.stdout, 'fileno'): 171 | os.dup2(so.fileno(), sys.stdout.fileno()) 172 | if hasattr(sys.stderr, 'fileno'): 173 | os.dup2(se.fileno(), sys.stderr.fileno()) 174 | 175 | 176 | def kill(name, sig=signal.SIGTERM): 177 | """Send a signal to job ``name`` via :func:`os.kill`. 178 | 179 | .. versionadded:: 1.29 180 | 181 | Args: 182 | name (str): Name of the job 183 | sig (int, optional): Signal to send (default: SIGTERM) 184 | 185 | Returns: 186 | bool: `False` if job isn't running, `True` if signal was sent. 187 | """ 188 | pid = _job_pid(name) 189 | if pid is None: 190 | return False 191 | 192 | os.kill(pid, sig) 193 | return True 194 | 195 | 196 | def run_in_background(name, args, **kwargs): 197 | r"""Cache arguments then call this script again via :func:`subprocess.call`. 198 | 199 | :param name: name of job 200 | :type name: unicode 201 | :param args: arguments passed as first argument to :func:`subprocess.call` 202 | :param \**kwargs: keyword arguments to :func:`subprocess.call` 203 | :returns: exit code of sub-process 204 | :rtype: int 205 | 206 | When you call this function, it caches its arguments and then calls 207 | ``background.py`` in a subprocess. The Python subprocess will load the 208 | cached arguments, fork into the background, and then run the command you 209 | specified. 210 | 211 | This function will return as soon as the ``background.py`` subprocess has 212 | forked, returning the exit code of *that* process (i.e. not of the command 213 | you're trying to run). 214 | 215 | If that process fails, an error will be written to the log file. 216 | 217 | If a process is already running under the same name, this function will 218 | return immediately and will not run the specified command. 219 | 220 | """ 221 | if is_running(name): 222 | _log().info('[%s] job already running', name) 223 | return 224 | 225 | argcache = _arg_cache(name) 226 | 227 | # Cache arguments 228 | with open(argcache, 'wb') as fp: 229 | pickle.dump({'args': args, 'kwargs': kwargs}, fp) 230 | _log().debug('[%s] command cached: %s', name, argcache) 231 | 232 | # Call this script 233 | cmd = ['/usr/bin/python', __file__, name] 234 | _log().debug('[%s] passing job to background runner: %r', name, cmd) 235 | retcode = subprocess.call(cmd) 236 | 237 | if retcode: # pragma: no cover 238 | _log().error('[%s] background runner failed with %d', name, retcode) 239 | else: 240 | _log().debug('[%s] background job started', name) 241 | 242 | return retcode 243 | 244 | 245 | def main(wf): # pragma: no cover 246 | """Run command in a background process. 247 | 248 | Load cached arguments, fork into background, then call 249 | :meth:`subprocess.call` with cached arguments. 250 | 251 | """ 252 | log = wf.logger 253 | name = wf.args[0] 254 | argcache = _arg_cache(name) 255 | if not os.path.exists(argcache): 256 | msg = '[{0}] command cache not found: {1}'.format(name, argcache) 257 | log.critical(msg) 258 | raise IOError(msg) 259 | 260 | # Fork to background and run command 261 | pidfile = _pid_file(name) 262 | _background(pidfile) 263 | 264 | # Load cached arguments 265 | with open(argcache, 'rb') as fp: 266 | data = pickle.load(fp) 267 | 268 | # Cached arguments 269 | args = data['args'] 270 | kwargs = data['kwargs'] 271 | 272 | # Delete argument cache file 273 | os.unlink(argcache) 274 | 275 | try: 276 | # Run the command 277 | log.debug('[%s] running command: %r', name, args) 278 | 279 | retcode = subprocess.call(args, **kwargs) 280 | 281 | if retcode: 282 | log.error('[%s] command failed with status %d', name, retcode) 283 | finally: 284 | os.unlink(pidfile) 285 | 286 | log.debug('[%s] job complete', name) 287 | 288 | 289 | if __name__ == '__main__': # pragma: no cover 290 | wf().run(main) 291 | -------------------------------------------------------------------------------- /src/workflow/notify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2015 deanishe@deanishe.net 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2015-11-26 9 | # 10 | 11 | # TODO: Exclude this module from test and code coverage in py2.6 12 | 13 | """ 14 | Post notifications via the macOS Notification Center. 15 | 16 | This feature is only available on Mountain Lion (10.8) and later. 17 | It will silently fail on older systems. 18 | 19 | The main API is a single function, :func:`~workflow.notify.notify`. 20 | 21 | It works by copying a simple application to your workflow's data 22 | directory. It replaces the application's icon with your workflow's 23 | icon and then calls the application to post notifications. 24 | """ 25 | 26 | from __future__ import print_function, unicode_literals 27 | 28 | import os 29 | import plistlib 30 | import shutil 31 | import subprocess 32 | import sys 33 | import tarfile 34 | import tempfile 35 | import uuid 36 | 37 | import workflow 38 | 39 | 40 | _wf = None 41 | _log = None 42 | 43 | 44 | #: Available system sounds from System Preferences > Sound > Sound Effects 45 | SOUNDS = ( 46 | 'Basso', 47 | 'Blow', 48 | 'Bottle', 49 | 'Frog', 50 | 'Funk', 51 | 'Glass', 52 | 'Hero', 53 | 'Morse', 54 | 'Ping', 55 | 'Pop', 56 | 'Purr', 57 | 'Sosumi', 58 | 'Submarine', 59 | 'Tink', 60 | ) 61 | 62 | 63 | def wf(): 64 | """Return Workflow object for this module. 65 | 66 | Returns: 67 | workflow.Workflow: Workflow object for current workflow. 68 | """ 69 | global _wf 70 | if _wf is None: 71 | _wf = workflow.Workflow() 72 | return _wf 73 | 74 | 75 | def log(): 76 | """Return logger for this module. 77 | 78 | Returns: 79 | logging.Logger: Logger for this module. 80 | """ 81 | global _log 82 | if _log is None: 83 | _log = wf().logger 84 | return _log 85 | 86 | 87 | def notifier_program(): 88 | """Return path to notifier applet executable. 89 | 90 | Returns: 91 | unicode: Path to Notify.app ``applet`` executable. 92 | """ 93 | return wf().datafile('Notify.app/Contents/MacOS/applet') 94 | 95 | 96 | def notifier_icon_path(): 97 | """Return path to icon file in installed Notify.app. 98 | 99 | Returns: 100 | unicode: Path to ``applet.icns`` within the app bundle. 101 | """ 102 | return wf().datafile('Notify.app/Contents/Resources/applet.icns') 103 | 104 | 105 | def install_notifier(): 106 | """Extract ``Notify.app`` from the workflow to data directory. 107 | 108 | Changes the bundle ID of the installed app and gives it the 109 | workflow's icon. 110 | """ 111 | archive = os.path.join(os.path.dirname(__file__), 'Notify.tgz') 112 | destdir = wf().datadir 113 | app_path = os.path.join(destdir, 'Notify.app') 114 | n = notifier_program() 115 | log().debug('installing Notify.app to %r ...', destdir) 116 | # z = zipfile.ZipFile(archive, 'r') 117 | # z.extractall(destdir) 118 | tgz = tarfile.open(archive, 'r:gz') 119 | tgz.extractall(destdir) 120 | if not os.path.exists(n): # pragma: nocover 121 | raise RuntimeError('Notify.app could not be installed in ' + destdir) 122 | 123 | # Replace applet icon 124 | icon = notifier_icon_path() 125 | workflow_icon = wf().workflowfile('icon.png') 126 | if os.path.exists(icon): 127 | os.unlink(icon) 128 | 129 | png_to_icns(workflow_icon, icon) 130 | 131 | # Set file icon 132 | # PyObjC isn't available for 2.6, so this is 2.7 only. Actually, 133 | # none of this code will "work" on pre-10.8 systems. Let it run 134 | # until I figure out a better way of excluding this module 135 | # from coverage in py2.6. 136 | if sys.version_info >= (2, 7): # pragma: no cover 137 | from AppKit import NSWorkspace, NSImage 138 | 139 | ws = NSWorkspace.sharedWorkspace() 140 | img = NSImage.alloc().init() 141 | img.initWithContentsOfFile_(icon) 142 | ws.setIcon_forFile_options_(img, app_path, 0) 143 | 144 | # Change bundle ID of installed app 145 | ip_path = os.path.join(app_path, 'Contents/Info.plist') 146 | bundle_id = '{0}.{1}'.format(wf().bundleid, uuid.uuid4().hex) 147 | data = plistlib.readPlist(ip_path) 148 | log().debug('changing bundle ID to %r', bundle_id) 149 | data['CFBundleIdentifier'] = bundle_id 150 | plistlib.writePlist(data, ip_path) 151 | 152 | 153 | def validate_sound(sound): 154 | """Coerce ``sound`` to valid sound name. 155 | 156 | Returns ``None`` for invalid sounds. Sound names can be found 157 | in ``System Preferences > Sound > Sound Effects``. 158 | 159 | Args: 160 | sound (str): Name of system sound. 161 | 162 | Returns: 163 | str: Proper name of sound or ``None``. 164 | """ 165 | if not sound: 166 | return None 167 | 168 | # Case-insensitive comparison of `sound` 169 | if sound.lower() in [s.lower() for s in SOUNDS]: 170 | # Title-case is correct for all system sounds as of macOS 10.11 171 | return sound.title() 172 | return None 173 | 174 | 175 | def notify(title='', text='', sound=None): 176 | """Post notification via Notify.app helper. 177 | 178 | Args: 179 | title (str, optional): Notification title. 180 | text (str, optional): Notification body text. 181 | sound (str, optional): Name of sound to play. 182 | 183 | Raises: 184 | ValueError: Raised if both ``title`` and ``text`` are empty. 185 | 186 | Returns: 187 | bool: ``True`` if notification was posted, else ``False``. 188 | """ 189 | if title == text == '': 190 | raise ValueError('Empty notification') 191 | 192 | sound = validate_sound(sound) or '' 193 | 194 | n = notifier_program() 195 | 196 | if not os.path.exists(n): 197 | install_notifier() 198 | 199 | env = os.environ.copy() 200 | enc = 'utf-8' 201 | env['NOTIFY_TITLE'] = title.encode(enc) 202 | env['NOTIFY_MESSAGE'] = text.encode(enc) 203 | env['NOTIFY_SOUND'] = sound.encode(enc) 204 | cmd = [n] 205 | retcode = subprocess.call(cmd, env=env) 206 | if retcode == 0: 207 | return True 208 | 209 | log().error('Notify.app exited with status {0}.'.format(retcode)) 210 | return False 211 | 212 | 213 | def convert_image(inpath, outpath, size): 214 | """Convert an image file using ``sips``. 215 | 216 | Args: 217 | inpath (str): Path of source file. 218 | outpath (str): Path to destination file. 219 | size (int): Width and height of destination image in pixels. 220 | 221 | Raises: 222 | RuntimeError: Raised if ``sips`` exits with non-zero status. 223 | """ 224 | cmd = [ 225 | b'sips', 226 | b'-z', str(size), str(size), 227 | inpath, 228 | b'--out', outpath] 229 | # log().debug(cmd) 230 | with open(os.devnull, 'w') as pipe: 231 | retcode = subprocess.call(cmd, stdout=pipe, stderr=subprocess.STDOUT) 232 | 233 | if retcode != 0: 234 | raise RuntimeError('sips exited with %d' % retcode) 235 | 236 | 237 | def png_to_icns(png_path, icns_path): 238 | """Convert PNG file to ICNS using ``iconutil``. 239 | 240 | Create an iconset from the source PNG file. Generate PNG files 241 | in each size required by macOS, then call ``iconutil`` to turn 242 | them into a single ICNS file. 243 | 244 | Args: 245 | png_path (str): Path to source PNG file. 246 | icns_path (str): Path to destination ICNS file. 247 | 248 | Raises: 249 | RuntimeError: Raised if ``iconutil`` or ``sips`` fail. 250 | """ 251 | tempdir = tempfile.mkdtemp(prefix='aw-', dir=wf().datadir) 252 | 253 | try: 254 | iconset = os.path.join(tempdir, 'Icon.iconset') 255 | 256 | if os.path.exists(iconset): # pragma: nocover 257 | raise RuntimeError('iconset already exists: ' + iconset) 258 | 259 | os.makedirs(iconset) 260 | 261 | # Copy source icon to icon set and generate all the other 262 | # sizes needed 263 | configs = [] 264 | for i in (16, 32, 128, 256, 512): 265 | configs.append(('icon_{0}x{0}.png'.format(i), i)) 266 | configs.append((('icon_{0}x{0}@2x.png'.format(i), i * 2))) 267 | 268 | shutil.copy(png_path, os.path.join(iconset, 'icon_256x256.png')) 269 | shutil.copy(png_path, os.path.join(iconset, 'icon_128x128@2x.png')) 270 | 271 | for name, size in configs: 272 | outpath = os.path.join(iconset, name) 273 | if os.path.exists(outpath): 274 | continue 275 | convert_image(png_path, outpath, size) 276 | 277 | cmd = [ 278 | b'iconutil', 279 | b'-c', b'icns', 280 | b'-o', icns_path, 281 | iconset] 282 | 283 | retcode = subprocess.call(cmd) 284 | if retcode != 0: 285 | raise RuntimeError('iconset exited with %d' % retcode) 286 | 287 | if not os.path.exists(icns_path): # pragma: nocover 288 | raise ValueError( 289 | 'generated ICNS file not found: ' + repr(icns_path)) 290 | finally: 291 | try: 292 | shutil.rmtree(tempdir) 293 | except OSError: # pragma: no cover 294 | pass 295 | 296 | 297 | if __name__ == '__main__': # pragma: nocover 298 | # Simple command-line script to test module with 299 | # This won't work on 2.6, as `argparse` isn't available 300 | # by default. 301 | import argparse 302 | 303 | from unicodedata import normalize 304 | 305 | def ustr(s): 306 | """Coerce `s` to normalised Unicode.""" 307 | return normalize('NFD', s.decode('utf-8')) 308 | 309 | p = argparse.ArgumentParser() 310 | p.add_argument('-p', '--png', help="PNG image to convert to ICNS.") 311 | p.add_argument('-l', '--list-sounds', help="Show available sounds.", 312 | action='store_true') 313 | p.add_argument('-t', '--title', 314 | help="Notification title.", type=ustr, 315 | default='') 316 | p.add_argument('-s', '--sound', type=ustr, 317 | help="Optional notification sound.", default='') 318 | p.add_argument('text', type=ustr, 319 | help="Notification body text.", default='', nargs='?') 320 | o = p.parse_args() 321 | 322 | # List available sounds 323 | if o.list_sounds: 324 | for sound in SOUNDS: 325 | print(sound) 326 | sys.exit(0) 327 | 328 | # Convert PNG to ICNS 329 | if o.png: 330 | icns = os.path.join( 331 | os.path.dirname(o.png), 332 | os.path.splitext(os.path.basename(o.png))[0] + '.icns') 333 | 334 | print('converting {0!r} to {1!r} ...'.format(o.png, icns), 335 | file=sys.stderr) 336 | 337 | if os.path.exists(icns): 338 | raise ValueError('destination file already exists: ' + icns) 339 | 340 | png_to_icns(o.png, icns) 341 | sys.exit(0) 342 | 343 | # Post notification 344 | if o.title == o.text == '': 345 | print('ERROR: empty notification.', file=sys.stderr) 346 | sys.exit(1) 347 | else: 348 | notify(o.title, o.text, o.sound) 349 | -------------------------------------------------------------------------------- /src/workflow/update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 Fabio Niephaus , 5 | # Dean Jackson 6 | # 7 | # MIT Licence. See http://opensource.org/licenses/MIT 8 | # 9 | # Created on 2014-08-16 10 | # 11 | 12 | """Self-updating from GitHub. 13 | 14 | .. versionadded:: 1.9 15 | 16 | .. note:: 17 | 18 | This module is not intended to be used directly. Automatic updates 19 | are controlled by the ``update_settings`` :class:`dict` passed to 20 | :class:`~workflow.workflow.Workflow` objects. 21 | 22 | """ 23 | 24 | from __future__ import print_function, unicode_literals 25 | 26 | from collections import defaultdict 27 | from functools import total_ordering 28 | import json 29 | import os 30 | import tempfile 31 | import re 32 | import subprocess 33 | 34 | import workflow 35 | import web 36 | 37 | # __all__ = [] 38 | 39 | 40 | RELEASES_BASE = 'https://api.github.com/repos/{}/releases' 41 | match_workflow = re.compile(r'\.alfred(\d+)?workflow$').search 42 | 43 | _wf = None 44 | 45 | 46 | def wf(): 47 | """Lazy `Workflow` object.""" 48 | global _wf 49 | if _wf is None: 50 | _wf = workflow.Workflow() 51 | return _wf 52 | 53 | 54 | @total_ordering 55 | class Download(object): 56 | """A workflow file that is available for download. 57 | 58 | .. versionadded: 1.37 59 | 60 | Attributes: 61 | url (str): URL of workflow file. 62 | filename (str): Filename of workflow file. 63 | version (Version): Semantic version of workflow. 64 | prerelease (bool): Whether version is a pre-release. 65 | alfred_version (Version): Minimum compatible version 66 | of Alfred. 67 | 68 | """ 69 | 70 | @classmethod 71 | def from_dict(cls, d): 72 | """Create a `Download` from a `dict`.""" 73 | return cls(url=d['url'], filename=d['filename'], 74 | version=Version(d['version']), 75 | prerelease=d['prerelease']) 76 | 77 | @classmethod 78 | def from_releases(cls, js): 79 | """Extract downloads from GitHub releases. 80 | 81 | Searches releases with semantic tags for assets with 82 | file extension .alfredworkflow or .alfredXworkflow where 83 | X is a number. 84 | 85 | Files are returned sorted by latest version first. Any 86 | releases containing multiple files with the same (workflow) 87 | extension are rejected as ambiguous. 88 | 89 | Args: 90 | js (str): JSON response from GitHub's releases endpoint. 91 | 92 | Returns: 93 | list: Sequence of `Download`. 94 | """ 95 | releases = json.loads(js) 96 | downloads = [] 97 | for release in releases: 98 | tag = release['tag_name'] 99 | dupes = defaultdict(int) 100 | try: 101 | version = Version(tag) 102 | except ValueError as err: 103 | wf().logger.debug('ignored release: bad version "%s": %s', 104 | tag, err) 105 | continue 106 | 107 | dls = [] 108 | for asset in release.get('assets', []): 109 | url = asset.get('browser_download_url') 110 | filename = os.path.basename(url) 111 | m = match_workflow(filename) 112 | if not m: 113 | wf().logger.debug('unwanted file: %s', filename) 114 | continue 115 | 116 | ext = m.group(0) 117 | dupes[ext] = dupes[ext] + 1 118 | dls.append(Download(url, filename, version, 119 | release['prerelease'])) 120 | 121 | valid = True 122 | for ext, n in dupes.items(): 123 | if n > 1: 124 | wf().logger.debug('ignored release "%s": multiple assets ' 125 | 'with extension "%s"', tag, ext) 126 | valid = False 127 | break 128 | 129 | if valid: 130 | downloads.extend(dls) 131 | 132 | downloads.sort(reverse=True) 133 | return downloads 134 | 135 | def __init__(self, url, filename, version, prerelease=False): 136 | """Create a new Download. 137 | 138 | Args: 139 | url (str): URL of workflow file. 140 | filename (str): Filename of workflow file. 141 | version (Version): Version of workflow. 142 | prerelease (bool, optional): Whether version is 143 | pre-release. Defaults to False. 144 | 145 | """ 146 | if isinstance(version, basestring): 147 | version = Version(version) 148 | 149 | self.url = url 150 | self.filename = filename 151 | self.version = version 152 | self.prerelease = prerelease 153 | 154 | @property 155 | def alfred_version(self): 156 | """Minimum Alfred version based on filename extension.""" 157 | m = match_workflow(self.filename) 158 | if not m or not m.group(1): 159 | return Version('0') 160 | return Version(m.group(1)) 161 | 162 | @property 163 | def dict(self): 164 | """Convert `Download` to `dict`.""" 165 | return dict(url=self.url, filename=self.filename, 166 | version=str(self.version), prerelease=self.prerelease) 167 | 168 | def __str__(self): 169 | """Format `Download` for printing.""" 170 | u = ('Download(url={dl.url!r}, ' 171 | 'filename={dl.filename!r}, ' 172 | 'version={dl.version!r}, ' 173 | 'prerelease={dl.prerelease!r})'.format(dl=self)) 174 | 175 | return u.encode('utf-8') 176 | 177 | def __repr__(self): 178 | """Code-like representation of `Download`.""" 179 | return str(self) 180 | 181 | def __eq__(self, other): 182 | """Compare Downloads based on version numbers.""" 183 | if self.url != other.url \ 184 | or self.filename != other.filename \ 185 | or self.version != other.version \ 186 | or self.prerelease != other.prerelease: 187 | return False 188 | return True 189 | 190 | def __ne__(self, other): 191 | """Compare Downloads based on version numbers.""" 192 | return not self.__eq__(other) 193 | 194 | def __lt__(self, other): 195 | """Compare Downloads based on version numbers.""" 196 | if self.version != other.version: 197 | return self.version < other.version 198 | return self.alfred_version < other.alfred_version 199 | 200 | 201 | class Version(object): 202 | """Mostly semantic versioning. 203 | 204 | The main difference to proper :ref:`semantic versioning ` 205 | is that this implementation doesn't require a minor or patch version. 206 | 207 | Version strings may also be prefixed with "v", e.g.: 208 | 209 | >>> v = Version('v1.1.1') 210 | >>> v.tuple 211 | (1, 1, 1, '') 212 | 213 | >>> v = Version('2.0') 214 | >>> v.tuple 215 | (2, 0, 0, '') 216 | 217 | >>> Version('3.1-beta').tuple 218 | (3, 1, 0, 'beta') 219 | 220 | >>> Version('1.0.1') > Version('0.0.1') 221 | True 222 | """ 223 | 224 | #: Match version and pre-release/build information in version strings 225 | match_version = re.compile(r'([0-9][0-9\.]*)(.+)?').match 226 | 227 | def __init__(self, vstr): 228 | """Create new `Version` object. 229 | 230 | Args: 231 | vstr (basestring): Semantic version string. 232 | """ 233 | if not vstr: 234 | raise ValueError('invalid version number: {!r}'.format(vstr)) 235 | 236 | self.vstr = vstr 237 | self.major = 0 238 | self.minor = 0 239 | self.patch = 0 240 | self.suffix = '' 241 | self.build = '' 242 | self._parse(vstr) 243 | 244 | def _parse(self, vstr): 245 | if vstr.startswith('v'): 246 | m = self.match_version(vstr[1:]) 247 | else: 248 | m = self.match_version(vstr) 249 | if not m: 250 | raise ValueError('invalid version number: ' + vstr) 251 | 252 | version, suffix = m.groups() 253 | parts = self._parse_dotted_string(version) 254 | self.major = parts.pop(0) 255 | if len(parts): 256 | self.minor = parts.pop(0) 257 | if len(parts): 258 | self.patch = parts.pop(0) 259 | if not len(parts) == 0: 260 | raise ValueError('version number too long: ' + vstr) 261 | 262 | if suffix: 263 | # Build info 264 | idx = suffix.find('+') 265 | if idx > -1: 266 | self.build = suffix[idx+1:] 267 | suffix = suffix[:idx] 268 | if suffix: 269 | if not suffix.startswith('-'): 270 | raise ValueError( 271 | 'suffix must start with - : ' + suffix) 272 | self.suffix = suffix[1:] 273 | 274 | def _parse_dotted_string(self, s): 275 | """Parse string ``s`` into list of ints and strings.""" 276 | parsed = [] 277 | parts = s.split('.') 278 | for p in parts: 279 | if p.isdigit(): 280 | p = int(p) 281 | parsed.append(p) 282 | return parsed 283 | 284 | @property 285 | def tuple(self): 286 | """Version number as a tuple of major, minor, patch, pre-release.""" 287 | return (self.major, self.minor, self.patch, self.suffix) 288 | 289 | def __lt__(self, other): 290 | """Implement comparison.""" 291 | if not isinstance(other, Version): 292 | raise ValueError('not a Version instance: {0!r}'.format(other)) 293 | t = self.tuple[:3] 294 | o = other.tuple[:3] 295 | if t < o: 296 | return True 297 | if t == o: # We need to compare suffixes 298 | if self.suffix and not other.suffix: 299 | return True 300 | if other.suffix and not self.suffix: 301 | return False 302 | return self._parse_dotted_string(self.suffix) \ 303 | < self._parse_dotted_string(other.suffix) 304 | # t > o 305 | return False 306 | 307 | def __eq__(self, other): 308 | """Implement comparison.""" 309 | if not isinstance(other, Version): 310 | raise ValueError('not a Version instance: {0!r}'.format(other)) 311 | return self.tuple == other.tuple 312 | 313 | def __ne__(self, other): 314 | """Implement comparison.""" 315 | return not self.__eq__(other) 316 | 317 | def __gt__(self, other): 318 | """Implement comparison.""" 319 | if not isinstance(other, Version): 320 | raise ValueError('not a Version instance: {0!r}'.format(other)) 321 | return other.__lt__(self) 322 | 323 | def __le__(self, other): 324 | """Implement comparison.""" 325 | if not isinstance(other, Version): 326 | raise ValueError('not a Version instance: {0!r}'.format(other)) 327 | return not other.__lt__(self) 328 | 329 | def __ge__(self, other): 330 | """Implement comparison.""" 331 | return not self.__lt__(other) 332 | 333 | def __str__(self): 334 | """Return semantic version string.""" 335 | vstr = '{0}.{1}.{2}'.format(self.major, self.minor, self.patch) 336 | if self.suffix: 337 | vstr = '{0}-{1}'.format(vstr, self.suffix) 338 | if self.build: 339 | vstr = '{0}+{1}'.format(vstr, self.build) 340 | return vstr 341 | 342 | def __repr__(self): 343 | """Return 'code' representation of `Version`.""" 344 | return "Version('{0}')".format(str(self)) 345 | 346 | 347 | def retrieve_download(dl): 348 | """Saves a download to a temporary file and returns path. 349 | 350 | .. versionadded: 1.37 351 | 352 | Args: 353 | url (unicode): URL to .alfredworkflow file in GitHub repo 354 | 355 | Returns: 356 | unicode: path to downloaded file 357 | 358 | """ 359 | if not match_workflow(dl.filename): 360 | raise ValueError('attachment not a workflow: ' + dl.filename) 361 | 362 | path = os.path.join(tempfile.gettempdir(), dl.filename) 363 | wf().logger.debug('downloading update from ' 364 | '%r to %r ...', dl.url, path) 365 | 366 | r = web.get(dl.url) 367 | r.raise_for_status() 368 | 369 | r.save_to_path(path) 370 | 371 | return path 372 | 373 | 374 | def build_api_url(repo): 375 | """Generate releases URL from GitHub repo. 376 | 377 | Args: 378 | repo (unicode): Repo name in form ``username/repo`` 379 | 380 | Returns: 381 | unicode: URL to the API endpoint for the repo's releases 382 | 383 | """ 384 | if len(repo.split('/')) != 2: 385 | raise ValueError('invalid GitHub repo: {!r}'.format(repo)) 386 | 387 | return RELEASES_BASE.format(repo) 388 | 389 | 390 | def get_downloads(repo): 391 | """Load available ``Download``s for GitHub repo. 392 | 393 | .. versionadded: 1.37 394 | 395 | Args: 396 | repo (unicode): GitHub repo to load releases for. 397 | 398 | Returns: 399 | list: Sequence of `Download` contained in GitHub releases. 400 | """ 401 | url = build_api_url(repo) 402 | 403 | def _fetch(): 404 | wf().logger.info('retrieving releases for %r ...', repo) 405 | r = web.get(url) 406 | r.raise_for_status() 407 | return r.content 408 | 409 | key = 'github-releases-' + repo.replace('/', '-') 410 | js = wf().cached_data(key, _fetch, max_age=60) 411 | 412 | return Download.from_releases(js) 413 | 414 | 415 | def latest_download(dls, alfred_version=None, prereleases=False): 416 | """Return newest `Download`.""" 417 | alfred_version = alfred_version or os.getenv('alfred_version') 418 | version = None 419 | if alfred_version: 420 | version = Version(alfred_version) 421 | 422 | dls.sort(reverse=True) 423 | for dl in dls: 424 | if dl.prerelease and not prereleases: 425 | wf().logger.debug('ignored prerelease: %s', dl.version) 426 | continue 427 | if version and dl.alfred_version > version: 428 | wf().logger.debug('ignored incompatible (%s > %s): %s', 429 | dl.alfred_version, version, dl.filename) 430 | continue 431 | 432 | wf().logger.debug('latest version: %s (%s)', dl.version, dl.filename) 433 | return dl 434 | 435 | return None 436 | 437 | 438 | def check_update(repo, current_version, prereleases=False, 439 | alfred_version=None): 440 | """Check whether a newer release is available on GitHub. 441 | 442 | Args: 443 | repo (unicode): ``username/repo`` for workflow's GitHub repo 444 | current_version (unicode): the currently installed version of the 445 | workflow. :ref:`Semantic versioning ` is required. 446 | prereleases (bool): Whether to include pre-releases. 447 | alfred_version (unicode): version of currently-running Alfred. 448 | if empty, defaults to ``$alfred_version`` environment variable. 449 | 450 | Returns: 451 | bool: ``True`` if an update is available, else ``False`` 452 | 453 | If an update is available, its version number and download URL will 454 | be cached. 455 | 456 | """ 457 | key = '__workflow_latest_version' 458 | # data stored when no update is available 459 | no_update = { 460 | 'available': False, 461 | 'download': None, 462 | 'version': None, 463 | } 464 | current = Version(current_version) 465 | 466 | dls = get_downloads(repo) 467 | if not len(dls): 468 | wf().logger.warning('no valid downloads for %s', repo) 469 | wf().cache_data(key, no_update) 470 | return False 471 | 472 | wf().logger.info('%d download(s) for %s', len(dls), repo) 473 | 474 | dl = latest_download(dls, alfred_version, prereleases) 475 | 476 | if not dl: 477 | wf().logger.warning('no compatible downloads for %s', repo) 478 | wf().cache_data(key, no_update) 479 | return False 480 | 481 | wf().logger.debug('latest=%r, installed=%r', dl.version, current) 482 | 483 | if dl.version > current: 484 | wf().cache_data(key, { 485 | 'version': str(dl.version), 486 | 'download': dl.dict, 487 | 'available': True, 488 | }) 489 | return True 490 | 491 | wf().cache_data(key, no_update) 492 | return False 493 | 494 | 495 | def install_update(): 496 | """If a newer release is available, download and install it. 497 | 498 | :returns: ``True`` if an update is installed, else ``False`` 499 | 500 | """ 501 | key = '__workflow_latest_version' 502 | # data stored when no update is available 503 | no_update = { 504 | 'available': False, 505 | 'download': None, 506 | 'version': None, 507 | } 508 | status = wf().cached_data(key, max_age=0) 509 | 510 | if not status or not status.get('available'): 511 | wf().logger.info('no update available') 512 | return False 513 | 514 | dl = status.get('download') 515 | if not dl: 516 | wf().logger.info('no download information') 517 | return False 518 | 519 | path = retrieve_download(Download.from_dict(dl)) 520 | 521 | wf().logger.info('installing updated workflow ...') 522 | subprocess.call(['open', path]) # nosec 523 | 524 | wf().cache_data(key, no_update) 525 | return True 526 | 527 | 528 | if __name__ == '__main__': # pragma: nocover 529 | import sys 530 | 531 | prereleases = False 532 | 533 | def show_help(status=0): 534 | """Print help message.""" 535 | print('usage: update.py (check|install) ' 536 | '[--prereleases] ') 537 | sys.exit(status) 538 | 539 | argv = sys.argv[:] 540 | if '-h' in argv or '--help' in argv: 541 | show_help() 542 | 543 | if '--prereleases' in argv: 544 | argv.remove('--prereleases') 545 | prereleases = True 546 | 547 | if len(argv) != 4: 548 | show_help(1) 549 | 550 | action = argv[1] 551 | repo = argv[2] 552 | version = argv[3] 553 | 554 | try: 555 | 556 | if action == 'check': 557 | check_update(repo, version, prereleases) 558 | elif action == 'install': 559 | install_update() 560 | else: 561 | show_help(1) 562 | 563 | except Exception as err: # ensure traceback is in log file 564 | wf().logger.exception(err) 565 | raise err 566 | -------------------------------------------------------------------------------- /src/workflow/util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2017 Dean Jackson 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2017-12-17 9 | # 10 | 11 | """A selection of helper functions useful for building workflows.""" 12 | 13 | from __future__ import print_function, absolute_import 14 | 15 | import atexit 16 | from collections import namedtuple 17 | from contextlib import contextmanager 18 | import errno 19 | import fcntl 20 | import functools 21 | import json 22 | import os 23 | import signal 24 | import subprocess 25 | import sys 26 | from threading import Event 27 | import time 28 | 29 | # JXA scripts to call Alfred's API via the Scripting Bridge 30 | # {app} is automatically replaced with "Alfred 3" or 31 | # "com.runningwithcrayons.Alfred" depending on version. 32 | # 33 | # Open Alfred in search (regular) mode 34 | JXA_SEARCH = 'Application({app}).search({arg});' 35 | # Open Alfred's File Actions on an argument 36 | JXA_ACTION = 'Application({app}).action({arg});' 37 | # Open Alfred's navigation mode at path 38 | JXA_BROWSE = 'Application({app}).browse({arg});' 39 | # Set the specified theme 40 | JXA_SET_THEME = 'Application({app}).setTheme({arg});' 41 | # Call an External Trigger 42 | JXA_TRIGGER = 'Application({app}).runTrigger({arg}, {opts});' 43 | # Save a variable to the workflow configuration sheet/info.plist 44 | JXA_SET_CONFIG = 'Application({app}).setConfiguration({arg}, {opts});' 45 | # Delete a variable from the workflow configuration sheet/info.plist 46 | JXA_UNSET_CONFIG = 'Application({app}).removeConfiguration({arg}, {opts});' 47 | # Tell Alfred to reload a workflow from disk 48 | JXA_RELOAD_WORKFLOW = 'Application({app}).reloadWorkflow({arg});' 49 | 50 | 51 | class AcquisitionError(Exception): 52 | """Raised if a lock cannot be acquired.""" 53 | 54 | 55 | AppInfo = namedtuple('AppInfo', ['name', 'path', 'bundleid']) 56 | """Information about an installed application. 57 | 58 | Returned by :func:`appinfo`. All attributes are Unicode. 59 | 60 | .. py:attribute:: name 61 | 62 | Name of the application, e.g. ``u'Safari'``. 63 | 64 | .. py:attribute:: path 65 | 66 | Path to the application bundle, e.g. ``u'/Applications/Safari.app'``. 67 | 68 | .. py:attribute:: bundleid 69 | 70 | Application's bundle ID, e.g. ``u'com.apple.Safari'``. 71 | 72 | """ 73 | 74 | 75 | def jxa_app_name(): 76 | """Return name of application to call currently running Alfred. 77 | 78 | .. versionadded: 1.37 79 | 80 | Returns 'Alfred 3' or 'com.runningwithcrayons.Alfred' depending 81 | on which version of Alfred is running. 82 | 83 | This name is suitable for use with ``Application(name)`` in JXA. 84 | 85 | Returns: 86 | unicode: Application name or ID. 87 | 88 | """ 89 | if os.getenv('alfred_version', '').startswith('3'): 90 | # Alfred 3 91 | return u'Alfred 3' 92 | # Alfred 4+ 93 | return u'com.runningwithcrayons.Alfred' 94 | 95 | 96 | def unicodify(s, encoding='utf-8', norm=None): 97 | """Ensure string is Unicode. 98 | 99 | .. versionadded:: 1.31 100 | 101 | Decode encoded strings using ``encoding`` and normalise Unicode 102 | to form ``norm`` if specified. 103 | 104 | Args: 105 | s (str): String to decode. May also be Unicode. 106 | encoding (str, optional): Encoding to use on bytestrings. 107 | norm (None, optional): Normalisation form to apply to Unicode string. 108 | 109 | Returns: 110 | unicode: Decoded, optionally normalised, Unicode string. 111 | 112 | """ 113 | if not isinstance(s, unicode): 114 | s = unicode(s, encoding) 115 | 116 | if norm: 117 | from unicodedata import normalize 118 | s = normalize(norm, s) 119 | 120 | return s 121 | 122 | 123 | def utf8ify(s): 124 | """Ensure string is a bytestring. 125 | 126 | .. versionadded:: 1.31 127 | 128 | Returns `str` objects unchanced, encodes `unicode` objects to 129 | UTF-8, and calls :func:`str` on anything else. 130 | 131 | Args: 132 | s (object): A Python object 133 | 134 | Returns: 135 | str: UTF-8 string or string representation of s. 136 | 137 | """ 138 | if isinstance(s, str): 139 | return s 140 | 141 | if isinstance(s, unicode): 142 | return s.encode('utf-8') 143 | 144 | return str(s) 145 | 146 | 147 | def applescriptify(s): 148 | """Escape string for insertion into an AppleScript string. 149 | 150 | .. versionadded:: 1.31 151 | 152 | Replaces ``"`` with `"& quote &"`. Use this function if you want 153 | to insert a string into an AppleScript script: 154 | 155 | >>> applescriptify('g "python" test') 156 | 'g " & quote & "python" & quote & "test' 157 | 158 | Args: 159 | s (unicode): Unicode string to escape. 160 | 161 | Returns: 162 | unicode: Escaped string. 163 | 164 | """ 165 | return s.replace(u'"', u'" & quote & "') 166 | 167 | 168 | def run_command(cmd, **kwargs): 169 | """Run a command and return the output. 170 | 171 | .. versionadded:: 1.31 172 | 173 | A thin wrapper around :func:`subprocess.check_output` that ensures 174 | all arguments are encoded to UTF-8 first. 175 | 176 | Args: 177 | cmd (list): Command arguments to pass to :func:`~subprocess.check_output`. 178 | **kwargs: Keyword arguments to pass to :func:`~subprocess.check_output`. 179 | 180 | Returns: 181 | str: Output returned by :func:`~subprocess.check_output`. 182 | 183 | """ 184 | cmd = [utf8ify(s) for s in cmd] 185 | return subprocess.check_output(cmd, **kwargs) 186 | 187 | 188 | def run_applescript(script, *args, **kwargs): 189 | """Execute an AppleScript script and return its output. 190 | 191 | .. versionadded:: 1.31 192 | 193 | Run AppleScript either by filepath or code. If ``script`` is a valid 194 | filepath, that script will be run, otherwise ``script`` is treated 195 | as code. 196 | 197 | Args: 198 | script (str, optional): Filepath of script or code to run. 199 | *args: Optional command-line arguments to pass to the script. 200 | **kwargs: Pass ``lang`` to run a language other than AppleScript. 201 | Any other keyword arguments are passed to :func:`run_command`. 202 | 203 | Returns: 204 | str: Output of run command. 205 | 206 | """ 207 | lang = 'AppleScript' 208 | if 'lang' in kwargs: 209 | lang = kwargs['lang'] 210 | del kwargs['lang'] 211 | 212 | cmd = ['/usr/bin/osascript', '-l', lang] 213 | 214 | if os.path.exists(script): 215 | cmd += [script] 216 | else: 217 | cmd += ['-e', script] 218 | 219 | cmd.extend(args) 220 | 221 | return run_command(cmd, **kwargs) 222 | 223 | 224 | def run_jxa(script, *args): 225 | """Execute a JXA script and return its output. 226 | 227 | .. versionadded:: 1.31 228 | 229 | Wrapper around :func:`run_applescript` that passes ``lang=JavaScript``. 230 | 231 | Args: 232 | script (str): Filepath of script or code to run. 233 | *args: Optional command-line arguments to pass to script. 234 | 235 | Returns: 236 | str: Output of script. 237 | 238 | """ 239 | return run_applescript(script, *args, lang='JavaScript') 240 | 241 | 242 | def run_trigger(name, bundleid=None, arg=None): 243 | """Call an Alfred External Trigger. 244 | 245 | .. versionadded:: 1.31 246 | 247 | If ``bundleid`` is not specified, the bundle ID of the calling 248 | workflow is used. 249 | 250 | Args: 251 | name (str): Name of External Trigger to call. 252 | bundleid (str, optional): Bundle ID of workflow trigger belongs to. 253 | arg (str, optional): Argument to pass to trigger. 254 | 255 | """ 256 | bundleid = bundleid or os.getenv('alfred_workflow_bundleid') 257 | appname = jxa_app_name() 258 | opts = {'inWorkflow': bundleid} 259 | if arg: 260 | opts['withArgument'] = arg 261 | 262 | script = JXA_TRIGGER.format(app=json.dumps(appname), 263 | arg=json.dumps(name), 264 | opts=json.dumps(opts, sort_keys=True)) 265 | 266 | run_applescript(script, lang='JavaScript') 267 | 268 | 269 | def set_theme(theme_name): 270 | """Change Alfred's theme. 271 | 272 | .. versionadded:: 1.39.0 273 | 274 | Args: 275 | theme_name (unicode): Name of theme Alfred should use. 276 | 277 | """ 278 | appname = jxa_app_name() 279 | script = JXA_SET_THEME.format(app=json.dumps(appname), 280 | arg=json.dumps(theme_name)) 281 | run_applescript(script, lang='JavaScript') 282 | 283 | 284 | def set_config(name, value, bundleid=None, exportable=False): 285 | """Set a workflow variable in ``info.plist``. 286 | 287 | .. versionadded:: 1.33 288 | 289 | If ``bundleid`` is not specified, the bundle ID of the calling 290 | workflow is used. 291 | 292 | Args: 293 | name (str): Name of variable to set. 294 | value (str): Value to set variable to. 295 | bundleid (str, optional): Bundle ID of workflow variable belongs to. 296 | exportable (bool, optional): Whether variable should be marked 297 | as exportable (Don't Export checkbox). 298 | 299 | """ 300 | bundleid = bundleid or os.getenv('alfred_workflow_bundleid') 301 | appname = jxa_app_name() 302 | opts = { 303 | 'toValue': value, 304 | 'inWorkflow': bundleid, 305 | 'exportable': exportable, 306 | } 307 | 308 | script = JXA_SET_CONFIG.format(app=json.dumps(appname), 309 | arg=json.dumps(name), 310 | opts=json.dumps(opts, sort_keys=True)) 311 | 312 | run_applescript(script, lang='JavaScript') 313 | 314 | 315 | def unset_config(name, bundleid=None): 316 | """Delete a workflow variable from ``info.plist``. 317 | 318 | .. versionadded:: 1.33 319 | 320 | If ``bundleid`` is not specified, the bundle ID of the calling 321 | workflow is used. 322 | 323 | Args: 324 | name (str): Name of variable to delete. 325 | bundleid (str, optional): Bundle ID of workflow variable belongs to. 326 | 327 | """ 328 | bundleid = bundleid or os.getenv('alfred_workflow_bundleid') 329 | appname = jxa_app_name() 330 | opts = {'inWorkflow': bundleid} 331 | 332 | script = JXA_UNSET_CONFIG.format(app=json.dumps(appname), 333 | arg=json.dumps(name), 334 | opts=json.dumps(opts, sort_keys=True)) 335 | 336 | run_applescript(script, lang='JavaScript') 337 | 338 | 339 | def search_in_alfred(query=None): 340 | """Open Alfred with given search query. 341 | 342 | .. versionadded:: 1.39.0 343 | 344 | Omit ``query`` to simply open Alfred's main window. 345 | 346 | Args: 347 | query (unicode, optional): Search query. 348 | 349 | """ 350 | query = query or u'' 351 | appname = jxa_app_name() 352 | script = JXA_SEARCH.format(app=json.dumps(appname), arg=json.dumps(query)) 353 | run_applescript(script, lang='JavaScript') 354 | 355 | 356 | def browse_in_alfred(path): 357 | """Open Alfred's filesystem navigation mode at ``path``. 358 | 359 | .. versionadded:: 1.39.0 360 | 361 | Args: 362 | path (unicode): File or directory path. 363 | 364 | """ 365 | appname = jxa_app_name() 366 | script = JXA_BROWSE.format(app=json.dumps(appname), arg=json.dumps(path)) 367 | run_applescript(script, lang='JavaScript') 368 | 369 | 370 | def action_in_alfred(paths): 371 | """Action the give filepaths in Alfred. 372 | 373 | .. versionadded:: 1.39.0 374 | 375 | Args: 376 | paths (list): Unicode paths to files/directories to action. 377 | 378 | """ 379 | appname = jxa_app_name() 380 | script = JXA_ACTION.format(app=json.dumps(appname), arg=json.dumps(paths)) 381 | run_applescript(script, lang='JavaScript') 382 | 383 | 384 | def reload_workflow(bundleid=None): 385 | """Tell Alfred to reload a workflow from disk. 386 | 387 | .. versionadded:: 1.39.0 388 | 389 | If ``bundleid`` is not specified, the bundle ID of the calling 390 | workflow is used. 391 | 392 | Args: 393 | bundleid (unicode, optional): Bundle ID of workflow to reload. 394 | 395 | """ 396 | bundleid = bundleid or os.getenv('alfred_workflow_bundleid') 397 | appname = jxa_app_name() 398 | script = JXA_RELOAD_WORKFLOW.format(app=json.dumps(appname), 399 | arg=json.dumps(bundleid)) 400 | 401 | run_applescript(script, lang='JavaScript') 402 | 403 | 404 | def appinfo(name): 405 | """Get information about an installed application. 406 | 407 | .. versionadded:: 1.31 408 | 409 | Args: 410 | name (str): Name of application to look up. 411 | 412 | Returns: 413 | AppInfo: :class:`AppInfo` tuple or ``None`` if app isn't found. 414 | 415 | """ 416 | cmd = [ 417 | 'mdfind', 418 | '-onlyin', '/Applications', 419 | '-onlyin', '/System/Applications', 420 | '-onlyin', os.path.expanduser('~/Applications'), 421 | '(kMDItemContentTypeTree == com.apple.application &&' 422 | '(kMDItemDisplayName == "{0}" || kMDItemFSName == "{0}.app"))' 423 | .format(name) 424 | ] 425 | 426 | output = run_command(cmd).strip() 427 | if not output: 428 | return None 429 | 430 | path = output.split('\n')[0] 431 | 432 | cmd = ['mdls', '-raw', '-name', 'kMDItemCFBundleIdentifier', path] 433 | bid = run_command(cmd).strip() 434 | if not bid: # pragma: no cover 435 | return None 436 | 437 | return AppInfo(unicodify(name), unicodify(path), unicodify(bid)) 438 | 439 | 440 | @contextmanager 441 | def atomic_writer(fpath, mode): 442 | """Atomic file writer. 443 | 444 | .. versionadded:: 1.12 445 | 446 | Context manager that ensures the file is only written if the write 447 | succeeds. The data is first written to a temporary file. 448 | 449 | :param fpath: path of file to write to. 450 | :type fpath: ``unicode`` 451 | :param mode: sames as for :func:`open` 452 | :type mode: string 453 | 454 | """ 455 | suffix = '.{}.tmp'.format(os.getpid()) 456 | temppath = fpath + suffix 457 | with open(temppath, mode) as fp: 458 | try: 459 | yield fp 460 | os.rename(temppath, fpath) 461 | finally: 462 | try: 463 | os.remove(temppath) 464 | except (OSError, IOError): 465 | pass 466 | 467 | 468 | class LockFile(object): 469 | """Context manager to protect filepaths with lockfiles. 470 | 471 | .. versionadded:: 1.13 472 | 473 | Creates a lockfile alongside ``protected_path``. Other ``LockFile`` 474 | instances will refuse to lock the same path. 475 | 476 | >>> path = '/path/to/file' 477 | >>> with LockFile(path): 478 | >>> with open(path, 'wb') as fp: 479 | >>> fp.write(data) 480 | 481 | Args: 482 | protected_path (unicode): File to protect with a lockfile 483 | timeout (float, optional): Raises an :class:`AcquisitionError` 484 | if lock cannot be acquired within this number of seconds. 485 | If ``timeout`` is 0 (the default), wait forever. 486 | delay (float, optional): How often to check (in seconds) if 487 | lock has been released. 488 | 489 | Attributes: 490 | delay (float): How often to check (in seconds) whether the lock 491 | can be acquired. 492 | lockfile (unicode): Path of the lockfile. 493 | timeout (float): How long to wait to acquire the lock. 494 | 495 | """ 496 | 497 | def __init__(self, protected_path, timeout=0.0, delay=0.05): 498 | """Create new :class:`LockFile` object.""" 499 | self.lockfile = protected_path + '.lock' 500 | self._lockfile = None 501 | self.timeout = timeout 502 | self.delay = delay 503 | self._lock = Event() 504 | atexit.register(self.release) 505 | 506 | @property 507 | def locked(self): 508 | """``True`` if file is locked by this instance.""" 509 | return self._lock.is_set() 510 | 511 | def acquire(self, blocking=True): 512 | """Acquire the lock if possible. 513 | 514 | If the lock is in use and ``blocking`` is ``False``, return 515 | ``False``. 516 | 517 | Otherwise, check every :attr:`delay` seconds until it acquires 518 | lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`. 519 | 520 | """ 521 | if self.locked and not blocking: 522 | return False 523 | 524 | start = time.time() 525 | while True: 526 | # Raise error if we've been waiting too long to acquire the lock 527 | if self.timeout and (time.time() - start) >= self.timeout: 528 | raise AcquisitionError('lock acquisition timed out') 529 | 530 | # If already locked, wait then try again 531 | if self.locked: 532 | time.sleep(self.delay) 533 | continue 534 | 535 | # Create in append mode so we don't lose any contents 536 | if self._lockfile is None: 537 | self._lockfile = open(self.lockfile, 'a') 538 | 539 | # Try to acquire the lock 540 | try: 541 | fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB) 542 | self._lock.set() 543 | break 544 | except IOError as err: # pragma: no cover 545 | if err.errno not in (errno.EACCES, errno.EAGAIN): 546 | raise 547 | 548 | # Don't try again 549 | if not blocking: # pragma: no cover 550 | return False 551 | 552 | # Wait, then try again 553 | time.sleep(self.delay) 554 | 555 | return True 556 | 557 | def release(self): 558 | """Release the lock by deleting `self.lockfile`.""" 559 | if not self._lock.is_set(): 560 | return False 561 | 562 | try: 563 | fcntl.lockf(self._lockfile, fcntl.LOCK_UN) 564 | except IOError: # pragma: no cover 565 | pass 566 | finally: 567 | self._lock.clear() 568 | self._lockfile = None 569 | try: 570 | os.unlink(self.lockfile) 571 | except (IOError, OSError): # pragma: no cover 572 | pass 573 | 574 | return True 575 | 576 | def __enter__(self): 577 | """Acquire lock.""" 578 | self.acquire() 579 | return self 580 | 581 | def __exit__(self, typ, value, traceback): 582 | """Release lock.""" 583 | self.release() 584 | 585 | def __del__(self): 586 | """Clear up `self.lockfile`.""" 587 | self.release() # pragma: no cover 588 | 589 | 590 | class uninterruptible(object): 591 | """Decorator that postpones SIGTERM until wrapped function returns. 592 | 593 | .. versionadded:: 1.12 594 | 595 | .. important:: This decorator is NOT thread-safe. 596 | 597 | As of version 2.7, Alfred allows Script Filters to be killed. If 598 | your workflow is killed in the middle of critical code (e.g. 599 | writing data to disk), this may corrupt your workflow's data. 600 | 601 | Use this decorator to wrap critical functions that *must* complete. 602 | If the script is killed while a wrapped function is executing, 603 | the SIGTERM will be caught and handled after your function has 604 | finished executing. 605 | 606 | Alfred-Workflow uses this internally to ensure its settings, data 607 | and cache writes complete. 608 | 609 | """ 610 | 611 | def __init__(self, func, class_name=''): 612 | """Decorate `func`.""" 613 | self.func = func 614 | functools.update_wrapper(self, func) 615 | self._caught_signal = None 616 | 617 | def signal_handler(self, signum, frame): 618 | """Called when process receives SIGTERM.""" 619 | self._caught_signal = (signum, frame) 620 | 621 | def __call__(self, *args, **kwargs): 622 | """Trap ``SIGTERM`` and call wrapped function.""" 623 | self._caught_signal = None 624 | # Register handler for SIGTERM, then call `self.func` 625 | self.old_signal_handler = signal.getsignal(signal.SIGTERM) 626 | signal.signal(signal.SIGTERM, self.signal_handler) 627 | 628 | self.func(*args, **kwargs) 629 | 630 | # Restore old signal handler 631 | signal.signal(signal.SIGTERM, self.old_signal_handler) 632 | 633 | # Handle any signal caught during execution 634 | if self._caught_signal is not None: 635 | signum, frame = self._caught_signal 636 | if callable(self.old_signal_handler): 637 | self.old_signal_handler(signum, frame) 638 | elif self.old_signal_handler == signal.SIG_DFL: 639 | sys.exit(0) 640 | 641 | def __get__(self, obj=None, klass=None): 642 | """Decorator API.""" 643 | return self.__class__(self.func.__get__(obj, klass), 644 | klass.__name__) 645 | -------------------------------------------------------------------------------- /src/docopt.py: -------------------------------------------------------------------------------- 1 | """Pythonic command-line interface parser that will make you smile. 2 | 3 | * http://docopt.org 4 | * Repository and issue-tracker: https://github.com/docopt/docopt 5 | * Licensed under terms of MIT license (see LICENSE-MIT) 6 | * Copyright (c) 2013 Vladimir Keleshev, vladimir@keleshev.com 7 | 8 | """ 9 | import sys 10 | import re 11 | 12 | 13 | __all__ = ['docopt'] 14 | __version__ = '0.6.1' 15 | 16 | 17 | class DocoptLanguageError(Exception): 18 | 19 | """Error in construction of usage-message by developer.""" 20 | 21 | 22 | class DocoptExit(SystemExit): 23 | 24 | """Exit in case user invoked program with incorrect arguments.""" 25 | 26 | usage = '' 27 | 28 | def __init__(self, message=''): 29 | SystemExit.__init__(self, (message + '\n' + self.usage).strip()) 30 | 31 | 32 | class Pattern(object): 33 | 34 | def __eq__(self, other): 35 | return repr(self) == repr(other) 36 | 37 | def __hash__(self): 38 | return hash(repr(self)) 39 | 40 | def fix(self): 41 | self.fix_identities() 42 | self.fix_repeating_arguments() 43 | return self 44 | 45 | def fix_identities(self, uniq=None): 46 | """Make pattern-tree tips point to same object if they are equal.""" 47 | if not hasattr(self, 'children'): 48 | return self 49 | uniq = list(set(self.flat())) if uniq is None else uniq 50 | for i, child in enumerate(self.children): 51 | if not hasattr(child, 'children'): 52 | assert child in uniq 53 | self.children[i] = uniq[uniq.index(child)] 54 | else: 55 | child.fix_identities(uniq) 56 | 57 | def fix_repeating_arguments(self): 58 | """Fix elements that should accumulate/increment values.""" 59 | either = [list(child.children) for child in transform(self).children] 60 | for case in either: 61 | for e in [child for child in case if case.count(child) > 1]: 62 | if type(e) is Argument or type(e) is Option and e.argcount: 63 | if e.value is None: 64 | e.value = [] 65 | elif type(e.value) is not list: 66 | e.value = e.value.split() 67 | if type(e) is Command or type(e) is Option and e.argcount == 0: 68 | e.value = 0 69 | return self 70 | 71 | 72 | def transform(pattern): 73 | """Expand pattern into an (almost) equivalent one, but with single Either. 74 | 75 | Example: ((-a | -b) (-c | -d)) => (-a -c | -a -d | -b -c | -b -d) 76 | Quirks: [-a] => (-a), (-a...) => (-a -a) 77 | 78 | """ 79 | result = [] 80 | groups = [[pattern]] 81 | while groups: 82 | children = groups.pop(0) 83 | parents = [Required, Optional, OptionsShortcut, Either, OneOrMore] 84 | if any(t in map(type, children) for t in parents): 85 | child = [c for c in children if type(c) in parents][0] 86 | children.remove(child) 87 | if type(child) is Either: 88 | for c in child.children: 89 | groups.append([c] + children) 90 | elif type(child) is OneOrMore: 91 | groups.append(child.children * 2 + children) 92 | else: 93 | groups.append(child.children + children) 94 | else: 95 | result.append(children) 96 | return Either(*[Required(*e) for e in result]) 97 | 98 | 99 | class LeafPattern(Pattern): 100 | 101 | """Leaf/terminal node of a pattern tree.""" 102 | 103 | def __init__(self, name, value=None): 104 | self.name, self.value = name, value 105 | 106 | def __repr__(self): 107 | return '%s(%r, %r)' % (self.__class__.__name__, self.name, self.value) 108 | 109 | def flat(self, *types): 110 | return [self] if not types or type(self) in types else [] 111 | 112 | def match(self, left, collected=None): 113 | collected = [] if collected is None else collected 114 | pos, match = self.single_match(left) 115 | if match is None: 116 | return False, left, collected 117 | left_ = left[:pos] + left[pos + 1:] 118 | same_name = [a for a in collected if a.name == self.name] 119 | if type(self.value) in (int, list): 120 | if type(self.value) is int: 121 | increment = 1 122 | else: 123 | increment = ([match.value] if type(match.value) is str 124 | else match.value) 125 | if not same_name: 126 | match.value = increment 127 | return True, left_, collected + [match] 128 | same_name[0].value += increment 129 | return True, left_, collected 130 | return True, left_, collected + [match] 131 | 132 | 133 | class BranchPattern(Pattern): 134 | 135 | """Branch/inner node of a pattern tree.""" 136 | 137 | def __init__(self, *children): 138 | self.children = list(children) 139 | 140 | def __repr__(self): 141 | return '%s(%s)' % (self.__class__.__name__, 142 | ', '.join(repr(a) for a in self.children)) 143 | 144 | def flat(self, *types): 145 | if type(self) in types: 146 | return [self] 147 | return sum([child.flat(*types) for child in self.children], []) 148 | 149 | 150 | class Argument(LeafPattern): 151 | 152 | def single_match(self, left): 153 | for n, pattern in enumerate(left): 154 | if type(pattern) is Argument: 155 | return n, Argument(self.name, pattern.value) 156 | return None, None 157 | 158 | @classmethod 159 | def parse(class_, source): 160 | name = re.findall('(<\S*?>)', source)[0] 161 | value = re.findall('\[default: (.*)\]', source, flags=re.I) 162 | return class_(name, value[0] if value else None) 163 | 164 | 165 | class Command(Argument): 166 | 167 | def __init__(self, name, value=False): 168 | self.name, self.value = name, value 169 | 170 | def single_match(self, left): 171 | for n, pattern in enumerate(left): 172 | if type(pattern) is Argument: 173 | if pattern.value == self.name: 174 | return n, Command(self.name, True) 175 | else: 176 | break 177 | return None, None 178 | 179 | 180 | class Option(LeafPattern): 181 | 182 | def __init__(self, short=None, long=None, argcount=0, value=False): 183 | assert argcount in (0, 1) 184 | self.short, self.long, self.argcount = short, long, argcount 185 | self.value = None if value is False and argcount else value 186 | 187 | @classmethod 188 | def parse(class_, option_description): 189 | short, long, argcount, value = None, None, 0, False 190 | options, _, description = option_description.strip().partition(' ') 191 | options = options.replace(',', ' ').replace('=', ' ') 192 | for s in options.split(): 193 | if s.startswith('--'): 194 | long = s 195 | elif s.startswith('-'): 196 | short = s 197 | else: 198 | argcount = 1 199 | if argcount: 200 | matched = re.findall('\[default: (.*)\]', description, flags=re.I) 201 | value = matched[0] if matched else None 202 | return class_(short, long, argcount, value) 203 | 204 | def single_match(self, left): 205 | for n, pattern in enumerate(left): 206 | if self.name == pattern.name: 207 | return n, pattern 208 | return None, None 209 | 210 | @property 211 | def name(self): 212 | return self.long or self.short 213 | 214 | def __repr__(self): 215 | return 'Option(%r, %r, %r, %r)' % (self.short, self.long, 216 | self.argcount, self.value) 217 | 218 | 219 | class Required(BranchPattern): 220 | 221 | def match(self, left, collected=None): 222 | collected = [] if collected is None else collected 223 | l = left 224 | c = collected 225 | for pattern in self.children: 226 | matched, l, c = pattern.match(l, c) 227 | if not matched: 228 | return False, left, collected 229 | return True, l, c 230 | 231 | 232 | class Optional(BranchPattern): 233 | 234 | def match(self, left, collected=None): 235 | collected = [] if collected is None else collected 236 | for pattern in self.children: 237 | m, left, collected = pattern.match(left, collected) 238 | return True, left, collected 239 | 240 | 241 | class OptionsShortcut(Optional): 242 | 243 | """Marker/placeholder for [options] shortcut.""" 244 | 245 | 246 | class OneOrMore(BranchPattern): 247 | 248 | def match(self, left, collected=None): 249 | assert len(self.children) == 1 250 | collected = [] if collected is None else collected 251 | l = left 252 | c = collected 253 | l_ = None 254 | matched = True 255 | times = 0 256 | while matched: 257 | # could it be that something didn't match but changed l or c? 258 | matched, l, c = self.children[0].match(l, c) 259 | times += 1 if matched else 0 260 | if l_ == l: 261 | break 262 | l_ = l 263 | if times >= 1: 264 | return True, l, c 265 | return False, left, collected 266 | 267 | 268 | class Either(BranchPattern): 269 | 270 | def match(self, left, collected=None): 271 | collected = [] if collected is None else collected 272 | outcomes = [] 273 | for pattern in self.children: 274 | matched, _, _ = outcome = pattern.match(left, collected) 275 | if matched: 276 | outcomes.append(outcome) 277 | if outcomes: 278 | return min(outcomes, key=lambda outcome: len(outcome[1])) 279 | return False, left, collected 280 | 281 | 282 | class Tokens(list): 283 | 284 | def __init__(self, source, error=DocoptExit): 285 | self += source.split() if hasattr(source, 'split') else source 286 | self.error = error 287 | 288 | @staticmethod 289 | def from_pattern(source): 290 | source = re.sub(r'([\[\]\(\)\|]|\.\.\.)', r' \1 ', source) 291 | source = [s for s in re.split('\s+|(\S*<.*?>)', source) if s] 292 | return Tokens(source, error=DocoptLanguageError) 293 | 294 | def move(self): 295 | return self.pop(0) if len(self) else None 296 | 297 | def current(self): 298 | return self[0] if len(self) else None 299 | 300 | 301 | def parse_long(tokens, options): 302 | """long ::= '--' chars [ ( ' ' | '=' ) chars ] ;""" 303 | long, eq, value = tokens.move().partition('=') 304 | assert long.startswith('--') 305 | value = None if eq == value == '' else value 306 | similar = [o for o in options if o.long == long] 307 | if tokens.error is DocoptExit and similar == []: # if no exact match 308 | similar = [o for o in options if o.long and o.long.startswith(long)] 309 | if len(similar) > 1: # might be simply specified ambiguously 2+ times? 310 | raise tokens.error('%s is not a unique prefix: %s?' % 311 | (long, ', '.join(o.long for o in similar))) 312 | elif len(similar) < 1: 313 | argcount = 1 if eq == '=' else 0 314 | o = Option(None, long, argcount) 315 | options.append(o) 316 | if tokens.error is DocoptExit: 317 | o = Option(None, long, argcount, value if argcount else True) 318 | else: 319 | o = Option(similar[0].short, similar[0].long, 320 | similar[0].argcount, similar[0].value) 321 | if o.argcount == 0: 322 | if value is not None: 323 | raise tokens.error('%s must not have an argument' % o.long) 324 | else: 325 | if value is None: 326 | if tokens.current() in [None, '--']: 327 | raise tokens.error('%s requires argument' % o.long) 328 | value = tokens.move() 329 | if tokens.error is DocoptExit: 330 | o.value = value if value is not None else True 331 | return [o] 332 | 333 | 334 | def parse_shorts(tokens, options): 335 | """shorts ::= '-' ( chars )* [ [ ' ' ] chars ] ;""" 336 | token = tokens.move() 337 | assert token.startswith('-') and not token.startswith('--') 338 | left = token.lstrip('-') 339 | parsed = [] 340 | while left != '': 341 | short, left = '-' + left[0], left[1:] 342 | similar = [o for o in options if o.short == short] 343 | if len(similar) > 1: 344 | raise tokens.error('%s is specified ambiguously %d times' % 345 | (short, len(similar))) 346 | elif len(similar) < 1: 347 | o = Option(short, None, 0) 348 | options.append(o) 349 | if tokens.error is DocoptExit: 350 | o = Option(short, None, 0, True) 351 | else: # why copying is necessary here? 352 | o = Option(short, similar[0].long, 353 | similar[0].argcount, similar[0].value) 354 | value = None 355 | if o.argcount != 0: 356 | if left == '': 357 | if tokens.current() in [None, '--']: 358 | raise tokens.error('%s requires argument' % short) 359 | value = tokens.move() 360 | else: 361 | value = left 362 | left = '' 363 | if tokens.error is DocoptExit: 364 | o.value = value if value is not None else True 365 | parsed.append(o) 366 | return parsed 367 | 368 | 369 | def parse_pattern(source, options): 370 | tokens = Tokens.from_pattern(source) 371 | result = parse_expr(tokens, options) 372 | if tokens.current() is not None: 373 | raise tokens.error('unexpected ending: %r' % ' '.join(tokens)) 374 | return Required(*result) 375 | 376 | 377 | def parse_expr(tokens, options): 378 | """expr ::= seq ( '|' seq )* ;""" 379 | seq = parse_seq(tokens, options) 380 | if tokens.current() != '|': 381 | return seq 382 | result = [Required(*seq)] if len(seq) > 1 else seq 383 | while tokens.current() == '|': 384 | tokens.move() 385 | seq = parse_seq(tokens, options) 386 | result += [Required(*seq)] if len(seq) > 1 else seq 387 | return [Either(*result)] if len(result) > 1 else result 388 | 389 | 390 | def parse_seq(tokens, options): 391 | """seq ::= ( atom [ '...' ] )* ;""" 392 | result = [] 393 | while tokens.current() not in [None, ']', ')', '|']: 394 | atom = parse_atom(tokens, options) 395 | if tokens.current() == '...': 396 | atom = [OneOrMore(*atom)] 397 | tokens.move() 398 | result += atom 399 | return result 400 | 401 | 402 | def parse_atom(tokens, options): 403 | """atom ::= '(' expr ')' | '[' expr ']' | 'options' 404 | | long | shorts | argument | command ; 405 | """ 406 | token = tokens.current() 407 | result = [] 408 | if token in '([': 409 | tokens.move() 410 | matching, pattern = {'(': [')', Required], '[': [']', Optional]}[token] 411 | result = pattern(*parse_expr(tokens, options)) 412 | if tokens.move() != matching: 413 | raise tokens.error("unmatched '%s'" % token) 414 | return [result] 415 | elif token == 'options': 416 | tokens.move() 417 | return [OptionsShortcut()] 418 | elif token.startswith('--') and token != '--': 419 | return parse_long(tokens, options) 420 | elif token.startswith('-') and token not in ('-', '--'): 421 | return parse_shorts(tokens, options) 422 | elif token.startswith('<') and token.endswith('>') or token.isupper(): 423 | return [Argument(tokens.move())] 424 | else: 425 | return [Command(tokens.move())] 426 | 427 | 428 | def parse_argv(tokens, options, options_first=False): 429 | """Parse command-line argument vector. 430 | 431 | If options_first: 432 | argv ::= [ long | shorts ]* [ argument ]* [ '--' [ argument ]* ] ; 433 | else: 434 | argv ::= [ long | shorts | argument ]* [ '--' [ argument ]* ] ; 435 | 436 | """ 437 | parsed = [] 438 | while tokens.current() is not None: 439 | if tokens.current() == '--': 440 | return parsed + [Argument(None, v) for v in tokens] 441 | elif tokens.current().startswith('--'): 442 | parsed += parse_long(tokens, options) 443 | elif tokens.current().startswith('-') and tokens.current() != '-': 444 | parsed += parse_shorts(tokens, options) 445 | elif options_first: 446 | return parsed + [Argument(None, v) for v in tokens] 447 | else: 448 | parsed.append(Argument(None, tokens.move())) 449 | return parsed 450 | 451 | 452 | def parse_defaults(doc): 453 | defaults = [] 454 | for s in parse_section('options:', doc): 455 | # FIXME corner case "bla: options: --foo" 456 | _, _, s = s.partition(':') # get rid of "options:" 457 | split = re.split('\n[ \t]*(-\S+?)', '\n' + s)[1:] 458 | split = [s1 + s2 for s1, s2 in zip(split[::2], split[1::2])] 459 | options = [Option.parse(s) for s in split if s.startswith('-')] 460 | defaults += options 461 | return defaults 462 | 463 | 464 | def parse_section(name, source): 465 | pattern = re.compile('^([^\n]*' + name + '[^\n]*\n?(?:[ \t].*?(?:\n|$))*)', 466 | re.IGNORECASE | re.MULTILINE) 467 | return [s.strip() for s in pattern.findall(source)] 468 | 469 | 470 | def formal_usage(section): 471 | _, _, section = section.partition(':') # drop "usage:" 472 | pu = section.split() 473 | return '( ' + ' '.join(') | (' if s == pu[0] else s for s in pu[1:]) + ' )' 474 | 475 | 476 | def extras(help, version, options, doc): 477 | if help and any((o.name in ('-h', '--help')) and o.value for o in options): 478 | print(doc.strip("\n")) 479 | sys.exit() 480 | if version and any(o.name == '--version' and o.value for o in options): 481 | print(version) 482 | sys.exit() 483 | 484 | 485 | class Dict(dict): 486 | def __repr__(self): 487 | return '{%s}' % ',\n '.join('%r: %r' % i for i in sorted(self.items())) 488 | 489 | 490 | def docopt(doc, argv=None, help=True, version=None, options_first=False): 491 | """Parse `argv` based on command-line interface described in `doc`. 492 | 493 | `docopt` creates your command-line interface based on its 494 | description that you pass as `doc`. Such description can contain 495 | --options, , commands, which could be 496 | [optional], (required), (mutually | exclusive) or repeated... 497 | 498 | Parameters 499 | ---------- 500 | doc : str 501 | Description of your command-line interface. 502 | argv : list of str, optional 503 | Argument vector to be parsed. sys.argv[1:] is used if not 504 | provided. 505 | help : bool (default: True) 506 | Set to False to disable automatic help on -h or --help 507 | options. 508 | version : any object 509 | If passed, the object will be printed if --version is in 510 | `argv`. 511 | options_first : bool (default: False) 512 | Set to True to require options precede positional arguments, 513 | i.e. to forbid options and positional arguments intermix. 514 | 515 | Returns 516 | ------- 517 | args : dict 518 | A dictionary, where keys are names of command-line elements 519 | such as e.g. "--verbose" and "", and values are the 520 | parsed values of those elements. 521 | 522 | Example 523 | ------- 524 | >>> from docopt import docopt 525 | >>> doc = ''' 526 | ... Usage: 527 | ... my_program tcp [--timeout=] 528 | ... my_program serial [--baud=] [--timeout=] 529 | ... my_program (-h | --help | --version) 530 | ... 531 | ... Options: 532 | ... -h, --help Show this screen and exit. 533 | ... --baud= Baudrate [default: 9600] 534 | ... ''' 535 | >>> argv = ['tcp', '127.0.0.1', '80', '--timeout', '30'] 536 | >>> docopt(doc, argv) 537 | {'--baud': '9600', 538 | '--help': False, 539 | '--timeout': '30', 540 | '--version': False, 541 | '': '127.0.0.1', 542 | '': '80', 543 | 'serial': False, 544 | 'tcp': True} 545 | 546 | See also 547 | -------- 548 | * For video introduction see http://docopt.org 549 | * Full documentation is available in README.rst as well as online 550 | at https://github.com/docopt/docopt#readme 551 | 552 | """ 553 | argv = sys.argv[1:] if argv is None else argv 554 | 555 | usage_sections = parse_section('usage:', doc) 556 | if len(usage_sections) == 0: 557 | raise DocoptLanguageError('"usage:" (case-insensitive) not found.') 558 | if len(usage_sections) > 1: 559 | raise DocoptLanguageError('More than one "usage:" (case-insensitive).') 560 | DocoptExit.usage = usage_sections[0] 561 | 562 | options = parse_defaults(doc) 563 | pattern = parse_pattern(formal_usage(DocoptExit.usage), options) 564 | # [default] syntax for argument is disabled 565 | #for a in pattern.flat(Argument): 566 | # same_name = [d for d in arguments if d.name == a.name] 567 | # if same_name: 568 | # a.value = same_name[0].value 569 | argv = parse_argv(Tokens(argv), list(options), options_first) 570 | pattern_options = set(pattern.flat(Option)) 571 | for options_shortcut in pattern.flat(OptionsShortcut): 572 | doc_options = parse_defaults(doc) 573 | options_shortcut.children = list(set(doc_options) - pattern_options) 574 | #if any_options: 575 | # options_shortcut.children += [Option(o.short, o.long, o.argcount) 576 | # for o in argv if type(o) is Option] 577 | extras(help, version, argv, doc) 578 | matched, left, collected = pattern.fix().match(argv) 579 | if matched and left == []: # better error message if left? 580 | return Dict((a.name, a.value) for a in (pattern.flat() + collected)) 581 | raise DocoptExit() 582 | -------------------------------------------------------------------------------- /src/workflow/workflow3.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2016 Dean Jackson 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2016-06-25 8 | # 9 | 10 | """An Alfred 3+ version of :class:`~workflow.Workflow`. 11 | 12 | :class:`~workflow.Workflow3` supports new features, such as 13 | setting :ref:`workflow-variables` and 14 | :class:`the more advanced modifiers ` supported by Alfred 3+. 15 | 16 | In order for the feedback mechanism to work correctly, it's important 17 | to create :class:`Item3` and :class:`Modifier` objects via the 18 | :meth:`Workflow3.add_item()` and :meth:`Item3.add_modifier()` methods 19 | respectively. If you instantiate :class:`Item3` or :class:`Modifier` 20 | objects directly, the current :class:`Workflow3` object won't be aware 21 | of them, and they won't be sent to Alfred when you call 22 | :meth:`Workflow3.send_feedback()`. 23 | 24 | """ 25 | 26 | from __future__ import print_function, unicode_literals, absolute_import 27 | 28 | import json 29 | import os 30 | import sys 31 | 32 | from .workflow import ICON_WARNING, Workflow 33 | 34 | 35 | class Variables(dict): 36 | """Workflow variables for Run Script actions. 37 | 38 | .. versionadded: 1.26 39 | 40 | This class allows you to set workflow variables from 41 | Run Script actions. 42 | 43 | It is a subclass of :class:`dict`. 44 | 45 | >>> v = Variables(username='deanishe', password='hunter2') 46 | >>> v.arg = u'output value' 47 | >>> print(v) 48 | 49 | See :ref:`variables-run-script` in the User Guide for more 50 | information. 51 | 52 | Args: 53 | arg (unicode or list, optional): Main output/``{query}``. 54 | **variables: Workflow variables to set. 55 | 56 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a 57 | :class:`list` or :class:`tuple`. 58 | 59 | Attributes: 60 | arg (unicode or list): Output value (``{query}``). 61 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a 62 | :class:`list` or :class:`tuple`. 63 | config (dict): Configuration for downstream workflow element. 64 | 65 | """ 66 | 67 | def __init__(self, arg=None, **variables): 68 | """Create a new `Variables` object.""" 69 | self.arg = arg 70 | self.config = {} 71 | super(Variables, self).__init__(**variables) 72 | 73 | @property 74 | def obj(self): 75 | """``alfredworkflow`` :class:`dict`.""" 76 | o = {} 77 | if self: 78 | d2 = {} 79 | for k, v in self.items(): 80 | d2[k] = v 81 | o['variables'] = d2 82 | 83 | if self.config: 84 | o['config'] = self.config 85 | 86 | if self.arg is not None: 87 | o['arg'] = self.arg 88 | 89 | return {'alfredworkflow': o} 90 | 91 | def __unicode__(self): 92 | """Convert to ``alfredworkflow`` JSON object. 93 | 94 | Returns: 95 | unicode: ``alfredworkflow`` JSON object 96 | 97 | """ 98 | if not self and not self.config: 99 | if not self.arg: 100 | return u'' 101 | if isinstance(self.arg, unicode): 102 | return self.arg 103 | 104 | return json.dumps(self.obj) 105 | 106 | def __str__(self): 107 | """Convert to ``alfredworkflow`` JSON object. 108 | 109 | Returns: 110 | str: UTF-8 encoded ``alfredworkflow`` JSON object 111 | 112 | """ 113 | return unicode(self).encode('utf-8') 114 | 115 | 116 | class Modifier(object): 117 | """Modify :class:`Item3` arg/icon/variables when modifier key is pressed. 118 | 119 | Don't use this class directly (as it won't be associated with any 120 | :class:`Item3`), but rather use :meth:`Item3.add_modifier()` 121 | to add modifiers to results. 122 | 123 | >>> it = wf.add_item('Title', 'Subtitle', valid=True) 124 | >>> it.setvar('name', 'default') 125 | >>> m = it.add_modifier('cmd') 126 | >>> m.setvar('name', 'alternate') 127 | 128 | See :ref:`workflow-variables` in the User Guide for more information 129 | and :ref:`example usage `. 130 | 131 | Args: 132 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. 133 | subtitle (unicode, optional): Override default subtitle. 134 | arg (unicode, optional): Argument to pass for this modifier. 135 | valid (bool, optional): Override item's validity. 136 | icon (unicode, optional): Filepath/UTI of icon to use 137 | icontype (unicode, optional): Type of icon. See 138 | :meth:`Workflow.add_item() ` 139 | for valid values. 140 | 141 | Attributes: 142 | arg (unicode): Arg to pass to following action. 143 | config (dict): Configuration for a downstream element, such as 144 | a File Filter. 145 | icon (unicode): Filepath/UTI of icon. 146 | icontype (unicode): Type of icon. See 147 | :meth:`Workflow.add_item() ` 148 | for valid values. 149 | key (unicode): Modifier key (see above). 150 | subtitle (unicode): Override item subtitle. 151 | valid (bool): Override item validity. 152 | variables (dict): Workflow variables set by this modifier. 153 | 154 | """ 155 | 156 | def __init__(self, key, subtitle=None, arg=None, valid=None, icon=None, 157 | icontype=None): 158 | """Create a new :class:`Modifier`. 159 | 160 | Don't use this class directly (as it won't be associated with any 161 | :class:`Item3`), but rather use :meth:`Item3.add_modifier()` 162 | to add modifiers to results. 163 | 164 | Args: 165 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. 166 | subtitle (unicode, optional): Override default subtitle. 167 | arg (unicode, optional): Argument to pass for this modifier. 168 | valid (bool, optional): Override item's validity. 169 | icon (unicode, optional): Filepath/UTI of icon to use 170 | icontype (unicode, optional): Type of icon. See 171 | :meth:`Workflow.add_item() ` 172 | for valid values. 173 | 174 | """ 175 | self.key = key 176 | self.subtitle = subtitle 177 | self.arg = arg 178 | self.valid = valid 179 | self.icon = icon 180 | self.icontype = icontype 181 | 182 | self.config = {} 183 | self.variables = {} 184 | 185 | def setvar(self, name, value): 186 | """Set a workflow variable for this Item. 187 | 188 | Args: 189 | name (unicode): Name of variable. 190 | value (unicode): Value of variable. 191 | 192 | """ 193 | self.variables[name] = value 194 | 195 | def getvar(self, name, default=None): 196 | """Return value of workflow variable for ``name`` or ``default``. 197 | 198 | Args: 199 | name (unicode): Variable name. 200 | default (None, optional): Value to return if variable is unset. 201 | 202 | Returns: 203 | unicode or ``default``: Value of variable if set or ``default``. 204 | 205 | """ 206 | return self.variables.get(name, default) 207 | 208 | @property 209 | def obj(self): 210 | """Modifier formatted for JSON serialization for Alfred 3. 211 | 212 | Returns: 213 | dict: Modifier for serializing to JSON. 214 | 215 | """ 216 | o = {} 217 | 218 | if self.subtitle is not None: 219 | o['subtitle'] = self.subtitle 220 | 221 | if self.arg is not None: 222 | o['arg'] = self.arg 223 | 224 | if self.valid is not None: 225 | o['valid'] = self.valid 226 | 227 | if self.variables: 228 | o['variables'] = self.variables 229 | 230 | if self.config: 231 | o['config'] = self.config 232 | 233 | icon = self._icon() 234 | if icon: 235 | o['icon'] = icon 236 | 237 | return o 238 | 239 | def _icon(self): 240 | """Return `icon` object for item. 241 | 242 | Returns: 243 | dict: Mapping for item `icon` (may be empty). 244 | 245 | """ 246 | icon = {} 247 | if self.icon is not None: 248 | icon['path'] = self.icon 249 | 250 | if self.icontype is not None: 251 | icon['type'] = self.icontype 252 | 253 | return icon 254 | 255 | 256 | class Item3(object): 257 | """Represents a feedback item for Alfred 3+. 258 | 259 | Generates Alfred-compliant JSON for a single item. 260 | 261 | Don't use this class directly (as it then won't be associated with 262 | any :class:`Workflow3 ` object), but rather use 263 | :meth:`Workflow3.add_item() `. 264 | See :meth:`~workflow.Workflow3.add_item` for details of arguments. 265 | 266 | """ 267 | 268 | def __init__(self, title, subtitle='', arg=None, autocomplete=None, 269 | match=None, valid=False, uid=None, icon=None, icontype=None, 270 | type=None, largetext=None, copytext=None, quicklookurl=None): 271 | """Create a new :class:`Item3` object. 272 | 273 | Use same arguments as for 274 | :class:`Workflow.Item `. 275 | 276 | Argument ``subtitle_modifiers`` is not supported. 277 | 278 | """ 279 | self.title = title 280 | self.subtitle = subtitle 281 | self.arg = arg 282 | self.autocomplete = autocomplete 283 | self.match = match 284 | self.valid = valid 285 | self.uid = uid 286 | self.icon = icon 287 | self.icontype = icontype 288 | self.type = type 289 | self.quicklookurl = quicklookurl 290 | self.largetext = largetext 291 | self.copytext = copytext 292 | 293 | self.modifiers = {} 294 | 295 | self.config = {} 296 | self.variables = {} 297 | 298 | def setvar(self, name, value): 299 | """Set a workflow variable for this Item. 300 | 301 | Args: 302 | name (unicode): Name of variable. 303 | value (unicode): Value of variable. 304 | 305 | """ 306 | self.variables[name] = value 307 | 308 | def getvar(self, name, default=None): 309 | """Return value of workflow variable for ``name`` or ``default``. 310 | 311 | Args: 312 | name (unicode): Variable name. 313 | default (None, optional): Value to return if variable is unset. 314 | 315 | Returns: 316 | unicode or ``default``: Value of variable if set or ``default``. 317 | 318 | """ 319 | return self.variables.get(name, default) 320 | 321 | def add_modifier(self, key, subtitle=None, arg=None, valid=None, icon=None, 322 | icontype=None): 323 | """Add alternative values for a modifier key. 324 | 325 | Args: 326 | key (unicode): Modifier key, e.g. ``"cmd"`` or ``"alt"`` 327 | subtitle (unicode, optional): Override item subtitle. 328 | arg (unicode, optional): Input for following action. 329 | valid (bool, optional): Override item validity. 330 | icon (unicode, optional): Filepath/UTI of icon. 331 | icontype (unicode, optional): Type of icon. See 332 | :meth:`Workflow.add_item() ` 333 | for valid values. 334 | 335 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a 336 | :class:`list` or :class:`tuple`. 337 | 338 | Returns: 339 | Modifier: Configured :class:`Modifier`. 340 | 341 | """ 342 | mod = Modifier(key, subtitle, arg, valid, icon, icontype) 343 | 344 | # Add Item variables to Modifier 345 | mod.variables.update(self.variables) 346 | 347 | self.modifiers[key] = mod 348 | 349 | return mod 350 | 351 | @property 352 | def obj(self): 353 | """Item formatted for JSON serialization. 354 | 355 | Returns: 356 | dict: Data suitable for Alfred 3 feedback. 357 | 358 | """ 359 | # Required values 360 | o = { 361 | 'title': self.title, 362 | 'subtitle': self.subtitle, 363 | 'valid': self.valid, 364 | } 365 | 366 | # Optional values 367 | if self.arg is not None: 368 | o['arg'] = self.arg 369 | 370 | if self.autocomplete is not None: 371 | o['autocomplete'] = self.autocomplete 372 | 373 | if self.match is not None: 374 | o['match'] = self.match 375 | 376 | if self.uid is not None: 377 | o['uid'] = self.uid 378 | 379 | if self.type is not None: 380 | o['type'] = self.type 381 | 382 | if self.quicklookurl is not None: 383 | o['quicklookurl'] = self.quicklookurl 384 | 385 | if self.variables: 386 | o['variables'] = self.variables 387 | 388 | if self.config: 389 | o['config'] = self.config 390 | 391 | # Largetype and copytext 392 | text = self._text() 393 | if text: 394 | o['text'] = text 395 | 396 | icon = self._icon() 397 | if icon: 398 | o['icon'] = icon 399 | 400 | # Modifiers 401 | mods = self._modifiers() 402 | if mods: 403 | o['mods'] = mods 404 | 405 | return o 406 | 407 | def _icon(self): 408 | """Return `icon` object for item. 409 | 410 | Returns: 411 | dict: Mapping for item `icon` (may be empty). 412 | 413 | """ 414 | icon = {} 415 | if self.icon is not None: 416 | icon['path'] = self.icon 417 | 418 | if self.icontype is not None: 419 | icon['type'] = self.icontype 420 | 421 | return icon 422 | 423 | def _text(self): 424 | """Return `largetext` and `copytext` object for item. 425 | 426 | Returns: 427 | dict: `text` mapping (may be empty) 428 | 429 | """ 430 | text = {} 431 | if self.largetext is not None: 432 | text['largetype'] = self.largetext 433 | 434 | if self.copytext is not None: 435 | text['copy'] = self.copytext 436 | 437 | return text 438 | 439 | def _modifiers(self): 440 | """Build `mods` dictionary for JSON feedback. 441 | 442 | Returns: 443 | dict: Modifier mapping or `None`. 444 | 445 | """ 446 | if self.modifiers: 447 | mods = {} 448 | for k, mod in self.modifiers.items(): 449 | mods[k] = mod.obj 450 | 451 | return mods 452 | 453 | return None 454 | 455 | 456 | class Workflow3(Workflow): 457 | """Workflow class that generates Alfred 3+ feedback. 458 | 459 | It is a subclass of :class:`~workflow.Workflow` and most of its 460 | methods are documented there. 461 | 462 | Attributes: 463 | item_class (class): Class used to generate feedback items. 464 | variables (dict): Top level workflow variables. 465 | 466 | """ 467 | 468 | item_class = Item3 469 | 470 | def __init__(self, **kwargs): 471 | """Create a new :class:`Workflow3` object. 472 | 473 | See :class:`~workflow.Workflow` for documentation. 474 | 475 | """ 476 | Workflow.__init__(self, **kwargs) 477 | self.variables = {} 478 | self._rerun = 0 479 | # Get session ID from environment if present 480 | self._session_id = os.getenv('_WF_SESSION_ID') or None 481 | if self._session_id: 482 | self.setvar('_WF_SESSION_ID', self._session_id) 483 | 484 | @property 485 | def _default_cachedir(self): 486 | """Alfred 4's default cache directory.""" 487 | return os.path.join( 488 | os.path.expanduser( 489 | '~/Library/Caches/com.runningwithcrayons.Alfred/' 490 | 'Workflow Data/'), 491 | self.bundleid) 492 | 493 | @property 494 | def _default_datadir(self): 495 | """Alfred 4's default data directory.""" 496 | return os.path.join(os.path.expanduser( 497 | '~/Library/Application Support/Alfred/Workflow Data/'), 498 | self.bundleid) 499 | 500 | @property 501 | def rerun(self): 502 | """How often (in seconds) Alfred should re-run the Script Filter.""" 503 | return self._rerun 504 | 505 | @rerun.setter 506 | def rerun(self, seconds): 507 | """Interval at which Alfred should re-run the Script Filter. 508 | 509 | Args: 510 | seconds (int): Interval between runs. 511 | """ 512 | self._rerun = seconds 513 | 514 | @property 515 | def session_id(self): 516 | """A unique session ID every time the user uses the workflow. 517 | 518 | .. versionadded:: 1.25 519 | 520 | The session ID persists while the user is using this workflow. 521 | It expires when the user runs a different workflow or closes 522 | Alfred. 523 | 524 | """ 525 | if not self._session_id: 526 | from uuid import uuid4 527 | self._session_id = uuid4().hex 528 | self.setvar('_WF_SESSION_ID', self._session_id) 529 | 530 | return self._session_id 531 | 532 | def setvar(self, name, value, persist=False): 533 | """Set a "global" workflow variable. 534 | 535 | .. versionchanged:: 1.33 536 | 537 | These variables are always passed to downstream workflow objects. 538 | 539 | If you have set :attr:`rerun`, these variables are also passed 540 | back to the script when Alfred runs it again. 541 | 542 | Args: 543 | name (unicode): Name of variable. 544 | value (unicode): Value of variable. 545 | persist (bool, optional): Also save variable to ``info.plist``? 546 | 547 | """ 548 | self.variables[name] = value 549 | if persist: 550 | from .util import set_config 551 | set_config(name, value, self.bundleid) 552 | self.logger.debug('saved variable %r with value %r to info.plist', 553 | name, value) 554 | 555 | def getvar(self, name, default=None): 556 | """Return value of workflow variable for ``name`` or ``default``. 557 | 558 | Args: 559 | name (unicode): Variable name. 560 | default (None, optional): Value to return if variable is unset. 561 | 562 | Returns: 563 | unicode or ``default``: Value of variable if set or ``default``. 564 | 565 | """ 566 | return self.variables.get(name, default) 567 | 568 | def add_item(self, title, subtitle='', arg=None, autocomplete=None, 569 | valid=False, uid=None, icon=None, icontype=None, type=None, 570 | largetext=None, copytext=None, quicklookurl=None, match=None): 571 | """Add an item to be output to Alfred. 572 | 573 | Args: 574 | match (unicode, optional): If you have "Alfred filters results" 575 | turned on for your Script Filter, Alfred (version 3.5 and 576 | above) will filter against this field, not ``title``. 577 | 578 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a 579 | :class:`list` or :class:`tuple`. 580 | 581 | See :meth:`Workflow.add_item() ` for 582 | the main documentation and other parameters. 583 | 584 | The key difference is that this method does not support the 585 | ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()` 586 | method instead on the returned item instead. 587 | 588 | Returns: 589 | Item3: Alfred feedback item. 590 | 591 | """ 592 | item = self.item_class(title, subtitle, arg, autocomplete, 593 | match, valid, uid, icon, icontype, type, 594 | largetext, copytext, quicklookurl) 595 | 596 | # Add variables to child item 597 | item.variables.update(self.variables) 598 | 599 | self._items.append(item) 600 | return item 601 | 602 | @property 603 | def _session_prefix(self): 604 | """Filename prefix for current session.""" 605 | return '_wfsess-{0}-'.format(self.session_id) 606 | 607 | def _mk_session_name(self, name): 608 | """New cache name/key based on session ID.""" 609 | return self._session_prefix + name 610 | 611 | def cache_data(self, name, data, session=False): 612 | """Cache API with session-scoped expiry. 613 | 614 | .. versionadded:: 1.25 615 | 616 | Args: 617 | name (str): Cache key 618 | data (object): Data to cache 619 | session (bool, optional): Whether to scope the cache 620 | to the current session. 621 | 622 | ``name`` and ``data`` are the same as for the 623 | :meth:`~workflow.Workflow.cache_data` method on 624 | :class:`~workflow.Workflow`. 625 | 626 | If ``session`` is ``True``, then ``name`` is prefixed 627 | with :attr:`session_id`. 628 | 629 | """ 630 | if session: 631 | name = self._mk_session_name(name) 632 | 633 | return super(Workflow3, self).cache_data(name, data) 634 | 635 | def cached_data(self, name, data_func=None, max_age=60, session=False): 636 | """Cache API with session-scoped expiry. 637 | 638 | .. versionadded:: 1.25 639 | 640 | Args: 641 | name (str): Cache key 642 | data_func (callable): Callable that returns fresh data. It 643 | is called if the cache has expired or doesn't exist. 644 | max_age (int): Maximum allowable age of cache in seconds. 645 | session (bool, optional): Whether to scope the cache 646 | to the current session. 647 | 648 | ``name``, ``data_func`` and ``max_age`` are the same as for the 649 | :meth:`~workflow.Workflow.cached_data` method on 650 | :class:`~workflow.Workflow`. 651 | 652 | If ``session`` is ``True``, then ``name`` is prefixed 653 | with :attr:`session_id`. 654 | 655 | """ 656 | if session: 657 | name = self._mk_session_name(name) 658 | 659 | return super(Workflow3, self).cached_data(name, data_func, max_age) 660 | 661 | def clear_session_cache(self, current=False): 662 | """Remove session data from the cache. 663 | 664 | .. versionadded:: 1.25 665 | .. versionchanged:: 1.27 666 | 667 | By default, data belonging to the current session won't be 668 | deleted. Set ``current=True`` to also clear current session. 669 | 670 | Args: 671 | current (bool, optional): If ``True``, also remove data for 672 | current session. 673 | 674 | """ 675 | def _is_session_file(filename): 676 | if current: 677 | return filename.startswith('_wfsess-') 678 | return filename.startswith('_wfsess-') \ 679 | and not filename.startswith(self._session_prefix) 680 | 681 | self.clear_cache(_is_session_file) 682 | 683 | @property 684 | def obj(self): 685 | """Feedback formatted for JSON serialization. 686 | 687 | Returns: 688 | dict: Data suitable for Alfred 3 feedback. 689 | 690 | """ 691 | items = [] 692 | for item in self._items: 693 | items.append(item.obj) 694 | 695 | o = {'items': items} 696 | if self.variables: 697 | o['variables'] = self.variables 698 | if self.rerun: 699 | o['rerun'] = self.rerun 700 | return o 701 | 702 | def warn_empty(self, title, subtitle=u'', icon=None): 703 | """Add a warning to feedback if there are no items. 704 | 705 | .. versionadded:: 1.31 706 | 707 | Add a "warning" item to Alfred feedback if no other items 708 | have been added. This is a handy shortcut to prevent Alfred 709 | from showing its fallback searches, which is does if no 710 | items are returned. 711 | 712 | Args: 713 | title (unicode): Title of feedback item. 714 | subtitle (unicode, optional): Subtitle of feedback item. 715 | icon (str, optional): Icon for feedback item. If not 716 | specified, ``ICON_WARNING`` is used. 717 | 718 | Returns: 719 | Item3: Newly-created item. 720 | 721 | """ 722 | if len(self._items): 723 | return 724 | 725 | icon = icon or ICON_WARNING 726 | return self.add_item(title, subtitle, icon=icon) 727 | 728 | def send_feedback(self): 729 | """Print stored items to console/Alfred as JSON.""" 730 | if self.debugging: 731 | json.dump(self.obj, sys.stdout, indent=2, separators=(',', ': ')) 732 | else: 733 | json.dump(self.obj, sys.stdout) 734 | sys.stdout.flush() 735 | -------------------------------------------------------------------------------- /src/workflow/web.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2014 Dean Jackson 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2014-02-15 8 | # 9 | 10 | """Lightweight HTTP library with a requests-like interface.""" 11 | 12 | from __future__ import absolute_import, print_function 13 | 14 | import codecs 15 | import json 16 | import mimetypes 17 | import os 18 | import random 19 | import re 20 | import socket 21 | import string 22 | import unicodedata 23 | import urllib 24 | import urllib2 25 | import urlparse 26 | import zlib 27 | 28 | __version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read() 29 | 30 | USER_AGENT = (u'Alfred-Workflow/' + __version__ + 31 | ' (+http://www.deanishe.net/alfred-workflow)') 32 | 33 | # Valid characters for multipart form data boundaries 34 | BOUNDARY_CHARS = string.digits + string.ascii_letters 35 | 36 | # HTTP response codes 37 | RESPONSES = { 38 | 100: 'Continue', 39 | 101: 'Switching Protocols', 40 | 200: 'OK', 41 | 201: 'Created', 42 | 202: 'Accepted', 43 | 203: 'Non-Authoritative Information', 44 | 204: 'No Content', 45 | 205: 'Reset Content', 46 | 206: 'Partial Content', 47 | 300: 'Multiple Choices', 48 | 301: 'Moved Permanently', 49 | 302: 'Found', 50 | 303: 'See Other', 51 | 304: 'Not Modified', 52 | 305: 'Use Proxy', 53 | 307: 'Temporary Redirect', 54 | 400: 'Bad Request', 55 | 401: 'Unauthorized', 56 | 402: 'Payment Required', 57 | 403: 'Forbidden', 58 | 404: 'Not Found', 59 | 405: 'Method Not Allowed', 60 | 406: 'Not Acceptable', 61 | 407: 'Proxy Authentication Required', 62 | 408: 'Request Timeout', 63 | 409: 'Conflict', 64 | 410: 'Gone', 65 | 411: 'Length Required', 66 | 412: 'Precondition Failed', 67 | 413: 'Request Entity Too Large', 68 | 414: 'Request-URI Too Long', 69 | 415: 'Unsupported Media Type', 70 | 416: 'Requested Range Not Satisfiable', 71 | 417: 'Expectation Failed', 72 | 500: 'Internal Server Error', 73 | 501: 'Not Implemented', 74 | 502: 'Bad Gateway', 75 | 503: 'Service Unavailable', 76 | 504: 'Gateway Timeout', 77 | 505: 'HTTP Version Not Supported' 78 | } 79 | 80 | 81 | def str_dict(dic): 82 | """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`. 83 | 84 | :param dic: Mapping of Unicode strings 85 | :type dic: dict 86 | :returns: Dictionary containing only UTF-8 strings 87 | :rtype: dict 88 | 89 | """ 90 | if isinstance(dic, CaseInsensitiveDictionary): 91 | dic2 = CaseInsensitiveDictionary() 92 | else: 93 | dic2 = {} 94 | for k, v in dic.items(): 95 | if isinstance(k, unicode): 96 | k = k.encode('utf-8') 97 | if isinstance(v, unicode): 98 | v = v.encode('utf-8') 99 | dic2[k] = v 100 | return dic2 101 | 102 | 103 | class NoRedirectHandler(urllib2.HTTPRedirectHandler): 104 | """Prevent redirections.""" 105 | 106 | def redirect_request(self, *args): 107 | """Ignore redirect.""" 108 | return None 109 | 110 | 111 | # Adapted from https://gist.github.com/babakness/3901174 112 | class CaseInsensitiveDictionary(dict): 113 | """Dictionary with caseless key search. 114 | 115 | Enables case insensitive searching while preserving case sensitivity 116 | when keys are listed, ie, via keys() or items() methods. 117 | 118 | Works by storing a lowercase version of the key as the new key and 119 | stores the original key-value pair as the key's value 120 | (values become dictionaries). 121 | 122 | """ 123 | 124 | def __init__(self, initval=None): 125 | """Create new case-insensitive dictionary.""" 126 | if isinstance(initval, dict): 127 | for key, value in initval.iteritems(): 128 | self.__setitem__(key, value) 129 | 130 | elif isinstance(initval, list): 131 | for (key, value) in initval: 132 | self.__setitem__(key, value) 133 | 134 | def __contains__(self, key): 135 | return dict.__contains__(self, key.lower()) 136 | 137 | def __getitem__(self, key): 138 | return dict.__getitem__(self, key.lower())['val'] 139 | 140 | def __setitem__(self, key, value): 141 | return dict.__setitem__(self, key.lower(), {'key': key, 'val': value}) 142 | 143 | def get(self, key, default=None): 144 | """Return value for case-insensitive key or default.""" 145 | try: 146 | v = dict.__getitem__(self, key.lower()) 147 | except KeyError: 148 | return default 149 | else: 150 | return v['val'] 151 | 152 | def update(self, other): 153 | """Update values from other ``dict``.""" 154 | for k, v in other.items(): 155 | self[k] = v 156 | 157 | def items(self): 158 | """Return ``(key, value)`` pairs.""" 159 | return [(v['key'], v['val']) for v in dict.itervalues(self)] 160 | 161 | def keys(self): 162 | """Return original keys.""" 163 | return [v['key'] for v in dict.itervalues(self)] 164 | 165 | def values(self): 166 | """Return all values.""" 167 | return [v['val'] for v in dict.itervalues(self)] 168 | 169 | def iteritems(self): 170 | """Iterate over ``(key, value)`` pairs.""" 171 | for v in dict.itervalues(self): 172 | yield v['key'], v['val'] 173 | 174 | def iterkeys(self): 175 | """Iterate over original keys.""" 176 | for v in dict.itervalues(self): 177 | yield v['key'] 178 | 179 | def itervalues(self): 180 | """Interate over values.""" 181 | for v in dict.itervalues(self): 182 | yield v['val'] 183 | 184 | 185 | class Request(urllib2.Request): 186 | """Subclass of :class:`urllib2.Request` that supports custom methods.""" 187 | 188 | def __init__(self, *args, **kwargs): 189 | """Create a new :class:`Request`.""" 190 | self._method = kwargs.pop('method', None) 191 | urllib2.Request.__init__(self, *args, **kwargs) 192 | 193 | def get_method(self): 194 | return self._method.upper() 195 | 196 | 197 | class Response(object): 198 | """ 199 | Returned by :func:`request` / :func:`get` / :func:`post` functions. 200 | 201 | Simplified version of the ``Response`` object in the ``requests`` library. 202 | 203 | >>> r = request('http://www.google.com') 204 | >>> r.status_code 205 | 200 206 | >>> r.encoding 207 | ISO-8859-1 208 | >>> r.content # bytes 209 | ... 210 | >>> r.text # unicode, decoded according to charset in HTTP header/meta tag 211 | u' ...' 212 | >>> r.json() # content parsed as JSON 213 | 214 | """ 215 | 216 | def __init__(self, request, stream=False): 217 | """Call `request` with :mod:`urllib2` and process results. 218 | 219 | :param request: :class:`Request` instance 220 | :param stream: Whether to stream response or retrieve it all at once 221 | :type stream: bool 222 | 223 | """ 224 | self.request = request 225 | self._stream = stream 226 | self.url = None 227 | self.raw = None 228 | self._encoding = None 229 | self.error = None 230 | self.status_code = None 231 | self.reason = None 232 | self.headers = CaseInsensitiveDictionary() 233 | self._content = None 234 | self._content_loaded = False 235 | self._gzipped = False 236 | 237 | # Execute query 238 | try: 239 | self.raw = urllib2.urlopen(request) 240 | except urllib2.HTTPError as err: 241 | self.error = err 242 | try: 243 | self.url = err.geturl() 244 | # sometimes (e.g. when authentication fails) 245 | # urllib can't get a URL from an HTTPError 246 | # This behaviour changes across Python versions, 247 | # so no test cover (it isn't important). 248 | except AttributeError: # pragma: no cover 249 | pass 250 | self.status_code = err.code 251 | else: 252 | self.status_code = self.raw.getcode() 253 | self.url = self.raw.geturl() 254 | self.reason = RESPONSES.get(self.status_code) 255 | 256 | # Parse additional info if request succeeded 257 | if not self.error: 258 | headers = self.raw.info() 259 | self.transfer_encoding = headers.getencoding() 260 | self.mimetype = headers.gettype() 261 | for key in headers.keys(): 262 | self.headers[key.lower()] = headers.get(key) 263 | 264 | # Is content gzipped? 265 | # Transfer-Encoding appears to not be used in the wild 266 | # (contrary to the HTTP standard), but no harm in testing 267 | # for it 268 | if 'gzip' in headers.get('content-encoding', '') or \ 269 | 'gzip' in headers.get('transfer-encoding', ''): 270 | self._gzipped = True 271 | 272 | @property 273 | def stream(self): 274 | """Whether response is streamed. 275 | 276 | Returns: 277 | bool: `True` if response is streamed. 278 | 279 | """ 280 | return self._stream 281 | 282 | @stream.setter 283 | def stream(self, value): 284 | if self._content_loaded: 285 | raise RuntimeError("`content` has already been read from " 286 | "this Response.") 287 | 288 | self._stream = value 289 | 290 | def json(self): 291 | """Decode response contents as JSON. 292 | 293 | :returns: object decoded from JSON 294 | :rtype: list, dict or unicode 295 | 296 | """ 297 | return json.loads(self.content, self.encoding or 'utf-8') 298 | 299 | @property 300 | def encoding(self): 301 | """Text encoding of document or ``None``. 302 | 303 | :returns: Text encoding if found. 304 | :rtype: str or ``None`` 305 | 306 | """ 307 | if not self._encoding: 308 | self._encoding = self._get_encoding() 309 | 310 | return self._encoding 311 | 312 | @property 313 | def content(self): 314 | """Raw content of response (i.e. bytes). 315 | 316 | :returns: Body of HTTP response 317 | :rtype: str 318 | 319 | """ 320 | if not self._content: 321 | 322 | # Decompress gzipped content 323 | if self._gzipped: 324 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) 325 | self._content = decoder.decompress(self.raw.read()) 326 | 327 | else: 328 | self._content = self.raw.read() 329 | 330 | self._content_loaded = True 331 | 332 | return self._content 333 | 334 | @property 335 | def text(self): 336 | """Unicode-decoded content of response body. 337 | 338 | If no encoding can be determined from HTTP headers or the content 339 | itself, the encoded response body will be returned instead. 340 | 341 | :returns: Body of HTTP response 342 | :rtype: unicode or str 343 | 344 | """ 345 | if self.encoding: 346 | return unicodedata.normalize('NFC', unicode(self.content, 347 | self.encoding)) 348 | return self.content 349 | 350 | def iter_content(self, chunk_size=4096, decode_unicode=False): 351 | """Iterate over response data. 352 | 353 | .. versionadded:: 1.6 354 | 355 | :param chunk_size: Number of bytes to read into memory 356 | :type chunk_size: int 357 | :param decode_unicode: Decode to Unicode using detected encoding 358 | :type decode_unicode: bool 359 | :returns: iterator 360 | 361 | """ 362 | if not self.stream: 363 | raise RuntimeError("You cannot call `iter_content` on a " 364 | "Response unless you passed `stream=True`" 365 | " to `get()`/`post()`/`request()`.") 366 | 367 | if self._content_loaded: 368 | raise RuntimeError( 369 | "`content` has already been read from this Response.") 370 | 371 | def decode_stream(iterator, r): 372 | dec = codecs.getincrementaldecoder(r.encoding)(errors='replace') 373 | 374 | for chunk in iterator: 375 | data = dec.decode(chunk) 376 | if data: 377 | yield data 378 | 379 | data = dec.decode(b'', final=True) 380 | if data: # pragma: no cover 381 | yield data 382 | 383 | def generate(): 384 | if self._gzipped: 385 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) 386 | 387 | while True: 388 | chunk = self.raw.read(chunk_size) 389 | if not chunk: 390 | break 391 | 392 | if self._gzipped: 393 | chunk = decoder.decompress(chunk) 394 | 395 | yield chunk 396 | 397 | chunks = generate() 398 | 399 | if decode_unicode and self.encoding: 400 | chunks = decode_stream(chunks, self) 401 | 402 | return chunks 403 | 404 | def save_to_path(self, filepath): 405 | """Save retrieved data to file at ``filepath``. 406 | 407 | .. versionadded: 1.9.6 408 | 409 | :param filepath: Path to save retrieved data. 410 | 411 | """ 412 | filepath = os.path.abspath(filepath) 413 | dirname = os.path.dirname(filepath) 414 | if not os.path.exists(dirname): 415 | os.makedirs(dirname) 416 | 417 | self.stream = True 418 | 419 | with open(filepath, 'wb') as fileobj: 420 | for data in self.iter_content(): 421 | fileobj.write(data) 422 | 423 | def raise_for_status(self): 424 | """Raise stored error if one occurred. 425 | 426 | error will be instance of :class:`urllib2.HTTPError` 427 | """ 428 | if self.error is not None: 429 | raise self.error 430 | return 431 | 432 | def _get_encoding(self): 433 | """Get encoding from HTTP headers or content. 434 | 435 | :returns: encoding or `None` 436 | :rtype: unicode or ``None`` 437 | 438 | """ 439 | headers = self.raw.info() 440 | encoding = None 441 | 442 | if headers.getparam('charset'): 443 | encoding = headers.getparam('charset') 444 | 445 | # HTTP Content-Type header 446 | for param in headers.getplist(): 447 | if param.startswith('charset='): 448 | encoding = param[8:] 449 | break 450 | 451 | if not self.stream: # Try sniffing response content 452 | # Encoding declared in document should override HTTP headers 453 | if self.mimetype == 'text/html': # sniff HTML headers 454 | m = re.search(r"""""", 455 | self.content) 456 | if m: 457 | encoding = m.group(1) 458 | 459 | elif ((self.mimetype.startswith('application/') 460 | or self.mimetype.startswith('text/')) 461 | and 'xml' in self.mimetype): 462 | m = re.search(r"""]*\?>""", 463 | self.content) 464 | if m: 465 | encoding = m.group(1) 466 | 467 | # Format defaults 468 | if self.mimetype == 'application/json' and not encoding: 469 | # The default encoding for JSON 470 | encoding = 'utf-8' 471 | 472 | elif self.mimetype == 'application/xml' and not encoding: 473 | # The default for 'application/xml' 474 | encoding = 'utf-8' 475 | 476 | if encoding: 477 | encoding = encoding.lower() 478 | 479 | return encoding 480 | 481 | 482 | def request(method, url, params=None, data=None, headers=None, cookies=None, 483 | files=None, auth=None, timeout=60, allow_redirects=False, 484 | stream=False): 485 | """Initiate an HTTP(S) request. Returns :class:`Response` object. 486 | 487 | :param method: 'GET' or 'POST' 488 | :type method: unicode 489 | :param url: URL to open 490 | :type url: unicode 491 | :param params: mapping of URL parameters 492 | :type params: dict 493 | :param data: mapping of form data ``{'field_name': 'value'}`` or 494 | :class:`str` 495 | :type data: dict or str 496 | :param headers: HTTP headers 497 | :type headers: dict 498 | :param cookies: cookies to send to server 499 | :type cookies: dict 500 | :param files: files to upload (see below). 501 | :type files: dict 502 | :param auth: username, password 503 | :type auth: tuple 504 | :param timeout: connection timeout limit in seconds 505 | :type timeout: int 506 | :param allow_redirects: follow redirections 507 | :type allow_redirects: bool 508 | :param stream: Stream content instead of fetching it all at once. 509 | :type stream: bool 510 | :returns: Response object 511 | :rtype: :class:`Response` 512 | 513 | 514 | The ``files`` argument is a dictionary:: 515 | 516 | {'fieldname' : { 'filename': 'blah.txt', 517 | 'content': '', 518 | 'mimetype': 'text/plain'} 519 | } 520 | 521 | * ``fieldname`` is the name of the field in the HTML form. 522 | * ``mimetype`` is optional. If not provided, :mod:`mimetypes` will 523 | be used to guess the mimetype, or ``application/octet-stream`` 524 | will be used. 525 | 526 | """ 527 | # TODO: cookies 528 | socket.setdefaulttimeout(timeout) 529 | 530 | # Default handlers 531 | openers = [urllib2.ProxyHandler(urllib2.getproxies())] 532 | 533 | if not allow_redirects: 534 | openers.append(NoRedirectHandler()) 535 | 536 | if auth is not None: # Add authorisation handler 537 | username, password = auth 538 | password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm() 539 | password_manager.add_password(None, url, username, password) 540 | auth_manager = urllib2.HTTPBasicAuthHandler(password_manager) 541 | openers.append(auth_manager) 542 | 543 | # Install our custom chain of openers 544 | opener = urllib2.build_opener(*openers) 545 | urllib2.install_opener(opener) 546 | 547 | if not headers: 548 | headers = CaseInsensitiveDictionary() 549 | else: 550 | headers = CaseInsensitiveDictionary(headers) 551 | 552 | if 'user-agent' not in headers: 553 | headers['user-agent'] = USER_AGENT 554 | 555 | # Accept gzip-encoded content 556 | encodings = [s.strip() for s in 557 | headers.get('accept-encoding', '').split(',')] 558 | if 'gzip' not in encodings: 559 | encodings.append('gzip') 560 | 561 | headers['accept-encoding'] = ', '.join(encodings) 562 | 563 | if files: 564 | if not data: 565 | data = {} 566 | new_headers, data = encode_multipart_formdata(data, files) 567 | headers.update(new_headers) 568 | elif data and isinstance(data, dict): 569 | data = urllib.urlencode(str_dict(data)) 570 | 571 | # Make sure everything is encoded text 572 | headers = str_dict(headers) 573 | 574 | if isinstance(url, unicode): 575 | url = url.encode('utf-8') 576 | 577 | if params: # GET args (POST args are handled in encode_multipart_formdata) 578 | 579 | scheme, netloc, path, query, fragment = urlparse.urlsplit(url) 580 | 581 | if query: # Combine query string and `params` 582 | url_params = urlparse.parse_qs(query) 583 | # `params` take precedence over URL query string 584 | url_params.update(params) 585 | params = url_params 586 | 587 | query = urllib.urlencode(str_dict(params), doseq=True) 588 | url = urlparse.urlunsplit((scheme, netloc, path, query, fragment)) 589 | 590 | req = Request(url, data, headers, method=method) 591 | return Response(req, stream) 592 | 593 | 594 | def get(url, params=None, headers=None, cookies=None, auth=None, 595 | timeout=60, allow_redirects=True, stream=False): 596 | """Initiate a GET request. Arguments as for :func:`request`. 597 | 598 | :returns: :class:`Response` instance 599 | 600 | """ 601 | return request('GET', url, params, headers=headers, cookies=cookies, 602 | auth=auth, timeout=timeout, allow_redirects=allow_redirects, 603 | stream=stream) 604 | 605 | 606 | def delete(url, params=None, data=None, headers=None, cookies=None, auth=None, 607 | timeout=60, allow_redirects=True, stream=False): 608 | """Initiate a DELETE request. Arguments as for :func:`request`. 609 | 610 | :returns: :class:`Response` instance 611 | 612 | """ 613 | return request('DELETE', url, params, data, headers=headers, 614 | cookies=cookies, auth=auth, timeout=timeout, 615 | allow_redirects=allow_redirects, stream=stream) 616 | 617 | 618 | def post(url, params=None, data=None, headers=None, cookies=None, files=None, 619 | auth=None, timeout=60, allow_redirects=False, stream=False): 620 | """Initiate a POST request. Arguments as for :func:`request`. 621 | 622 | :returns: :class:`Response` instance 623 | 624 | """ 625 | return request('POST', url, params, data, headers, cookies, files, auth, 626 | timeout, allow_redirects, stream) 627 | 628 | 629 | def put(url, params=None, data=None, headers=None, cookies=None, files=None, 630 | auth=None, timeout=60, allow_redirects=False, stream=False): 631 | """Initiate a PUT request. Arguments as for :func:`request`. 632 | 633 | :returns: :class:`Response` instance 634 | 635 | """ 636 | return request('PUT', url, params, data, headers, cookies, files, auth, 637 | timeout, allow_redirects, stream) 638 | 639 | 640 | def encode_multipart_formdata(fields, files): 641 | """Encode form data (``fields``) and ``files`` for POST request. 642 | 643 | :param fields: mapping of ``{name : value}`` pairs for normal form fields. 644 | :type fields: dict 645 | :param files: dictionary of fieldnames/files elements for file data. 646 | See below for details. 647 | :type files: dict of :class:`dict` 648 | :returns: ``(headers, body)`` ``headers`` is a 649 | :class:`dict` of HTTP headers 650 | :rtype: 2-tuple ``(dict, str)`` 651 | 652 | The ``files`` argument is a dictionary:: 653 | 654 | {'fieldname' : { 'filename': 'blah.txt', 655 | 'content': '', 656 | 'mimetype': 'text/plain'} 657 | } 658 | 659 | - ``fieldname`` is the name of the field in the HTML form. 660 | - ``mimetype`` is optional. If not provided, :mod:`mimetypes` will 661 | be used to guess the mimetype, or ``application/octet-stream`` 662 | will be used. 663 | 664 | """ 665 | def get_content_type(filename): 666 | """Return or guess mimetype of ``filename``. 667 | 668 | :param filename: filename of file 669 | :type filename: unicode/str 670 | :returns: mime-type, e.g. ``text/html`` 671 | :rtype: str 672 | 673 | """ 674 | return mimetypes.guess_type(filename)[0] or 'application/octet-stream' 675 | 676 | boundary = '-----' + ''.join(random.choice(BOUNDARY_CHARS) 677 | for i in range(30)) 678 | CRLF = '\r\n' 679 | output = [] 680 | 681 | # Normal form fields 682 | for (name, value) in fields.items(): 683 | if isinstance(name, unicode): 684 | name = name.encode('utf-8') 685 | if isinstance(value, unicode): 686 | value = value.encode('utf-8') 687 | output.append('--' + boundary) 688 | output.append('Content-Disposition: form-data; name="%s"' % name) 689 | output.append('') 690 | output.append(value) 691 | 692 | # Files to upload 693 | for name, d in files.items(): 694 | filename = d[u'filename'] 695 | content = d[u'content'] 696 | if u'mimetype' in d: 697 | mimetype = d[u'mimetype'] 698 | else: 699 | mimetype = get_content_type(filename) 700 | if isinstance(name, unicode): 701 | name = name.encode('utf-8') 702 | if isinstance(filename, unicode): 703 | filename = filename.encode('utf-8') 704 | if isinstance(mimetype, unicode): 705 | mimetype = mimetype.encode('utf-8') 706 | output.append('--' + boundary) 707 | output.append('Content-Disposition: form-data; ' 708 | 'name="%s"; filename="%s"' % (name, filename)) 709 | output.append('Content-Type: %s' % mimetype) 710 | output.append('') 711 | output.append(content) 712 | 713 | output.append('--' + boundary + '--') 714 | output.append('') 715 | body = CRLF.join(output) 716 | headers = { 717 | 'Content-Type': 'multipart/form-data; boundary=%s' % boundary, 718 | 'Content-Length': str(len(body)), 719 | } 720 | return (headers, body) 721 | -------------------------------------------------------------------------------- /src/info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | net.deanishe.alfred-fuzzyfolders 7 | connections 8 | 9 | 072ED033-882E-4FD9-B6DD-308AA679F35D 10 | 11 | 12 | destinationuid 13 | C9B865B2-C678-490E-9B41-8E7086A40403 14 | modifiers 15 | 0 16 | modifiersubtext 17 | 18 | vitoclose 19 | 20 | 21 | 22 | 0D965234-E730-45BE-A07A-4BC8B87BF65F 23 | 24 | 25 | destinationuid 26 | B0340064-83A3-4631-AE24-C3D774778CCC 27 | modifiers 28 | 0 29 | modifiersubtext 30 | 31 | vitoclose 32 | 33 | 34 | 35 | 5D907FEA-E291-40D6-81EA-03BBF88CBA19 36 | 37 | 38 | destinationuid 39 | 023B01E3-48DF-47F7-BA7C-FAD3A0D2739D 40 | modifiers 41 | 0 42 | modifiersubtext 43 | 44 | vitoclose 45 | 46 | 47 | 48 | 64A5BF3F-BD7A-4909-A640-27F560FAB9C1 49 | 50 | 51 | destinationuid 52 | DE2535F9-2D18-4F4D-BFE1-D6BBB65866B2 53 | modifiers 54 | 0 55 | modifiersubtext 56 | 57 | vitoclose 58 | 59 | 60 | 61 | destinationuid 62 | 68C3E5B4-E26A-4D94-8951-8C2DB76040EA 63 | modifiers 64 | 524288 65 | modifiersubtext 66 | Delete this keyword / Fuzzy Folder combination 67 | vitoclose 68 | 69 | 70 | 71 | destinationuid 72 | B0A332C9-2546-4D18-9221-F4A17F48DF30 73 | modifiers 74 | 1048576 75 | modifiersubtext 76 | Run this Fuzzy Folder search 77 | vitoclose 78 | 79 | 80 | 81 | 68C3E5B4-E26A-4D94-8951-8C2DB76040EA 82 | 83 | 84 | destinationuid 85 | CA1FFD2B-8C73-4A26-974A-13C8E8EADAC0 86 | modifiers 87 | 0 88 | modifiersubtext 89 | 90 | vitoclose 91 | 92 | 93 | 94 | 7420E6D7-AD54-4CCB-A867-036168FFF97D 95 | 96 | 97 | destinationuid 98 | 3AC082E0-F48F-4094-8B54-E039CDBC418B 99 | modifiers 100 | 1048576 101 | modifiersubtext 102 | Browse in Alfred 103 | vitoclose 104 | 105 | 106 | 107 | destinationuid 108 | 8DA965F1-FBE5-4283-A66A-05789AA78758 109 | modifiers 110 | 0 111 | modifiersubtext 112 | 113 | vitoclose 114 | 115 | 116 | 117 | 7C8FD108-CDDA-41BE-96B5-2B9A2B0ED46B 118 | 119 | 120 | destinationuid 121 | A6C04BBD-768E-4271-9E7F-B19D39A4EDBE 122 | modifiers 123 | 0 124 | modifiersubtext 125 | 126 | vitoclose 127 | 128 | 129 | 130 | 88E0FCE6-68A7-478A-A2FC-A371813EDF8A 131 | 132 | 133 | destinationuid 134 | EE44C011-B59B-4D63-9EB3-DCC28FB95E64 135 | modifiers 136 | 0 137 | modifiersubtext 138 | 139 | vitoclose 140 | 141 | 142 | 143 | 8DA965F1-FBE5-4283-A66A-05789AA78758 144 | 145 | A6C04BBD-768E-4271-9E7F-B19D39A4EDBE 146 | 147 | 148 | destinationuid 149 | C4DE39F8-29AD-474D-B7FB-1CDE8B0A81F2 150 | modifiers 151 | 0 152 | modifiersubtext 153 | 154 | vitoclose 155 | 156 | 157 | 158 | E65E3ED3-C8AF-40FC-A04D-EB697B63FC1C 159 | 160 | 161 | destinationuid 162 | 023B01E3-48DF-47F7-BA7C-FAD3A0D2739D 163 | modifiers 164 | 0 165 | modifiersubtext 166 | 167 | vitoclose 168 | 169 | 170 | 171 | E94550B1-B343-4CA1-8E9E-B4DA47DFC189 172 | 173 | 174 | destinationuid 175 | BE24EC68-B0F7-4E83-969E-C2932974426F 176 | modifiers 177 | 0 178 | modifiersubtext 179 | 180 | vitoclose 181 | 182 | 183 | 184 | EE44C011-B59B-4D63-9EB3-DCC28FB95E64 185 | 186 | 187 | destinationuid 188 | 0E4068F3-D9FD-43D1-B336-6E2B5E9328A2 189 | modifiers 190 | 0 191 | modifiersubtext 192 | 193 | vitoclose 194 | 195 | 196 | 197 | F3F04FEC-9503-444C-9AA3-81486E22D88D 198 | 199 | 200 | destinationuid 201 | E94550B1-B343-4CA1-8E9E-B4DA47DFC189 202 | modifiers 203 | 0 204 | modifiersubtext 205 | 206 | vitoclose 207 | 208 | 209 | 210 | 211 | createdby 212 | Dean Jackson 213 | description 214 | Fuzzy search across folder hierarchies 215 | disabled 216 | 217 | name 218 | Fuzzy Folders 219 | objects 220 | 221 | 222 | config 223 | 224 | acceptsmulti 225 | 0 226 | filetypes 227 | 228 | public.folder 229 | 230 | name 231 | Fuzzy Search Here 232 | 233 | type 234 | alfred.workflow.trigger.action 235 | uid 236 | 0D965234-E730-45BE-A07A-4BC8B87BF65F 237 | version 238 | 1 239 | 240 | 241 | config 242 | 243 | concurrently 244 | 245 | escaping 246 | 102 247 | script 248 | python ff.py alfred-search "$1" 249 | scriptargtype 250 | 1 251 | scriptfile 252 | 253 | type 254 | 0 255 | 256 | type 257 | alfred.workflow.action.script 258 | uid 259 | B0340064-83A3-4631-AE24-C3D774778CCC 260 | version 261 | 2 262 | 263 | 264 | config 265 | 266 | alfredfiltersresults 267 | 268 | alfredfiltersresultsmatchmode 269 | 0 270 | argumenttreatemptyqueryasnil 271 | 272 | argumenttrimmode 273 | 0 274 | argumenttype 275 | 0 276 | escaping 277 | 102 278 | queuedelaycustom 279 | 1 280 | queuedelayimmediatelyinitially 281 | 282 | queuedelaymode 283 | 0 284 | queuemode 285 | 1 286 | runningsubtext 287 | Scanning your folders… 288 | script 289 | python ff.py choose "$1" 290 | scriptargtype 291 | 1 292 | scriptfile 293 | 294 | subtext 295 | Choose a folder to add as a Fuzzy Folder 296 | title 297 | Choose a new Fuzzy Folder 298 | type 299 | 0 300 | withspace 301 | 302 | 303 | inboundconfig 304 | 305 | externalid 306 | choose-folder 307 | 308 | type 309 | alfred.workflow.input.scriptfilter 310 | uid 311 | 5D907FEA-E291-40D6-81EA-03BBF88CBA19 312 | version 313 | 3 314 | 315 | 316 | config 317 | 318 | concurrently 319 | 320 | escaping 321 | 102 322 | script 323 | python ff.py add "$1" 324 | scriptargtype 325 | 1 326 | scriptfile 327 | 328 | type 329 | 0 330 | 331 | type 332 | alfred.workflow.action.script 333 | uid 334 | 023B01E3-48DF-47F7-BA7C-FAD3A0D2739D 335 | version 336 | 2 337 | 338 | 339 | config 340 | 341 | acceptsmulti 342 | 0 343 | filetypes 344 | 345 | public.folder 346 | 347 | name 348 | Add Fuzzy Folder 349 | 350 | type 351 | alfred.workflow.trigger.action 352 | uid 353 | E65E3ED3-C8AF-40FC-A04D-EB697B63FC1C 354 | version 355 | 1 356 | 357 | 358 | config 359 | 360 | argumenttype 361 | 2 362 | keyword 363 | fzyhelp 364 | subtext 365 | Open the help file in your browser 366 | text 367 | View Fuzzy Folders Help 368 | withspace 369 | 370 | 371 | type 372 | alfred.workflow.input.keyword 373 | uid 374 | 072ED033-882E-4FD9-B6DD-308AA679F35D 375 | version 376 | 1 377 | 378 | 379 | config 380 | 381 | concurrently 382 | 383 | escaping 384 | 102 385 | script 386 | python ff.py open-help 387 | scriptargtype 388 | 1 389 | scriptfile 390 | 391 | type 392 | 0 393 | 394 | type 395 | alfred.workflow.action.script 396 | uid 397 | C9B865B2-C678-490E-9B41-8E7086A40403 398 | version 399 | 2 400 | 401 | 402 | config 403 | 404 | concurrently 405 | 406 | escaping 407 | 102 408 | script 409 | python ff.py update "$1" 410 | scriptargtype 411 | 1 412 | scriptfile 413 | 414 | type 415 | 0 416 | 417 | type 418 | alfred.workflow.action.script 419 | uid 420 | A6C04BBD-768E-4271-9E7F-B19D39A4EDBE 421 | version 422 | 2 423 | 424 | 425 | config 426 | 427 | alfredfiltersresults 428 | 429 | alfredfiltersresultsmatchmode 430 | 0 431 | argumenttreatemptyqueryasnil 432 | 433 | argumenttrimmode 434 | 0 435 | argumenttype 436 | 0 437 | escaping 438 | 102 439 | queuedelaycustom 440 | 1 441 | queuedelayimmediatelyinitially 442 | 443 | queuedelaymode 444 | 0 445 | queuemode 446 | 1 447 | runningsubtext 448 | 449 | script 450 | python ff.py keyword "$1" 451 | scriptargtype 452 | 1 453 | scriptfile 454 | 455 | subtext 456 | Set search keyword for a folder 457 | title 458 | Set Fuzzy Folder Keyword 459 | type 460 | 0 461 | withspace 462 | 463 | 464 | inboundconfig 465 | 466 | externalid 467 | key 468 | 469 | type 470 | alfred.workflow.input.scriptfilter 471 | uid 472 | 7C8FD108-CDDA-41BE-96B5-2B9A2B0ED46B 473 | version 474 | 3 475 | 476 | 477 | config 478 | 479 | lastpathcomponent 480 | 481 | onlyshowifquerypopulated 482 | 483 | removeextension 484 | 485 | text 486 | {query} 487 | title 488 | FuzzyFolder Updated 489 | 490 | type 491 | alfred.workflow.output.notification 492 | uid 493 | C4DE39F8-29AD-474D-B7FB-1CDE8B0A81F2 494 | version 495 | 1 496 | 497 | 498 | config 499 | 500 | lastpathcomponent 501 | 502 | onlyshowifquerypopulated 503 | 504 | removeextension 505 | 506 | text 507 | Your Script Filters have been regenerated 508 | title 509 | Fuzzy Folders Updated 510 | 511 | type 512 | alfred.workflow.output.notification 513 | uid 514 | BE24EC68-B0F7-4E83-969E-C2932974426F 515 | version 516 | 1 517 | 518 | 519 | config 520 | 521 | argumenttype 522 | 2 523 | keyword 524 | fzyup 525 | subtext 526 | Regenerate Script Filters 527 | text 528 | Update Fuzzy Folders 529 | withspace 530 | 531 | 532 | inboundconfig 533 | 534 | externalid 535 | update 536 | 537 | type 538 | alfred.workflow.input.keyword 539 | uid 540 | F3F04FEC-9503-444C-9AA3-81486E22D88D 541 | version 542 | 1 543 | 544 | 545 | config 546 | 547 | concurrently 548 | 549 | escaping 550 | 102 551 | script 552 | python ff.py update 553 | scriptargtype 554 | 1 555 | scriptfile 556 | 557 | type 558 | 0 559 | 560 | type 561 | alfred.workflow.action.script 562 | uid 563 | E94550B1-B343-4CA1-8E9E-B4DA47DFC189 564 | version 565 | 2 566 | 567 | 568 | config 569 | 570 | concurrently 571 | 572 | escaping 573 | 103 574 | script 575 | python ff.py load-settings "$1" 576 | scriptargtype 577 | 1 578 | scriptfile 579 | 580 | type 581 | 0 582 | 583 | type 584 | alfred.workflow.action.script 585 | uid 586 | DE2535F9-2D18-4F4D-BFE1-D6BBB65866B2 587 | version 588 | 2 589 | 590 | 591 | config 592 | 593 | alfredfiltersresults 594 | 595 | alfredfiltersresultsmatchmode 596 | 0 597 | argumenttreatemptyqueryasnil 598 | 599 | argumenttrimmode 600 | 0 601 | argumenttype 602 | 1 603 | escaping 604 | 102 605 | keyword 606 | fuzzy 607 | queuedelaycustom 608 | 1 609 | queuedelayimmediatelyinitially 610 | 611 | queuedelaymode 612 | 0 613 | queuemode 614 | 1 615 | runningsubtext 616 | 617 | script 618 | python ff.py manage "$1" 619 | scriptargtype 620 | 1 621 | scriptfile 622 | 623 | subtext 624 | Search or delete your Fuzzy Folders 625 | title 626 | Fuzzy Folders 627 | type 628 | 0 629 | withspace 630 | 631 | 632 | inboundconfig 633 | 634 | externalid 635 | fuzzy-folders 636 | inputmode 637 | 1 638 | 639 | type 640 | alfred.workflow.input.scriptfilter 641 | uid 642 | 64A5BF3F-BD7A-4909-A640-27F560FAB9C1 643 | version 644 | 3 645 | 646 | 647 | config 648 | 649 | concurrently 650 | 651 | escaping 652 | 127 653 | script 654 | python ff.py remove "$1" 655 | scriptargtype 656 | 1 657 | scriptfile 658 | 659 | type 660 | 0 661 | 662 | type 663 | alfred.workflow.action.script 664 | uid 665 | 68C3E5B4-E26A-4D94-8951-8C2DB76040EA 666 | version 667 | 2 668 | 669 | 670 | config 671 | 672 | lastpathcomponent 673 | 674 | onlyshowifquerypopulated 675 | 676 | removeextension 677 | 678 | text 679 | {query} 680 | title 681 | Remove Fuzzy Folder 682 | 683 | type 684 | alfred.workflow.output.notification 685 | uid 686 | CA1FFD2B-8C73-4A26-974A-13C8E8EADAC0 687 | version 688 | 1 689 | 690 | 691 | config 692 | 693 | concurrently 694 | 695 | escaping 696 | 102 697 | script 698 | python ff.py load-profile "$1" 699 | scriptargtype 700 | 1 701 | scriptfile 702 | 703 | type 704 | 0 705 | 706 | type 707 | alfred.workflow.action.script 708 | uid 709 | B0A332C9-2546-4D18-9221-F4A17F48DF30 710 | version 711 | 2 712 | 713 | 714 | config 715 | 716 | lastpathcomponent 717 | 718 | onlyshowifquerypopulated 719 | 720 | removeextension 721 | 722 | text 723 | {query} 724 | title 725 | Settings Updated 726 | 727 | type 728 | alfred.workflow.output.notification 729 | uid 730 | 0E4068F3-D9FD-43D1-B336-6E2B5E9328A2 731 | version 732 | 1 733 | 734 | 735 | config 736 | 737 | alfredfiltersresults 738 | 739 | alfredfiltersresultsmatchmode 740 | 0 741 | argumenttreatemptyqueryasnil 742 | 743 | argumenttrimmode 744 | 0 745 | argumenttype 746 | 1 747 | escaping 748 | 102 749 | queuedelaycustom 750 | 1 751 | queuedelayimmediatelyinitially 752 | 753 | queuedelaymode 754 | 0 755 | queuemode 756 | 1 757 | runningsubtext 758 | Loading settings… 759 | script 760 | python ff.py settings "$1" 761 | scriptargtype 762 | 1 763 | scriptfile 764 | 765 | subtext 766 | 767 | title 768 | Change Fuzzy Folder Settings 769 | type 770 | 0 771 | withspace 772 | 773 | 774 | inboundconfig 775 | 776 | externalid 777 | set 778 | 779 | type 780 | alfred.workflow.input.scriptfilter 781 | uid 782 | 88E0FCE6-68A7-478A-A2FC-A371813EDF8A 783 | version 784 | 3 785 | 786 | 787 | config 788 | 789 | concurrently 790 | 791 | escaping 792 | 102 793 | script 794 | python ff.py update-setting "$1" 795 | scriptargtype 796 | 1 797 | scriptfile 798 | 799 | type 800 | 0 801 | 802 | type 803 | alfred.workflow.action.script 804 | uid 805 | EE44C011-B59B-4D63-9EB3-DCC28FB95E64 806 | version 807 | 2 808 | 809 | 810 | config 811 | 812 | concurrently 813 | 814 | escaping 815 | 102 816 | script 817 | python ff.py alfred-browse "$1" 818 | scriptargtype 819 | 1 820 | scriptfile 821 | 822 | type 823 | 0 824 | 825 | type 826 | alfred.workflow.action.script 827 | uid 828 | 3AC082E0-F48F-4094-8B54-E039CDBC418B 829 | version 830 | 2 831 | 832 | 833 | config 834 | 835 | alfredfiltersresults 836 | 837 | alfredfiltersresultsmatchmode 838 | 0 839 | argumenttreatemptyqueryasnil 840 | 841 | argumenttrimmode 842 | 0 843 | argumenttype 844 | 0 845 | escaping 846 | 102 847 | queuedelaycustom 848 | 1 849 | queuedelayimmediatelyinitially 850 | 851 | queuedelaymode 852 | 0 853 | queuemode 854 | 1 855 | runningsubtext 856 | Loading folders… 857 | script 858 | python ff.py search "$1" 859 | scriptargtype 860 | 1 861 | scriptfile 862 | 863 | subtext 864 | Perform fuzzy search across subdirectories 865 | title 866 | Fuzzy Search in Folder 867 | type 868 | 0 869 | withspace 870 | 871 | 872 | inboundconfig 873 | 874 | externalid 875 | search 876 | 877 | type 878 | alfred.workflow.input.scriptfilter 879 | uid 880 | 7420E6D7-AD54-4CCB-A867-036168FFF97D 881 | version 882 | 3 883 | 884 | 885 | config 886 | 887 | openwith 888 | 889 | sourcefile 890 | 891 | 892 | type 893 | alfred.workflow.action.openfile 894 | uid 895 | 8DA965F1-FBE5-4283-A66A-05789AA78758 896 | version 897 | 3 898 | 899 | 900 | readme 901 | Fuzzy search across all subdirectories of the selected folder. 902 | 903 | You can initiate a fuzzy search via the “Fuzzy Search Here” File Action, or use the “Add Fuzzy Folder” File Action to assign a keyword to perform a fuzzy search in the selected folder. 904 | 905 | Use keyword `fzyhelp` or see https://github.com/deanishe/alfred-fuzzyfolders for full details. 906 | uidata 907 | 908 | 023B01E3-48DF-47F7-BA7C-FAD3A0D2739D 909 | 910 | xpos 911 | 500 912 | ypos 913 | 145 914 | 915 | 072ED033-882E-4FD9-B6DD-308AA679F35D 916 | 917 | xpos 918 | 300 919 | ypos 920 | 285 921 | 922 | 0D965234-E730-45BE-A07A-4BC8B87BF65F 923 | 924 | xpos 925 | 100 926 | ypos 927 | 10 928 | 929 | 0E4068F3-D9FD-43D1-B336-6E2B5E9328A2 930 | 931 | xpos 932 | 700 933 | ypos 934 | 1085 935 | 936 | 3AC082E0-F48F-4094-8B54-E039CDBC418B 937 | 938 | xpos 939 | 500 940 | ypos 941 | 1220 942 | 943 | 5D907FEA-E291-40D6-81EA-03BBF88CBA19 944 | 945 | xpos 946 | 300 947 | ypos 948 | 105 949 | 950 | 64A5BF3F-BD7A-4909-A640-27F560FAB9C1 951 | 952 | xpos 953 | 300 954 | ypos 955 | 820 956 | 957 | 68C3E5B4-E26A-4D94-8951-8C2DB76040EA 958 | 959 | xpos 960 | 500 961 | ypos 962 | 820 963 | 964 | 7420E6D7-AD54-4CCB-A867-036168FFF97D 965 | 966 | xpos 967 | 300 968 | ypos 969 | 1220 970 | 971 | 7C8FD108-CDDA-41BE-96B5-2B9A2B0ED46B 972 | 973 | xpos 974 | 300 975 | ypos 976 | 420 977 | 978 | 88E0FCE6-68A7-478A-A2FC-A371813EDF8A 979 | 980 | xpos 981 | 300 982 | ypos 983 | 1085 984 | 985 | 8DA965F1-FBE5-4283-A66A-05789AA78758 986 | 987 | xpos 988 | 500 989 | ypos 990 | 1360 991 | 992 | A6C04BBD-768E-4271-9E7F-B19D39A4EDBE 993 | 994 | xpos 995 | 500 996 | ypos 997 | 420 998 | 999 | B0340064-83A3-4631-AE24-C3D774778CCC 1000 | 1001 | xpos 1002 | 500 1003 | ypos 1004 | 10 1005 | 1006 | B0A332C9-2546-4D18-9221-F4A17F48DF30 1007 | 1008 | xpos 1009 | 500 1010 | ypos 1011 | 955 1012 | 1013 | BE24EC68-B0F7-4E83-969E-C2932974426F 1014 | 1015 | xpos 1016 | 700 1017 | ypos 1018 | 555 1019 | 1020 | C4DE39F8-29AD-474D-B7FB-1CDE8B0A81F2 1021 | 1022 | xpos 1023 | 700 1024 | ypos 1025 | 420 1026 | 1027 | C9B865B2-C678-490E-9B41-8E7086A40403 1028 | 1029 | xpos 1030 | 500 1031 | ypos 1032 | 285 1033 | 1034 | CA1FFD2B-8C73-4A26-974A-13C8E8EADAC0 1035 | 1036 | xpos 1037 | 700 1038 | ypos 1039 | 820 1040 | 1041 | DE2535F9-2D18-4F4D-BFE1-D6BBB65866B2 1042 | 1043 | xpos 1044 | 500 1045 | ypos 1046 | 690 1047 | 1048 | E65E3ED3-C8AF-40FC-A04D-EB697B63FC1C 1049 | 1050 | xpos 1051 | 100 1052 | ypos 1053 | 225 1054 | 1055 | E94550B1-B343-4CA1-8E9E-B4DA47DFC189 1056 | 1057 | xpos 1058 | 500 1059 | ypos 1060 | 555 1061 | 1062 | EE44C011-B59B-4D63-9EB3-DCC28FB95E64 1063 | 1064 | xpos 1065 | 500 1066 | ypos 1067 | 1085 1068 | 1069 | F3F04FEC-9503-444C-9AA3-81486E22D88D 1070 | 1071 | xpos 1072 | 300 1073 | ypos 1074 | 555 1075 | 1076 | 1077 | version 1078 | 2.4.0 1079 | webaddress 1080 | http://www.deanishe.net 1081 | 1082 | 1083 | -------------------------------------------------------------------------------- /src/ff.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 deanishe@deanishe.net 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2014-03-09 9 | # 10 | 11 | """ff.py [ | ] [] 12 | 13 | FuzzyFolders -- fuzzy search across a folder hierarchy 14 | 15 | Usage: 16 | ff.py choose 17 | ff.py add 18 | ff.py remove 19 | ff.py search [] 20 | ff.py keyword 21 | ff.py update [] 22 | ff.py manage [] 23 | ff.py load-profile 24 | ff.py alfred-search 25 | ff.py alfred-browse 26 | ff.py load-settings 27 | ff.py settings 28 | ff.py update-setting 29 | ff.py open-help 30 | 31 | Arguments: 32 | Path to directory 33 | Saved profile number 34 | Search query 35 | 36 | Options: 37 | -h, --help Show this message 38 | 39 | 40 | Commands: 41 | choose 42 | Browse in Alfred. Displays and its subdirectories. Calls `add` 43 | add 44 | Add as Fuzzy Folder. Tells Alfred to ask for a keyword (via `keyword``). 45 | remove 46 | Remove keyword / Fuzzy Folder combination 47 | search [] 48 | Search for in 's dirpath. Display results in Alfred. 49 | If no is specified, is a dirpath-DELIMITER-query 50 | combination. Start an ad-hoc fuzzy search with this. 51 | keyword 52 | Choose a keyword for Fuzzy Folder. is a dirpath and query. 53 | Display options in Alfred. Calls `update `. 54 | update [] 55 | Update/add the Fuzzy Folder / keyword profile in . If no 56 | is specified, updates all profiles. 57 | manage [] 58 | Display a list of all configured profiles in Alfred. Allows activation 59 | or deletion of the profiles. 60 | load-profile 61 | Calls Alfred with the necessary keyword to activate 62 | alfred-search 63 | Calls Alfred with . Simple, pass-through function. 64 | alfred-browse 65 | Calls Alfred with , causing Alfred's browser to activate. 66 | open-help 67 | Open the help file in your browser 68 | 69 | This script is meant to be called from Alfred. 70 | 71 | """ 72 | 73 | from __future__ import print_function, unicode_literals 74 | 75 | import sys 76 | import os 77 | import subprocess 78 | import re 79 | from fnmatch import fnmatch 80 | from plistlib import readPlist, writePlist 81 | import uuid 82 | import unicodedata 83 | 84 | from docopt import docopt 85 | from workflow import (Workflow, ICON_NOTE, ICON_WARNING, 86 | ICON_INFO, ICON_SETTINGS, ICON_ERROR, ICON_SYNC) 87 | from workflow.util import reload_workflow, run_trigger, search_in_alfred 88 | from workflow.workflow import MATCH_ALL, MATCH_ALLCHARS 89 | 90 | 91 | log = None 92 | DELIMITER = '➣' 93 | 94 | # Keywords of Script Filters that shouldn't be removed 95 | RESERVED_KEYWORDS = [ 96 | 'fzyup', 97 | 'fzyhelp', 98 | 'fuzzy' 99 | ] 100 | 101 | 102 | # actions to connect script filters to 103 | ACTIONS = [ 104 | # Browse folder in Alfred 105 | {'destinationuid': '3AC082E0-F48F-4094-8B54-E039CDBC418B', 106 | 'modifiers': 1048576, 107 | 'modifiersubtext': 'Browse in Alfred'}, 108 | # Run keyword search 109 | {'destinationuid': '8DA965F1-FBE5-4283-A66A-05789AA78758', 110 | 'modifiers': '', 111 | 'modifiersubtext': ''}, 112 | ] 113 | 114 | 115 | SCOPE_FOLDERS = 1 116 | SCOPE_FILES = 2 117 | SCOPE_ALL = 3 118 | 119 | SCOPE_NAMES = { 120 | SCOPE_FOLDERS: 'folders only', 121 | SCOPE_FILES: 'files only', 122 | SCOPE_ALL: 'folders and files' 123 | } 124 | 125 | DEFAULT_SETTINGS = { 126 | 'min': 1, 127 | 'scope': SCOPE_FOLDERS 128 | } 129 | 130 | YPOS_START = 1360 131 | YSIZE = 135 132 | 133 | 134 | SCRIPT_SEARCH = re.compile(r"""python ff.py search ".+?" (\d+)""").search 135 | 136 | 137 | def search_in(root, query, scope): 138 | """Search for files under `root` matching `query`. 139 | 140 | If `dirs_only` is True, only search for directories. 141 | 142 | """ 143 | cmd = ['mdfind', '-onlyin', root] 144 | query = ["(kMDItemFSName == '*{}*'c)".format(query)] 145 | if scope == SCOPE_FOLDERS: 146 | query.append("(kMDItemContentType == 'public.folder')") 147 | elif scope == SCOPE_FILES: 148 | query.append("(kMDItemContentType != 'public.folder')") 149 | 150 | cmd.append(' && '.join(query)) 151 | log.debug(cmd) 152 | output = subprocess.check_output(cmd).decode('utf-8') 153 | output = unicodedata.normalize('NFC', output) 154 | paths = [s.strip() for s in output.split('\n') if s.strip()] 155 | log.debug('%d hits from Spotlight index', len(paths)) 156 | return paths 157 | 158 | 159 | def filter_excludes(paths, root, patterns): 160 | """Return subset of `paths` not matching patterns. 161 | 162 | `root` is the root fuzzy folder. It's removed from paths before 163 | matching. 164 | 165 | """ 166 | log.debug('exclude patterns: %r', patterns) 167 | hits = [] 168 | for path in paths: 169 | search_path = path.replace(root, '') 170 | for pat in patterns: 171 | match = False 172 | if fnmatch(search_path, pat): 173 | log.debug('match: %r --> %r', pat, search_path) 174 | match = True 175 | break 176 | if match: 177 | continue 178 | else: 179 | hits.append(path) 180 | log.debug('%d/%d after blacklist filtering', len(hits), len(paths)) 181 | return hits 182 | 183 | 184 | def filter_paths(queries, paths, root): 185 | """Return subset of `paths` that match `queries`. 186 | 187 | Matching `paths` are those whose path segments contain the elements 188 | in ``queries` in the same order. Case-insensitive. 189 | 190 | """ 191 | hits = set() 192 | queries = [q.lower() for q in queries] 193 | for i, p in enumerate(paths): 194 | # Split path into lower-case components, 195 | # removing the last one (matched by Spotlight) 196 | components = p.replace(root, '').lower().split('/')[:-1] 197 | matches = 0 198 | for q in queries: 199 | for j, s in enumerate(components): 200 | if q in s: 201 | log.debug('%r in %r', q, components) 202 | matches += 1 203 | components = components[j:] 204 | break 205 | if matches == len(queries): 206 | log.debug('match: %r --> %r', queries, p) 207 | hits.add(i) 208 | log.debug('%d/%d after filtering', len(hits), len(paths)) 209 | return [p for i, p in enumerate(paths) if i in hits] 210 | 211 | 212 | class Dirpath(unicode): 213 | """Helper for formatting directory paths.""" 214 | 215 | @classmethod 216 | def dirpath(cls, path): 217 | """Create a new `Dirpath`.""" 218 | return Dirpath(os.path.abspath(os.path.expanduser(path))) 219 | 220 | @property 221 | def abs_slash(self): 222 | """Return absolute path with trailing slash.""" 223 | p = os.path.abspath(self) 224 | if not p.endswith('/'): 225 | return p + '/' 226 | return p 227 | 228 | @property 229 | def abs_noslash(self): 230 | """Return absolute path with no trailing slash.""" 231 | p = os.path.abspath(self) 232 | if p.endswith('/') and p not in ('/', '~/'): 233 | return p[:-1] 234 | return p 235 | 236 | @property 237 | def abbr_slash(self): 238 | """Return abbreviated path with trailing slash.""" 239 | p = self.abs_slash.replace(os.path.expanduser('~/'), '~/') 240 | if not p.endswith('/'): 241 | return p + '/' 242 | return p 243 | 244 | @property 245 | def abbr_noslash(self): 246 | """Return abbreviated path with no trailing slash.""" 247 | p = self.abs_slash.replace(os.path.expanduser('~/'), '~/') 248 | if p.endswith('/') and p not in ('/', '~/'): 249 | return p[:-1] 250 | return p 251 | 252 | def splitquery(self): 253 | """Split into dirpath and query.""" 254 | if not os.path.exists(self.abs_slash): 255 | pos = self.abs_noslash.rfind('/') 256 | if pos > -1: # query 257 | if pos == 0: 258 | dirpath = Dirpath.dirpath('/') 259 | else: 260 | dirpath = Dirpath.dirpath(self[:pos]) 261 | 262 | query = self[pos+1:] 263 | log.debug('dirpath=%r, query=%r', dirpath, query) 264 | return dirpath, query 265 | 266 | return self, '' 267 | 268 | 269 | class FuzzyFolders(object): 270 | """Application controller.""" 271 | 272 | def __init__(self, wf): 273 | """Create a new `FuzzyFolder` object.""" 274 | self.wf = wf 275 | self.dirpath = None 276 | self.query = None 277 | 278 | def run(self, args): 279 | """Run the workflow/application.""" 280 | # install default settings if there are none 281 | if 'defaults' not in self.wf.settings: 282 | self.wf.settings['defaults'] = DEFAULT_SETTINGS 283 | 284 | if args['']: 285 | self.dirpath = Dirpath.dirpath(args['']) 286 | self.query = args[''] 287 | self.profile = args[''] 288 | log.debug('dirpath=%r, query=%r', self.dirpath, self.query) 289 | 290 | actions = ('choose', 'add', 'remove', 'search', 'keyword', 291 | 'update', 'manage', 'load-profile', 'alfred-search', 292 | 'alfred-browse', 'load-settings', 'update-setting', 293 | 'settings', 'open-help') 294 | 295 | for action in actions: 296 | if args.get(action): 297 | methname = 'do_{}'.format(action.replace('-', '_')) 298 | meth = getattr(self, methname, None) 299 | if meth: 300 | return meth() 301 | else: 302 | break 303 | 304 | raise ValueError('Unknown action : {}'.format(action)) 305 | 306 | def do_choose(self): 307 | """Show a list of subdirectories of ``self.dirpath`` to choose from.""" 308 | dirpath, query = self.dirpath.splitquery() 309 | log.debug('dirpath=%r, query=%r', dirpath, query) 310 | if not os.path.exists(dirpath) or not os.path.isdir(dirpath): 311 | log.debug('does not exist/not a directory: %r', dirpath) 312 | return 0 313 | 314 | if not query: 315 | self.wf.add_item( 316 | dirpath.abbr_noslash, 317 | 'Add {} as a new Fuzzy Folder'.format(dirpath.abbr_noslash), 318 | arg=dirpath.abs_slash, 319 | autocomplete=dirpath.abbr_slash, 320 | valid=True, 321 | icon=dirpath.abs_noslash, 322 | icontype='fileicon', 323 | type='file') 324 | 325 | files = [] 326 | for filename in os.listdir(dirpath): 327 | p = os.path.join(dirpath, filename) 328 | if os.path.isdir(p) and not filename.startswith('.'): 329 | files.append((filename, p)) 330 | 331 | log.debug('%d folder(s) in %r', len(files), dirpath) 332 | if files and query: 333 | log.debug('filtering %d files against %r'. len(files), query) 334 | files = self.wf.filter(query, files, key=lambda x: x[0]) 335 | 336 | for filename, p in files: 337 | p = Dirpath.dirpath(p) 338 | self.wf.add_item( 339 | filename, 340 | 'Add {} as a new Fuzzy Folder'.format(p.abbr_noslash), 341 | arg=p.abs_noslash, 342 | autocomplete=p.abbr_slash, 343 | valid=True, 344 | icon=p.abs_noslash, 345 | icontype='fileicon', 346 | type='file') 347 | 348 | self.wf.send_feedback() 349 | 350 | def do_add(self): 351 | """Tell Alfred to ask for ``keyword``.""" 352 | return run_trigger('key', arg='{} {} '.format(self.dirpath.abbr_noslash, DELIMITER)) 353 | # return run_alfred(':fzykey {} {} '.format(self.dirpath.abbr_noslash, DELIMITER)) 354 | 355 | def do_remove(self): 356 | """Remove existing folder.""" 357 | profiles = self.wf.settings.get('profiles', {}) 358 | if self.profile in profiles: 359 | log.debug('Removing profile %r ...', self.profile) 360 | del profiles[self.profile] 361 | self.wf.settings['profiles'] = profiles 362 | self._update_script_filters() 363 | print('Deleted keyword / Fuzzy Folder') 364 | else: 365 | log.debug('No such profile: %r', self.profile) 366 | print('No such keyword / Fuzzy Folder') 367 | 368 | def do_search(self): 369 | """Search Fuzzy Folder.""" 370 | if not self.profile: 371 | return self.do_ad_hoc_search() 372 | profile = self.wf.settings.get('profiles', {}).get(self.profile) 373 | if not profile: 374 | log.debug('Profile not found: %r', self.profile) 375 | return 1 376 | 377 | root = profile['dirpath'] 378 | 379 | scope = profile.get('scope', self.wf.settings.get('defaults', 380 | {}).get('scope', SCOPE_FOLDERS)) 381 | 382 | min_query = profile.get('min', self.wf.settings.get('defaults', 383 | {}).get('min', 1)) 384 | excludes = self.wf.settings.get('defaults', {}).get('excludes', []) 385 | excludes += profile.get('excludes', []) 386 | 387 | return self._search(root, self.query, scope, min_query, excludes) 388 | 389 | def do_ad_hoc_search(self): 390 | """Search in directory not stored in a profile.""" 391 | if DELIMITER not in self.query: # bounce path back to Alfred 392 | log.debug('No delimiter found') 393 | search_in_alfred(self.query) 394 | # run_alfred(self.query) 395 | # run_alfred(':fzychs {}'.format(Dirpath.dirpath( 396 | # self.query.strip()).abbr_slash)) 397 | return 0 398 | root, query = self._parse_query(self.query) 399 | log.debug('root=%r, query=%r', root, query) 400 | 401 | scope = self.wf.settings.get('defaults', {}).get('scope', 402 | SCOPE_FOLDERS) 403 | 404 | min_query = self.wf.settings.get('defaults', {}).get('min', 1) 405 | excludes = self.wf.settings.get('defaults', {}).get('excludes', []) 406 | 407 | return self._search(root, query, scope, min_query, excludes) 408 | 409 | def _search(self, root, query, scope, min_query, excludes): 410 | """Perform search and display results.""" 411 | query = query.split() 412 | 413 | if len(query) > 1: 414 | mdquery = query[-1] 415 | query = query[:-1] 416 | elif len(query): 417 | mdquery = query[0] 418 | query = None 419 | else: 420 | mdquery = '' 421 | query = None 422 | 423 | log.debug('mdquery=%r, query=%r, scope=%r', mdquery, query, scope) 424 | 425 | if len(mdquery) < min_query or not mdquery: 426 | self.wf.add_item('Query too short', 427 | 'minimum length is {}'.format(min_query), 428 | valid=False, 429 | icon=ICON_WARNING) 430 | self.wf.send_feedback() 431 | log.debug('Query too short [min=%d]: %r', min_query, mdquery) 432 | return 0 433 | 434 | paths = search_in(root, mdquery, scope) 435 | 436 | if excludes: 437 | paths = filter_excludes(paths, root, excludes) 438 | 439 | if query: 440 | paths = filter_paths(query, paths, root) 441 | 442 | home = os.path.expanduser('~/') 443 | 444 | if not len(paths): 445 | self.wf.add_item('No results found', 446 | 'Try a different query', 447 | valid=False, 448 | icon=ICON_WARNING) 449 | 450 | for path in paths: 451 | filename = os.path.basename(path) 452 | wf.add_item(filename, path.replace(home, '~/'), 453 | valid=True, arg=path, 454 | uid=path, type='file', 455 | icon=path, icontype='fileicon') 456 | 457 | wf.send_feedback() 458 | log.debug('finished search') 459 | return 0 460 | 461 | def do_load_profile(self): 462 | """Load the corresponding profile in Alfred.""" 463 | if self.profile == '0': 464 | return run_trigger('fuzzy-folders') 465 | profile = self.wf.settings.get('profiles', {}).get(self.profile) 466 | log.debug('loading profile %r ...', profile) 467 | return search_in_alfred(profile['keyword'] + ' ') 468 | 469 | def do_manage(self): 470 | """Show list of existing profiles.""" 471 | if wf.update_available: 472 | wf.add_item('A newer version of Fuzzy Folders is available', 473 | '↩ or ⇥ to update.', 474 | autocomplete='workflow:update', 475 | valid=False, 476 | icon=ICON_SYNC) 477 | 478 | profiles = self.wf.settings.get('profiles', {}) 479 | 480 | if self.query: 481 | items = profiles.items() 482 | log.debug('items: %r', items) 483 | items = self.wf.filter(self.query, 484 | items, 485 | key=lambda t: '{} {}'.format(t[1]['keyword'], t[1]['dirpath']), 486 | match_on=MATCH_ALL ^ MATCH_ALLCHARS) 487 | profiles = dict(items) 488 | 489 | self.wf.add_item('Default Fuzzy Folder settings', 490 | 'View / change settings', 491 | valid=True, 492 | arg="0", 493 | icon=ICON_SETTINGS) 494 | 495 | if not profiles: 496 | self.wf.add_item( 497 | 'No Fuzzy Folders defined', 498 | "Use the 'Add Fuzzy Folder' File Action to add some", 499 | valid=False, 500 | icon=ICON_WARNING) 501 | 502 | for num, profile in profiles.items(): 503 | self.wf.add_item('{} {} {}'.format(profile['keyword'], DELIMITER, 504 | Dirpath.dirpath(profile['dirpath']).abbr_noslash), 505 | 'View / change settings', 506 | valid=True, 507 | arg=num, 508 | autocomplete=profile['keyword'], 509 | icon='icon.png') 510 | 511 | self.wf.send_feedback() 512 | 513 | def do_keyword(self): 514 | """Choose keyword for folder in Alfred.""" 515 | dirpath, keyword = self._parse_query(self.query) 516 | log.debug('dirpath=%r, keyword=%r', dirpath, keyword) 517 | 518 | # check for existing configurations for this dirpath and keyword 519 | profiles = [] 520 | profile_exists = False 521 | keyword_warnings = [] 522 | dirpath_warnings = [] 523 | for profile in self.wf.settings.get('profiles', {}).values(): 524 | profiles.append((profile['keyword'], profile['dirpath'])) 525 | 526 | if (keyword, dirpath.abs_noslash) in profiles: 527 | profile_exists = True 528 | 529 | for k, p in profiles: 530 | if keyword == k: 531 | keyword_warnings.append(u"'{}' searches {}".format( 532 | k, Dirpath.dirpath(p).abbr_noslash)) 533 | elif dirpath.abs_noslash == p: 534 | dirpath_warnings.append(u"Folder already linked to '{}'".format(k)) 535 | 536 | if self.query.endswith(DELIMITER): # user has deleted trailing space 537 | # back up the file tree 538 | return run_trigger('choose-folder', 539 | arg=Dirpath.dirpath(os.path.dirname(dirpath)).abbr_slash) 540 | # return run_alfred(':fzychs {}'.format( 541 | # Dirpath.dirpath(os.path.dirname(dirpath)).abbr_slash)) 542 | # return self.do_add() 543 | elif keyword == '': # no keyword as yet 544 | if not keyword: 545 | self.wf.add_item('Enter a keyword for the Folder', 546 | dirpath, 547 | valid=False, 548 | icon=ICON_NOTE) 549 | for warning in dirpath_warnings: 550 | self.wf.add_item( 551 | warning, 552 | 'But you can set multiple keywords per folders', 553 | valid=False, 554 | icon=ICON_INFO) 555 | self.wf.send_feedback() 556 | return 0 557 | else: # offer to set keyword 558 | if profile_exists: 559 | self.wf.add_item( 560 | 'This keyword > Fuzzy Folder already exists', 561 | u"'{}' already linked to {}".format( 562 | keyword, 563 | dirpath.abbr_noslash), 564 | valid=False, 565 | icon=ICON_WARNING) 566 | else: 567 | self.wf.add_item(u"Set '{}' as keyword for {}".format( 568 | keyword, dirpath.abbr_noslash), 569 | dirpath, 570 | arg='{} {} {}'.format(dirpath, DELIMITER, keyword), 571 | valid=True, 572 | icon='icon.png') 573 | for warning in dirpath_warnings: 574 | self.wf.add_item( 575 | warning, 576 | 'But you can set multiple keywords per folders', 577 | valid=False, 578 | icon=ICON_INFO) 579 | for warning in keyword_warnings: 580 | self.wf.add_item( 581 | warning, 582 | 'But you can use the same keyword for multiple folders', 583 | valid=False, 584 | icon=ICON_INFO) 585 | self.wf.send_feedback() 586 | 587 | def do_load_settings(self): 588 | """Tell Alfred to load profile settings.""" 589 | return run_trigger('set', arg=self.profile) 590 | # return run_alfred(':fzyset {}'.format(self.profile)) 591 | 592 | def do_settings(self): 593 | """Show file/folder support, min query length for folder.""" 594 | defaults = self.wf.settings.get('defaults', {}) 595 | profile, setting, value = self._parse_settings(self.query) 596 | 597 | if self.query.endswith(DELIMITER): # trailing space deleted; back up 598 | return run_trigger('set', arg=profile) 599 | # return run_alfred(':fzyset {}'.format(profile)) 600 | 601 | if not profile: 602 | return run_trigger('search') 603 | # return run_alfred('fuzzy ') 604 | # self.wf.add_item('No Fuzzy Folder specified', 605 | # valid=False, 606 | # icon=ICON_ERROR) 607 | # self.wf.send_feedback() 608 | # return 0 609 | 610 | if profile == '0': # default settings 611 | conf = defaults.copy() 612 | else: 613 | conf = self.wf.settings.get('profiles', {}).get(profile) 614 | log.debug('conf: %r', conf) 615 | 616 | if not setting: 617 | 618 | kw = conf.get('keyword', 'Fuzzy Folder Defaults') 619 | path = conf.get('dirpath', 620 | # shown for default settings 621 | 'Overridden by Folder-specific settings') 622 | self.wf.add_item(kw, 623 | path, 624 | valid=False, 625 | icon='icon.png') 626 | 627 | # Show action to update setting 628 | if value is not None: 629 | valuestr = '' 630 | if value == 0: 631 | valuestr = 'default' 632 | if setting == 'min': 633 | name = 'minimum query length' 634 | if not valuestr: 635 | valuestr = unicode(value) 636 | elif setting == 'scope': 637 | name = 'search scope' 638 | if not valuestr: 639 | valuestr = SCOPE_NAMES[value] 640 | arg = '{} {} min {} {}'.format(profile, DELIMITER, DELIMITER, 641 | value) 642 | self.wf.add_item('Set {} to {}'.format(name, valuestr), 643 | '↩ to update', 644 | valid=True, 645 | arg=arg, 646 | icon=ICON_SETTINGS) 647 | self.wf.send_feedback() 648 | return 0 649 | 650 | # Show setting options/ask for query 651 | elif setting: 652 | if setting == 'min': 653 | self.wf.add_item('Enter a minimum query length', 654 | 'Enter 0 to use default', 655 | valid=False, 656 | icon=ICON_INFO) 657 | self.wf.send_feedback() 658 | return 0 659 | elif setting == 'scope': 660 | arg = '{} {} scope {} {}'.format(profile, DELIMITER, DELIMITER, SCOPE_FOLDERS) 661 | self.wf.add_item('Folders only', 662 | 'Only search for folders', 663 | arg=arg, 664 | valid=True, 665 | icon=ICON_SETTINGS) 666 | arg = '{} {} scope {} {}'.format(profile, DELIMITER, DELIMITER, SCOPE_FILES) 667 | self.wf.add_item('Files only', 668 | 'Only search for files', 669 | arg=arg, 670 | valid=True, 671 | icon=ICON_SETTINGS) 672 | arg = '{} {} scope {} {}'.format(profile, DELIMITER, DELIMITER, SCOPE_ALL) 673 | self.wf.add_item('Folders and files', 674 | 'Search for folders and files', 675 | arg=arg, 676 | valid=True, 677 | icon=ICON_SETTINGS) 678 | arg = '{} {} scope {} 0'.format(profile, DELIMITER, DELIMITER) 679 | self.wf.add_item('Default', 680 | 'Use default setting', 681 | arg=arg, 682 | valid=True, 683 | icon=ICON_SETTINGS) 684 | self.wf.send_feedback() 685 | 686 | else: 687 | self.wf.add_item('Unknown setting : {}'.format(setting), 688 | 'Hit ⌫ to choose again', 689 | valid=False, 690 | icon=ICON_ERROR) 691 | self.wf.send_feedback() 692 | return 0 693 | 694 | # Show available settings 695 | else: 696 | if 'min' in conf: 697 | value = conf['min'] 698 | else: 699 | value = 'default' 700 | arg = '{} {} min {} '.format(profile, DELIMITER, DELIMITER) 701 | self.wf.add_item( 702 | 'Minimum query length : {}'.format(value), 703 | 'The last part of your query must be this long to trigger a search', 704 | valid=False, 705 | arg=arg, 706 | autocomplete=arg, 707 | icon=ICON_SETTINGS) 708 | 709 | if 'scope' in conf: 710 | value = SCOPE_NAMES[conf['scope']] 711 | else: 712 | value = 'default' 713 | arg = '{} {} scope {} '.format(profile, DELIMITER, DELIMITER) 714 | self.wf.add_item( 715 | 'Search scope : {}'.format(value), 716 | 'Should results be folders and/or files?', 717 | valid=False, 718 | arg=arg, 719 | autocomplete=arg, 720 | icon=ICON_SETTINGS) 721 | 722 | self.wf.send_feedback() 723 | return 0 724 | 725 | def do_update_setting(self): 726 | """Update profile/default settings from ``query``.""" 727 | profile, setting, value = self._parse_settings(self.query) 728 | log.debug('setting %s/%s to %r', profile, setting, value) 729 | 730 | if setting not in ('min', 'scope'): 731 | log.error('Invalid setting: %s', setting) 732 | print('Invalid setting: %s', setting) 733 | return 0 734 | 735 | if profile == '0': 736 | self.wf.settings['defaults'][setting] = value 737 | else: 738 | if value == 0: # reset to default 739 | if setting in self.wf.settings['profiles'][profile]: 740 | del self.wf.settings['profiles'][profile][setting] 741 | else: 742 | self.wf.settings['profiles'][profile][setting] = value 743 | 744 | self.wf.settings.save() 745 | 746 | if value == 0: 747 | value = 'default' 748 | elif setting == 'scope': 749 | value = SCOPE_NAMES[value] 750 | 751 | setting = {'min': 'minimum query length', 752 | 'scope': 'search scope'}.get(setting) 753 | 754 | print('{} set to {}'.format(setting, value)) 755 | return 0 756 | 757 | def do_update(self): 758 | """Save new/updated Script Filter to info.plist.""" 759 | if not self.query: # Just do an update 760 | self._update_script_filters() 761 | reload_workflow() 762 | return 0 763 | 764 | dirpath, keyword = self._parse_query(self.query) 765 | log.debug('dirpath=%r, keyword=%r', dirpath, keyword) 766 | profiles = self.wf.settings.setdefault('profiles', {}) 767 | log.debug('profiles: %r', profiles) 768 | if not profiles: 769 | last = 0 770 | else: 771 | last = max([int(s) for s in profiles.keys()]) 772 | log.debug('Last profile: %d', last) 773 | profile = dict(keyword=keyword, dirpath=dirpath, excludes=[]) 774 | profiles[unicode(last + 1)] = profile # JSON requires string keys 775 | self.wf.settings['profiles'] = profiles 776 | self._update_script_filters() 777 | print(u"Keyword '{}' searches {}".format(keyword, Dirpath.dirpath(dirpath).abbr_noslash)) 778 | reload_workflow() 779 | 780 | def do_alfred_search(self): 781 | """Initiate an ad-hoc search in Alfred.""" 782 | dirpath = Dirpath.dirpath(self.query).abbr_noslash 783 | return run_trigger('search', arg='{} {} '.format(dirpath, DELIMITER)) 784 | # return run_alfred(':fzysrch {} {} '.format(dirpath, DELIMITER)) 785 | 786 | # def do_alfred_browse(self): 787 | # """Open directory in Alfred.""" 788 | # return browse_in_alfred(self.dirpath) 789 | # # return run_alfred(self.dirpath) 790 | 791 | def do_open_help(self): 792 | """Open help file in browser.""" 793 | return subprocess.call(['open', self.wf.workflowfile('README.html')]) 794 | 795 | def _update_script_filters(self): 796 | """Create / update Script Filters in info.plist to match settings.""" 797 | plistpath = self.wf.workflowfile('info.plist') 798 | plisttemp = self.wf.workflowfile('info.plist.temp') 799 | 800 | profiles = self.wf.settings.get('profiles', {}) 801 | 802 | self._reset_script_filters() 803 | 804 | plist = readPlist(plistpath) 805 | objects = plist['objects'] 806 | uidata = plist['uidata'] 807 | connections = plist['connections'] 808 | 809 | y_pos = YPOS_START 810 | for num, profile in profiles.items(): 811 | uid = unicode(uuid.uuid4()).upper() 812 | dirname = Dirpath.dirpath(profile['dirpath']).abbr_noslash 813 | script_filter = { 814 | 'type': 'alfred.workflow.input.scriptfilter', 815 | 'uid': uid, 816 | 'version': 0 817 | } 818 | config = { 819 | 'argumenttype': 0, 820 | 'escaping': 102, 821 | 'keyword': profile['keyword'], 822 | 'runningsubtext': 'Loading files\u2026', 823 | 'queuedelaycustom': 3, # Auto delay after keypress 824 | 'script': 'python ff.py search "$1" {}'.format(num), 825 | 'subtext': 'Fuzzy search across subdirectories of {}'.format( 826 | dirname), 827 | 'title': 'Fuzzy Search {}'.format(dirname), 828 | 'scriptargtype': 1, 829 | 'type': 0, 830 | 'withspace': True 831 | } 832 | script_filter['config'] = config 833 | objects.append(script_filter) 834 | # set position 835 | uidata[uid] = {'ypos': float(y_pos)} 836 | y_pos += YSIZE 837 | # add connection to Browse in Alfred action 838 | connections[uid] = ACTIONS 839 | 840 | plist['objects'] = objects 841 | plist['uidata'] = uidata 842 | plist['connections'] = connections 843 | 844 | writePlist(plist, plisttemp) 845 | os.unlink(plistpath) 846 | os.rename(plisttemp, plistpath) 847 | os.utime(plistpath, None) 848 | 849 | log.debug('Wrote %d Script Filters to info.plist', len(profiles)) 850 | 851 | # def _dirpath_abbr(self, dirpath=None): 852 | # """Return attr:`~FuzzyFolders.dirpath` with ``$HOME`` replaced 853 | # with ``~/`` 854 | 855 | # """ 856 | 857 | # if not dirpath: 858 | # dirpath = self.dirpath 859 | # if not dirpath.endswith('/'): 860 | # dirpath += '/' 861 | # dirpath = dirpath.replace(os.path.expanduser('~/'), '~/') 862 | # if dirpath.endswith('/') and dirpath not in ('/', '~/'): 863 | # dirpath = dirpath[:-1] 864 | # return dirpath 865 | 866 | def _parse_query(self, query): 867 | """Split ``query`` into ``dirpath`` and ``query``. 868 | 869 | :returns: ``(dirpath, query)`` where either may be empty 870 | 871 | """ 872 | components = query.split(DELIMITER) 873 | if not len(components) == 2: 874 | raise ValueError('Too many components in : {!r}'.format(query)) 875 | dirpath, query = [s.strip() for s in components] 876 | dirpath = Dirpath.dirpath(dirpath) 877 | return (dirpath, query) 878 | 879 | def _parse_settings(self, query): 880 | """Split ``query`` into ``profile``, ``setting`` and ``value``.""" 881 | profile = setting = value = None 882 | components = [s.strip() for s in query.split(DELIMITER)] 883 | log.debug('components: %r', components) 884 | profile = components[0] 885 | if len(components) > 1 and components[1]: 886 | setting = components[1] 887 | if len(components) > 2 and components[2]: 888 | value = int(components[2]) 889 | log.debug('profile=%, setting=%r, value=%r', profile, setting, value) 890 | return (profile, setting, value) 891 | 892 | def _reset_script_filters(self): 893 | """Load script filters from `info.plist`.""" 894 | plistpath = self.wf.workflowfile('info.plist') 895 | 896 | # backup info.plist 897 | with open(plistpath, 'rb') as infile: 898 | with open(self.wf.workflowfile('info.plist.bak'), 'wb') as outfile: 899 | outfile.write(infile.read()) 900 | 901 | script_filters = {} 902 | plist = readPlist(plistpath) 903 | 904 | count = 0 905 | keep = [] 906 | uids = set() 907 | for obj in plist['objects']: 908 | if obj.get('type') != 'alfred.workflow.input.scriptfilter': 909 | keep.append(obj) 910 | continue 911 | if obj.get('keyword') in RESERVED_KEYWORDS: 912 | keep.append(obj) 913 | continue 914 | 915 | script = obj.get('config', {}).get('script', '') 916 | log.debug('script: %r', script) 917 | m = SCRIPT_SEARCH(script) 918 | if not m: 919 | keep.append(obj) 920 | continue 921 | 922 | count += 1 923 | uids.add(obj['uid']) 924 | 925 | # Overwrite objects minus script filters 926 | plist['objects'] = keep 927 | 928 | # Delete positioning data 929 | keep = {} 930 | uidata = plist['uidata'] 931 | for uid in uidata: 932 | if uid not in uids: 933 | keep[uid] = uidata[uid] 934 | 935 | # Overwrite without script filter positions 936 | plist['uidata'] = keep 937 | 938 | # Remove connections 939 | keep = {} 940 | connections = plist['connections'] 941 | for uid in connections: 942 | if uid not in uids: 943 | keep[uid] = connections[uid] 944 | 945 | # Overwrite without script filter connections 946 | plist['connections'] = keep 947 | 948 | # Re-write info.plist without script filters 949 | 950 | writePlist(plist, plistpath) 951 | 952 | log.debug('%d Script Filters deleted from info.plist', count) 953 | return script_filters 954 | 955 | 956 | def main(wf): 957 | """Run workflow.""" 958 | args = docopt(__doc__, argv=wf.args, version=wf.version) 959 | log.debug('args: %r', args) 960 | ff = FuzzyFolders(wf) 961 | return ff.run(args) 962 | 963 | 964 | if __name__ == '__main__': 965 | wf = Workflow(update_settings={'github_slug': 'deanishe/alfred-fuzzyfolders'}) 966 | log = wf.logger 967 | sys.exit(wf.run(main)) 968 | --------------------------------------------------------------------------------