├── README.md ├── Snippets.alfredworkflow ├── snippets.gif └── source ├── icon.png ├── info.plist ├── snippets.py └── workflow ├── __init__.py └── workflow.py /README.md: -------------------------------------------------------------------------------- 1 | alfred_snippets 2 | =============== 3 | 4 | Simple, document-specific text snippets 5 | 6 | 7 | ### Version: 1.0 8 | 9 | Download on Packal 10 | 11 | ![demo](snippets.gif) 12 | 13 | 14 | Have you ever been taking notes and realized that certain terms or phrases were going to be used repeatedly? You don't have the time or really the desire to create all new TextExpander snippets for these terms or phrases, but you'd also really like to shorten your typing. That's where `Snippets` comes in. `Snippets` is a dead simple Alfred workflow that allows you to use simple snippet syntax while writing, and then seamlessly convert your text to its full glory. 15 | 16 | The set-up is simple. As you're typing, and you realize you want to make a snippet, simply prepend your snippet with `,,` (comma comma). Then, when you get a free moment, create a "snippet dictionary" to tell Snippets what that snippet means. To create the dictionary, simply wrap it in `^^^` (triple carets). Here's an example: 17 | ``` 18 | This is an example of ,,sn. ,,sn is a fantastic workflow for ,,a! 19 | 20 | ^^^ 21 | sn: `Snippets` 22 | a: Alfred 23 | ^^^ 24 | ``` 25 | That's all there is to it. Once your dictionary is complete and you have finished typing, either copy the text to the clipboard and use the keyword `snip`, or assign a keyboard shortcut for even quicker results. When you activate `Snippets`, the text above will instantly become: 26 | ``` 27 | This is an example of `Snippets`. `Snippets` is a fantastic workflow for Alfred! 28 | ``` 29 | It's so simple. Double-comma before the snippet; dictionary wrapped in triple-carets with snippet: expanded. Nothing more, nothing less. 30 | 31 | Hope this helps, 32 | stephen 33 | -------------------------------------------------------------------------------- /Snippets.alfredworkflow: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/alfred_snippets/793a4fe1eb1629a3f949833ec00b26236830e064/Snippets.alfredworkflow -------------------------------------------------------------------------------- /snippets.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/alfred_snippets/793a4fe1eb1629a3f949833ec00b26236830e064/snippets.gif -------------------------------------------------------------------------------- /source/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/alfred_snippets/793a4fe1eb1629a3f949833ec00b26236830e064/source/icon.png -------------------------------------------------------------------------------- /source/info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | com.hackademic.snippets 7 | connections 8 | 9 | 795EBA38-D286-4C40-8DEC-9C92AAEF33B6 10 | 11 | 12 | destinationuid 13 | C919C3EC-CEDF-4E91-A304-1AA3CBF908AC 14 | modifiers 15 | 0 16 | modifiersubtext 17 | 18 | 19 | 20 | B1259DC8-E4F9-4EBE-B6F3-80F71973297F 21 | 22 | 23 | destinationuid 24 | C919C3EC-CEDF-4E91-A304-1AA3CBF908AC 25 | modifiers 26 | 0 27 | modifiersubtext 28 | 29 | 30 | 31 | C919C3EC-CEDF-4E91-A304-1AA3CBF908AC 32 | 33 | 34 | destinationuid 35 | F64E4EDF-406D-4DCA-BB3F-74542EFA5F98 36 | modifiers 37 | 0 38 | modifiersubtext 39 | 40 | 41 | 42 | 43 | createdby 44 | Stephen Margheim 45 | description 46 | Simple, document-specific text snippets (v. 1.0) 47 | disabled 48 | 49 | name 50 | Snippets 51 | objects 52 | 53 | 54 | config 55 | 56 | escaping 57 | 102 58 | script 59 | python snippets.py "{query}" 60 | type 61 | 0 62 | 63 | type 64 | alfred.workflow.action.script 65 | uid 66 | C919C3EC-CEDF-4E91-A304-1AA3CBF908AC 67 | version 68 | 0 69 | 70 | 71 | config 72 | 73 | action 74 | 0 75 | argument 76 | 1 77 | hotkey 78 | 18 79 | hotmod 80 | 1179648 81 | hotstring 82 | 1 83 | leftcursor 84 | 85 | modsmode 86 | 0 87 | relatedAppsMode 88 | 0 89 | 90 | type 91 | alfred.workflow.trigger.hotkey 92 | uid 93 | B1259DC8-E4F9-4EBE-B6F3-80F71973297F 94 | version 95 | 1 96 | 97 | 98 | config 99 | 100 | autopaste 101 | 102 | clipboardtext 103 | {query} 104 | 105 | type 106 | alfred.workflow.output.clipboard 107 | uid 108 | F64E4EDF-406D-4DCA-BB3F-74542EFA5F98 109 | version 110 | 0 111 | 112 | 113 | config 114 | 115 | argumenttype 116 | 2 117 | keyword 118 | snip 119 | subtext 120 | Ensure text is copied to Clipboard first! 121 | text 122 | Expand in-text snippets? 123 | withspace 124 | 125 | 126 | type 127 | alfred.workflow.input.keyword 128 | uid 129 | 795EBA38-D286-4C40-8DEC-9C92AAEF33B6 130 | version 131 | 0 132 | 133 | 134 | readme 135 | 136 | uidata 137 | 138 | 795EBA38-D286-4C40-8DEC-9C92AAEF33B6 139 | 140 | ypos 141 | 60 142 | 143 | B1259DC8-E4F9-4EBE-B6F3-80F71973297F 144 | 145 | ypos 146 | 10 147 | 148 | C919C3EC-CEDF-4E91-A304-1AA3CBF908AC 149 | 150 | ypos 151 | 10 152 | 153 | F64E4EDF-406D-4DCA-BB3F-74542EFA5F98 154 | 155 | ypos 156 | 10 157 | 158 | 159 | webaddress 160 | hackademic.postach.io 161 | 162 | 163 | -------------------------------------------------------------------------------- /source/snippets.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # encoding: utf-8 3 | from __future__ import unicode_literals 4 | 5 | import re 6 | import os 7 | import sys 8 | import subprocess 9 | from workflow import Workflow 10 | 11 | PREFIX = ',,' 12 | 13 | def to_unicode(obj, encoding='utf-8'): 14 | """Ensure passed text is Unicode""" 15 | if isinstance(obj, basestring): 16 | if not isinstance(obj, unicode): 17 | obj = unicode(obj, encoding) 18 | return obj 19 | 20 | def get_clipboard(): 21 | """Retrieve data from clipboard""" 22 | 23 | os.environ['__CF_USER_TEXT_ENCODING'] = "0x1F5:0x8000100:0x8000100" 24 | proc = subprocess.Popen(['pbpaste'], stdout=subprocess.PIPE) 25 | (stdout, stderr) = proc.communicate() 26 | return to_unicode(stdout) 27 | 28 | 29 | ################################################## 30 | ### Expand In-Text Snippets Functions 31 | ################################################## 32 | 33 | def _split_set(item, delim): 34 | """Split multi-line snippet dictionaries""" 35 | if delim in item: 36 | split = filter(None, item.split(delim)) 37 | return split 38 | else: 39 | return item 40 | 41 | def _snippet_dict(item, delim): 42 | """Create Python snippet dictionary""" 43 | _dict = {} 44 | if delim in item: 45 | sub_l = _split_set(item, delim) 46 | for sub in sub_l: 47 | [key, val] = sub.split(':') 48 | _dict[key.strip()] = val.lstrip() 49 | return _dict 50 | 51 | def expand_snippets(the_str): 52 | """Find, Expand, and Replace any in-text snippets""" 53 | _snippets = re.findall(r"\^{3}(.*?)\^{3}", the_str, flags=re.S) 54 | 55 | _dict = {} 56 | for i in _snippets: 57 | if '\r' in i: 58 | # If multi-line with ``carriage return`` 59 | _dict.update(_snippet_dict(i, '\r')) 60 | elif '\n' in i: 61 | # If multi-line with ``newline`` 62 | _dict.update(_snippet_dict(i, '\n')) 63 | else: 64 | # If single line 65 | [key, val] = i.split(':') 66 | _dict[key.strip()] = val.lstrip() 67 | 68 | # Find and replace all snippets with expanded text 69 | for key in sorted(_dict, key=len, reverse=True): 70 | new_key = PREFIX + key 71 | the_str = the_str.replace(new_key, _dict[key]) 72 | 73 | # Remove all snippet dictionaries from text 74 | the_str = re.sub(r"\^{3}(.*?)\^{3}", "", the_str, flags=re.S) 75 | return the_str 76 | 77 | 78 | def get_input(wf): 79 | """Get text input from Alfred""" 80 | if wf.args[0] == '': 81 | # Get input text from clipboard 82 | the_str = get_clipboard() 83 | else: 84 | the_str = wf.args[0] 85 | return to_unicode(the_str) 86 | 87 | def main(wf): 88 | # Get input text 89 | _str = get_input(wf) 90 | #_str = get_clipboard() 91 | clean_str = expand_snippets(_str) 92 | print clean_str.encode('utf-8') 93 | 94 | if __name__ == '__main__': 95 | WF = Workflow() 96 | sys.exit(WF.run(main)) 97 | -------------------------------------------------------------------------------- /source/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 | """ 12 | A Python helper library for `Alfred 2 `_ Workflow 13 | authors. 14 | 15 | Alfred Workflows typically take user input, fetch data from the Web or 16 | elsewhere, filter them and display results to the user. **Alfred-Workflow** 17 | helps you do these things. 18 | 19 | There are convenience methods for: 20 | 21 | - Parsing script arguments. 22 | - Text decoding/normalisation. 23 | - Caching data and settings. 24 | - Secure storage (and sync) of passwords (using OS X Keychain). 25 | - Generating XML output for Alfred. 26 | - Including external libraries (adding directories to ``sys.path``). 27 | - Filtering results using an Alfred-like algorithm. 28 | - Generating log output for debugging. 29 | - Capturing errors, so the workflow doesn't fail silently. 30 | 31 | Quick Example 32 | ============= 33 | 34 | Here's how to show recent `Pinboard.in `_ posts in Alfred. 35 | 36 | Create a new Workflow in Alfred's preferences. Add a **Script Filter** with 37 | Language ``/usr/bin/python`` and paste the following into the **Script** field 38 | (changing ``API_KEY``): 39 | 40 | .. code-block:: python 41 | :emphasize-lines: 4 42 | 43 | import sys 44 | from workflow import Workflow, ICON_WEB, web 45 | 46 | API_KEY = 'your-pinboard-api-key' 47 | 48 | def main(wf): 49 | url = 'https://api.pinboard.in/v1/posts/recent' 50 | params = dict(auth_token=API_KEY, count=20, format='json') 51 | r = web.get(url, params) 52 | r.raise_for_status() 53 | for post in r.json()['posts']: 54 | wf.add_item(post['description'], post['href'], arg=post['href'], 55 | uid=post['hash'], valid=True, icon=ICON_WEB) 56 | wf.send_feedback() 57 | 58 | 59 | if __name__ == u"__main__": 60 | wf = Workflow() 61 | sys.exit(wf.run(main)) 62 | 63 | 64 | Add an **Open URL** action to your Workflow with ``{query}`` as the **URL**, 65 | connect your **Script Filter** to it, and you can now hit **ENTER** on a 66 | Pinboard item in Alfred to open it in your browser. 67 | 68 | Installation 69 | ============ 70 | 71 | Download the ``alfred-workflow-X.X.zip`` file from the 72 | `GitHub releases page `_ 73 | and either extract the ZIP to the root directory of your workflow (where 74 | ``info.plist`` is) or place the ZIP in the root directory and add 75 | ``sys.path.insert(0, 'alfred-workflow-X.X.zip')`` to the top of your 76 | Python scripts. 77 | 78 | Alternatively, you can download 79 | `the source code `_ 80 | from the `GitHub repository `_ and 81 | copy the ``workflow`` subfolder to the root directory of your Workflow. 82 | 83 | Your Workflow directory should look something like this (where 84 | ``yourscript.py`` contains your Workflow code and ``info.plist`` is 85 | the Workflow information file generated by Alfred):: 86 | 87 | Your Workflow/ 88 | info.plist 89 | icon.png 90 | workflow/ 91 | __init__.py 92 | background.py 93 | workflow.py 94 | web.py 95 | yourscript.py 96 | etc. 97 | 98 | 99 | Or like this:: 100 | 101 | Your Workflow/ 102 | info.plist 103 | icon.png 104 | workflow-1.4.zip 105 | yourscript.py 106 | etc. 107 | 108 | 109 | """ 110 | 111 | __version__ = '1.6.2' 112 | 113 | from .workflow import Workflow, PasswordNotFound, KeychainError 114 | from .workflow import (ICON_ERROR, ICON_WARNING, ICON_NOTE, ICON_INFO, 115 | ICON_FAVORITE, ICON_FAVOURITE, ICON_USER, ICON_GROUP, 116 | ICON_HELP, ICON_NETWORK, ICON_WEB, ICON_COLOR, 117 | ICON_COLOUR, ICON_SYNC, ICON_SETTINGS, ICON_TRASH, 118 | ICON_MUSIC, ICON_BURN, ICON_ACCOUNT, ICON_ERROR) 119 | from .workflow import (MATCH_ALL, MATCH_ALLCHARS, MATCH_ATOM, 120 | MATCH_CAPITALS, MATCH_INITIALS, 121 | MATCH_INITIALS_CONTAIN, MATCH_INITIALS_STARTSWITH, 122 | MATCH_STARTSWITH, MATCH_SUBSTRING) 123 | -------------------------------------------------------------------------------- /source/workflow/workflow.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 | """ 11 | Helper library for Alfred 2 workflow authors. 12 | 13 | You probably only want to use the :class:`Workflow` class directly. 14 | 15 | The :class:`Item` and :class:`Settings` classes are supporting classes, 16 | which are meant to be accessed via :class:`Workflow` instances. 17 | 18 | """ 19 | 20 | from __future__ import print_function, unicode_literals 21 | 22 | import os 23 | import sys 24 | import string 25 | import re 26 | import plistlib 27 | import subprocess 28 | import unicodedata 29 | import shutil 30 | import json 31 | import pickle 32 | import time 33 | import logging 34 | import logging.handlers 35 | try: 36 | import xml.etree.cElementTree as ET 37 | except ImportError: # pragma: no cover 38 | import xml.etree.ElementTree as ET 39 | 40 | 41 | #################################################################### 42 | # Some standard system icons 43 | #################################################################### 44 | 45 | # Shown when a workflow throws an error 46 | ICON_ACCOUNT = ('/System/Library/CoreServices/CoreTypes.bundle/Contents' 47 | '/Resources/Accounts.icns') 48 | ICON_BURN = ('/System/Library/CoreServices/CoreTypes.bundle/Contents' 49 | '/Resources/BurningIcon.icns') 50 | ICON_COLOR = ('/System/Library/CoreServices/CoreTypes.bundle/Contents' 51 | '/Resources/ProfileBackgroundColor.icns') 52 | ICON_COLOUR = ICON_COLOR # Queen's English, if you please 53 | ICON_ERROR = ('/System/Library/CoreServices/CoreTypes.bundle/Contents' 54 | '/Resources/AlertStopIcon.icns') 55 | ICON_FAVORITE = ('/System/Library/CoreServices/CoreTypes.bundle/Contents' 56 | '/Resources/ToolbarFavoritesIcon.icns') 57 | ICON_FAVOURITE = ICON_FAVORITE 58 | ICON_GROUP = ('/System/Library/CoreServices/CoreTypes.bundle/Contents' 59 | '/Resources/GroupIcon.icns') 60 | ICON_HELP = ('/System/Library/CoreServices/CoreTypes.bundle/Contents' 61 | '/Resources/HelpIcon.icns') 62 | ICON_INFO = ('/System/Library/CoreServices/CoreTypes.bundle/Contents' 63 | '/Resources/ToolbarInfo.icns') 64 | ICON_MUSIC = ('/System/Library/CoreServices/CoreTypes.bundle/Contents' 65 | '/Resources/ToolbarMusicFolderIcon.icns') 66 | ICON_NETWORK = ('/System/Library/CoreServices/CoreTypes.bundle/Contents' 67 | '/Resources/GenericNetworkIcon.icns') 68 | ICON_NOTE = ('/System/Library/CoreServices/CoreTypes.bundle/Contents' 69 | '/Resources/AlertNoteIcon.icns') 70 | ICON_SETTINGS = ('/System/Library/CoreServices/CoreTypes.bundle/Contents' 71 | '/Resources/ToolbarAdvanced.icns') 72 | ICON_SYNC = ('/System/Library/CoreServices/CoreTypes.bundle/Contents' 73 | '/Resources/Sync.icns') 74 | ICON_TRASH = ('/System/Library/CoreServices/CoreTypes.bundle/Contents' 75 | '/Resources/TrashIcon.icns') 76 | ICON_USER = ('/System/Library/CoreServices/CoreTypes.bundle/Contents' 77 | '/Resources/UserIcon.icns') 78 | ICON_WARNING = ('/System/Library/CoreServices/CoreTypes.bundle/Contents' 79 | '/Resources/AlertCautionIcon.icns') 80 | ICON_WEB = ('/System/Library/CoreServices/CoreTypes.bundle/Contents' 81 | '/Resources/BookmarkIcon.icns') 82 | 83 | #################################################################### 84 | # non-ASCII to ASCII diacritic folding. 85 | # Used by ``fold_to_ascii`` method 86 | #################################################################### 87 | 88 | ASCII_REPLACEMENTS = { 89 | 'À': 'A', 90 | 'Á': 'A', 91 | 'Â': 'A', 92 | 'Ã': 'A', 93 | 'Ä': 'A', 94 | 'Å': 'A', 95 | 'Æ': 'AE', 96 | 'Ç': 'C', 97 | 'È': 'E', 98 | 'É': 'E', 99 | 'Ê': 'E', 100 | 'Ë': 'E', 101 | 'Ì': 'I', 102 | 'Í': 'I', 103 | 'Î': 'I', 104 | 'Ï': 'I', 105 | 'Ð': 'D', 106 | 'Ñ': 'N', 107 | 'Ò': 'O', 108 | 'Ó': 'O', 109 | 'Ô': 'O', 110 | 'Õ': 'O', 111 | 'Ö': 'O', 112 | 'Ø': 'O', 113 | 'Ù': 'U', 114 | 'Ú': 'U', 115 | 'Û': 'U', 116 | 'Ü': 'U', 117 | 'Ý': 'Y', 118 | 'Þ': 'Th', 119 | 'ß': 'ss', 120 | 'à': 'a', 121 | 'á': 'a', 122 | 'â': 'a', 123 | 'ã': 'a', 124 | 'ä': 'a', 125 | 'å': 'a', 126 | 'æ': 'ae', 127 | 'ç': 'c', 128 | 'è': 'e', 129 | 'é': 'e', 130 | 'ê': 'e', 131 | 'ë': 'e', 132 | 'ì': 'i', 133 | 'í': 'i', 134 | 'î': 'i', 135 | 'ï': 'i', 136 | 'ð': 'd', 137 | 'ñ': 'n', 138 | 'ò': 'o', 139 | 'ó': 'o', 140 | 'ô': 'o', 141 | 'õ': 'o', 142 | 'ö': 'o', 143 | 'ø': 'o', 144 | 'ù': 'u', 145 | 'ú': 'u', 146 | 'û': 'u', 147 | 'ü': 'u', 148 | 'ý': 'y', 149 | 'þ': 'th', 150 | 'ÿ': 'y', 151 | 'Ł': 'L', 152 | 'ł': 'l', 153 | 'Ń': 'N', 154 | 'ń': 'n', 155 | 'Ņ': 'N', 156 | 'ņ': 'n', 157 | 'Ň': 'N', 158 | 'ň': 'n', 159 | 'Ŋ': 'ng', 160 | 'ŋ': 'NG', 161 | 'Ō': 'O', 162 | 'ō': 'o', 163 | 'Ŏ': 'O', 164 | 'ŏ': 'o', 165 | 'Ő': 'O', 166 | 'ő': 'o', 167 | 'Œ': 'OE', 168 | 'œ': 'oe', 169 | 'Ŕ': 'R', 170 | 'ŕ': 'r', 171 | 'Ŗ': 'R', 172 | 'ŗ': 'r', 173 | 'Ř': 'R', 174 | 'ř': 'r', 175 | 'Ś': 'S', 176 | 'ś': 's', 177 | 'Ŝ': 'S', 178 | 'ŝ': 's', 179 | 'Ş': 'S', 180 | 'ş': 's', 181 | 'Š': 'S', 182 | 'š': 's', 183 | 'Ţ': 'T', 184 | 'ţ': 't', 185 | 'Ť': 'T', 186 | 'ť': 't', 187 | 'Ŧ': 'T', 188 | 'ŧ': 't', 189 | 'Ũ': 'U', 190 | 'ũ': 'u', 191 | 'Ū': 'U', 192 | 'ū': 'u', 193 | 'Ŭ': 'U', 194 | 'ŭ': 'u', 195 | 'Ů': 'U', 196 | 'ů': 'u', 197 | 'Ű': 'U', 198 | 'ű': 'u', 199 | 'Ŵ': 'W', 200 | 'ŵ': 'w', 201 | 'Ŷ': 'Y', 202 | 'ŷ': 'y', 203 | 'Ÿ': 'Y', 204 | 'Ź': 'Z', 205 | 'ź': 'z', 206 | 'Ż': 'Z', 207 | 'ż': 'z', 208 | 'Ž': 'Z', 209 | 'ž': 'z', 210 | 'ſ': 's', 211 | 'Α': 'A', 212 | 'Β': 'B', 213 | 'Γ': 'G', 214 | 'Δ': 'D', 215 | 'Ε': 'E', 216 | 'Ζ': 'Z', 217 | 'Η': 'E', 218 | 'Θ': 'Th', 219 | 'Ι': 'I', 220 | 'Κ': 'K', 221 | 'Λ': 'L', 222 | 'Μ': 'M', 223 | 'Ν': 'N', 224 | 'Ξ': 'Ks', 225 | 'Ο': 'O', 226 | 'Π': 'P', 227 | 'Ρ': 'R', 228 | 'Σ': 'S', 229 | 'Τ': 'T', 230 | 'Υ': 'U', 231 | 'Φ': 'Ph', 232 | 'Χ': 'Kh', 233 | 'Ψ': 'Ps', 234 | 'Ω': 'O', 235 | 'α': 'a', 236 | 'β': 'b', 237 | 'γ': 'g', 238 | 'δ': 'd', 239 | 'ε': 'e', 240 | 'ζ': 'z', 241 | 'η': 'e', 242 | 'θ': 'th', 243 | 'ι': 'i', 244 | 'κ': 'k', 245 | 'λ': 'l', 246 | 'μ': 'm', 247 | 'ν': 'n', 248 | 'ξ': 'x', 249 | 'ο': 'o', 250 | 'π': 'p', 251 | 'ρ': 'r', 252 | 'ς': 's', 253 | 'σ': 's', 254 | 'τ': 't', 255 | 'υ': 'u', 256 | 'φ': 'ph', 257 | 'χ': 'kh', 258 | 'ψ': 'ps', 259 | 'ω': 'o', 260 | 'А': 'A', 261 | 'Б': 'B', 262 | 'В': 'V', 263 | 'Г': 'G', 264 | 'Д': 'D', 265 | 'Е': 'E', 266 | 'Ж': 'Zh', 267 | 'З': 'Z', 268 | 'И': 'I', 269 | 'Й': 'I', 270 | 'К': 'K', 271 | 'Л': 'L', 272 | 'М': 'M', 273 | 'Н': 'N', 274 | 'О': 'O', 275 | 'П': 'P', 276 | 'Р': 'R', 277 | 'С': 'S', 278 | 'Т': 'T', 279 | 'У': 'U', 280 | 'Ф': 'F', 281 | 'Х': 'Kh', 282 | 'Ц': 'Ts', 283 | 'Ч': 'Ch', 284 | 'Ш': 'Sh', 285 | 'Щ': 'Shch', 286 | 'Ъ': "'", 287 | 'Ы': 'Y', 288 | 'Ь': "'", 289 | 'Э': 'E', 290 | 'Ю': 'Iu', 291 | 'Я': 'Ia', 292 | 'а': 'a', 293 | 'б': 'b', 294 | 'в': 'v', 295 | 'г': 'g', 296 | 'д': 'd', 297 | 'е': 'e', 298 | 'ж': 'zh', 299 | 'з': 'z', 300 | 'и': 'i', 301 | 'й': 'i', 302 | 'к': 'k', 303 | 'л': 'l', 304 | 'м': 'm', 305 | 'н': 'n', 306 | 'о': 'o', 307 | 'п': 'p', 308 | 'р': 'r', 309 | 'с': 's', 310 | 'т': 't', 311 | 'у': 'u', 312 | 'ф': 'f', 313 | 'х': 'kh', 314 | 'ц': 'ts', 315 | 'ч': 'ch', 316 | 'ш': 'sh', 317 | 'щ': 'shch', 318 | 'ъ': "'", 319 | 'ы': 'y', 320 | 'ь': "'", 321 | 'э': 'e', 322 | 'ю': 'iu', 323 | 'я': 'ia', 324 | # 'ᴀ': '', 325 | # 'ᴁ': '', 326 | # 'ᴂ': '', 327 | # 'ᴃ': '', 328 | # 'ᴄ': '', 329 | # 'ᴅ': '', 330 | # 'ᴆ': '', 331 | # 'ᴇ': '', 332 | # 'ᴈ': '', 333 | # 'ᴉ': '', 334 | # 'ᴊ': '', 335 | # 'ᴋ': '', 336 | # 'ᴌ': '', 337 | # 'ᴍ': '', 338 | # 'ᴎ': '', 339 | # 'ᴏ': '', 340 | # 'ᴐ': '', 341 | # 'ᴑ': '', 342 | # 'ᴒ': '', 343 | # 'ᴓ': '', 344 | # 'ᴔ': '', 345 | # 'ᴕ': '', 346 | # 'ᴖ': '', 347 | # 'ᴗ': '', 348 | # 'ᴘ': '', 349 | # 'ᴙ': '', 350 | # 'ᴚ': '', 351 | # 'ᴛ': '', 352 | # 'ᴜ': '', 353 | # 'ᴝ': '', 354 | # 'ᴞ': '', 355 | # 'ᴟ': '', 356 | # 'ᴠ': '', 357 | # 'ᴡ': '', 358 | # 'ᴢ': '', 359 | # 'ᴣ': '', 360 | # 'ᴤ': '', 361 | # 'ᴥ': '', 362 | 'ᴦ': 'G', 363 | 'ᴧ': 'L', 364 | 'ᴨ': 'P', 365 | 'ᴩ': 'R', 366 | 'ᴪ': 'PS', 367 | 'ẞ': 'Ss', 368 | 'Ỳ': 'Y', 369 | 'ỳ': 'y', 370 | 'Ỵ': 'Y', 371 | 'ỵ': 'y', 372 | 'Ỹ': 'Y', 373 | 'ỹ': 'y', 374 | } 375 | 376 | #################################################################### 377 | # Used by `Workflow.filter` 378 | #################################################################### 379 | 380 | # Anchor characters in a name 381 | INITIALS = string.ascii_uppercase + string.digits 382 | 383 | # Split on non-letters, numbers 384 | split_on_delimiters = re.compile('[^a-zA-Z0-9]').split 385 | 386 | # Match filter flags 387 | MATCH_STARTSWITH = 1 388 | MATCH_CAPITALS = 2 389 | MATCH_ATOM = 4 390 | MATCH_INITIALS_STARTSWITH = 8 391 | MATCH_INITIALS_CONTAIN = 16 392 | MATCH_INITIALS = 24 393 | MATCH_SUBSTRING = 32 394 | MATCH_ALLCHARS = 64 395 | MATCH_ALL = 127 396 | 397 | 398 | #################################################################### 399 | # Keychain access errors 400 | #################################################################### 401 | 402 | class KeychainError(Exception): 403 | """Raised by methods :meth:`Workflow.save_password`, 404 | :meth:`Workflow.get_password` and :meth:`Workflow.delete_password` 405 | when ``security`` CLI app returns an unknown code. 406 | 407 | """ 408 | 409 | 410 | class PasswordNotFound(KeychainError): 411 | """Raised by method :meth:`Workflow.get_password` when ``account`` 412 | is unknown to the Keychain. 413 | 414 | """ 415 | 416 | 417 | class PasswordExists(KeychainError): 418 | """Raised when trying to overwrite an existing account password. 419 | 420 | The API user should never receive this error: it is used internally 421 | by the :meth:`Workflow.save_password` method. 422 | 423 | """ 424 | 425 | 426 | #################################################################### 427 | # Helper functions 428 | #################################################################### 429 | 430 | def isascii(text): 431 | """Test if ``text`` contains only ASCII characters 432 | 433 | :param text: text to test for ASCII-ness 434 | :type text: ``unicode`` 435 | :returns: ``True`` if ``text`` contains only ASCII characters 436 | :rtype: ``Boolean`` 437 | """ 438 | 439 | try: 440 | text.encode('ascii') 441 | except UnicodeEncodeError: 442 | return False 443 | return True 444 | 445 | 446 | #################################################################### 447 | # Implementation classes 448 | #################################################################### 449 | 450 | class Item(object): 451 | """Represents a feedback item for Alfred. Generates Alfred-compliant 452 | XML for a single item. 453 | 454 | You probably shouldn't use this class directly, but via 455 | :meth:`Workflow.add_item`. See :meth:`~Workflow.add_item` 456 | for details of arguments. 457 | 458 | """ 459 | 460 | def __init__(self, title, subtitle='', modifier_subtitles=None, 461 | arg=None, autocomplete=None, valid=False, uid=None, 462 | icon=None, icontype=None, type=None): 463 | """Arguments the same as for :meth:`Workflow.add_item`. 464 | 465 | """ 466 | 467 | self.title = title 468 | self.subtitle = subtitle 469 | self.modifier_subtitles = modifier_subtitles or {} 470 | self.arg = arg 471 | self.autocomplete = autocomplete 472 | self.valid = valid 473 | self.uid = uid 474 | self.icon = icon 475 | self.icontype = icontype 476 | self.type = type 477 | 478 | @property 479 | def elem(self): 480 | """Create and return feedback item for Alfred. 481 | 482 | :returns: :class:`ElementTree.Element ` 483 | instance for this :class:`Item` instance. 484 | 485 | """ 486 | 487 | attr = {} 488 | if self.valid: 489 | attr['valid'] = 'yes' 490 | else: 491 | attr['valid'] = 'no' 492 | # Optional attributes 493 | for name in ('uid', 'type', 'autocomplete'): 494 | value = getattr(self, name, None) 495 | if value: 496 | attr[name] = value 497 | 498 | root = ET.Element('item', attr) 499 | ET.SubElement(root, 'title').text = self.title 500 | ET.SubElement(root, 'subtitle').text = self.subtitle 501 | # Add modifier subtitles 502 | for mod in ('cmd', 'ctrl', 'alt', 'shift', 'fn'): 503 | if mod in self.modifier_subtitles: 504 | ET.SubElement(root, 'subtitle', 505 | {'mod': mod}).text = self.modifier_subtitles[mod] 506 | 507 | if self.arg: 508 | ET.SubElement(root, 'arg').text = self.arg 509 | # Add icon if there is one 510 | if self.icon: 511 | if self.icontype: 512 | attr = dict(type=self.icontype) 513 | else: 514 | attr = {} 515 | ET.SubElement(root, 'icon', attr).text = self.icon 516 | return root 517 | 518 | 519 | class Settings(dict): 520 | """A dictionary that saves itself when changed. 521 | 522 | Dictionary keys & values will be saved as a JSON file 523 | at ``filepath``. If the file does not exist, the dictionary 524 | (and settings file) will be initialised with ``defaults``. 525 | 526 | :param filepath: where to save the settings 527 | :type filepath: :class:`unicode` 528 | :param defaults: dict of default settings 529 | :type defaults: :class:`dict` 530 | 531 | 532 | An appropriate instance is provided by :class:`Workflow` instances at 533 | :attr:`Workflow.settings`. 534 | 535 | """ 536 | 537 | def __init__(self, filepath, defaults=None): 538 | 539 | super(Settings, self).__init__() 540 | self._filepath = filepath 541 | self._nosave = False 542 | if os.path.exists(self._filepath): 543 | self._load() 544 | elif defaults: 545 | for key, val in defaults.items(): 546 | self[key] = val 547 | self._save() # save default settings 548 | 549 | def _load(self): 550 | """Load cached settings from JSON file `self._filepath`""" 551 | 552 | self._nosave = True 553 | with open(self._filepath, 'rb') as file: 554 | for key, value in json.load(file, encoding='utf-8').items(): 555 | self[key] = value 556 | self._nosave = False 557 | 558 | def _save(self): 559 | """Save settings to JSON file `self._filepath`""" 560 | if self._nosave: 561 | return 562 | data = {} 563 | for key, value in self.items(): 564 | data[key] = value 565 | with open(self._filepath, 'wb') as file: 566 | json.dump(data, file, sort_keys=True, indent=2, encoding='utf-8') 567 | 568 | # dict methods 569 | def __setitem__(self, key, value): 570 | super(Settings, self).__setitem__(key, value) 571 | self._save() 572 | 573 | def update(self, *args, **kwargs): 574 | """Override :class:`dict` method to save on update.""" 575 | super(Settings, self).update(*args, **kwargs) 576 | self._save() 577 | 578 | def setdefault(self, key, value=None): 579 | """Override :class:`dict` method to save on update.""" 580 | ret = super(Settings, self).setdefault(key, value) 581 | self._save() 582 | return ret 583 | 584 | 585 | class Workflow(object): 586 | """Create new :class:`Workflow` instance. 587 | 588 | :param default_settings: default workflow settings. If no settings file 589 | exists, :class:`Workflow.settings` will be pre-populated with 590 | ``default_settings``. 591 | :type default_settings: :class:`dict` 592 | :param input_encoding: encoding of command line arguments 593 | :type input_encoding: :class:`unicode` 594 | :param normalization: normalisation to apply to CLI args. 595 | See :meth:`Workflow.decode` for more details. 596 | :type normalization: :class:`unicode` 597 | :param capture_args: capture and act on ``workflow:*`` arguments. See 598 | :ref:`Magic arguments ` for details. 599 | :type capture_args: :class:`Boolean` 600 | :param libraries: sequence of paths to directories containing 601 | libraries. These paths will be prepended to ``sys.path``. 602 | :type libraries: :class:`tuple` or :class:`list` 603 | 604 | """ 605 | 606 | # Which class to use to generate feedback items. You probably 607 | # won't want to change this 608 | item_class = Item 609 | 610 | def __init__(self, default_settings=None, input_encoding='utf-8', 611 | normalization='NFC', capture_args=True, libraries=None): 612 | 613 | self._default_settings = default_settings or {} 614 | self._input_encoding = input_encoding 615 | self._normalizsation = normalization 616 | self._capture_args = capture_args 617 | self._workflowdir = None 618 | self._settings_path = None 619 | self._settings = None 620 | self._bundleid = None 621 | self._name = None 622 | # info.plist should be in the directory above this one 623 | self._info_plist = self.workflowfile('info.plist') 624 | self._info = None 625 | self._info_loaded = False 626 | self._logger = None 627 | self._items = [] 628 | self._search_pattern_cache = {} 629 | if libraries: 630 | sys.path = libraries + sys.path 631 | 632 | #################################################################### 633 | # API methods 634 | #################################################################### 635 | 636 | # info.plist contents ---------------------------------------------- 637 | 638 | @property 639 | def info(self): 640 | """`dict` of ``info.plist`` contents. 641 | 642 | :returns: ``dict`` 643 | 644 | """ 645 | 646 | if not self._info_loaded: 647 | self._load_info_plist() 648 | return self._info 649 | 650 | @property 651 | def bundleid(self): 652 | """Workflow bundle ID from ``info.plist``. 653 | 654 | :returns: bundle ID 655 | :rtype: ``unicode`` 656 | 657 | """ 658 | 659 | if not self._bundleid: 660 | self._bundleid = unicode(self.info['bundleid'], 'utf-8') 661 | return self._bundleid 662 | 663 | @property 664 | def name(self): 665 | """Workflow name from ``info.plist``. 666 | 667 | :returns: workflow name 668 | :rtype: ``unicode`` 669 | 670 | """ 671 | 672 | if not self._name: 673 | self._name = unicode(self.info['name'], 'utf-8') 674 | return self._name 675 | 676 | # Workflow utility methods ----------------------------------------- 677 | 678 | @property 679 | def args(self): 680 | """Return command line args as normalised unicode. 681 | 682 | Args are decoded and normalised via :meth:`~Workflow.decode`. 683 | 684 | The encoding and normalisation are the ``input_encoding`` and 685 | ``normalization`` arguments passed to :class:`Workflow` (``UTF-8`` 686 | and ``NFC`` are the defaults). 687 | 688 | If :class:`Workflow` is called with ``capture_args=True`` (the default), 689 | :class:`Workflow` will look for certain ``workflow:*`` args and, if 690 | found, perform the corresponding actions and exit the workflow. 691 | 692 | See :ref:`Magic arguments ` for details. 693 | 694 | """ 695 | 696 | msg = None 697 | args = [self.decode(arg) for arg in sys.argv[1:]] 698 | if len(args) and self._capture_args: # pragma: no cover 699 | if 'workflow:openlog' in args: 700 | msg = 'Opening workflow log file' 701 | self.open_log() 702 | elif 'workflow:delcache' in args: 703 | self.clear_cache() 704 | msg = 'Deleted workflow cache' 705 | elif 'workflow:delsettings' in args: 706 | self.clear_settings() 707 | msg = 'Deleted workflow settings' 708 | elif 'workflow:openworkflow' in args: 709 | msg = 'Opening workflow directory' 710 | self.open_workflowdir() 711 | elif 'workflow:opendata' in args: 712 | msg = 'Opening workflow data directory' 713 | self.open_datadir() 714 | elif 'workflow:opencache' in args: 715 | msg = 'Opening workflow cache directory' 716 | self.open_cachedir() 717 | elif 'workflow:openterm' in args: 718 | msg = 'Opening workflow root directory in Terminal' 719 | self.open_terminal() 720 | elif 'workflow:foldingon' in args: 721 | msg = 'Diacritics will always be folded' 722 | self.settings['__workflows_diacritic_folding'] = True 723 | elif 'workflow:foldingoff' in args: 724 | msg = 'Diacritics will never be folded' 725 | self.settings['__workflows_diacritic_folding'] = False 726 | elif 'workflow:foldingdefault' in args: 727 | msg = 'Diacritics folding reset' 728 | if '__workflows_diacritic_folding' in self.settings: 729 | del self.settings['__workflows_diacritic_folding'] 730 | 731 | if msg: 732 | self.logger.debug(msg) 733 | if not sys.stdout.isatty(): # Show message in Alfred 734 | self.add_item(msg, valid=False, icon=ICON_INFO) 735 | self.send_feedback() 736 | sys.exit(0) 737 | return args 738 | 739 | @property 740 | def cachedir(self): 741 | """Path to workflow's cache directory. 742 | 743 | :returns: full path to workflow's cache directory 744 | :rtype: ``unicode`` 745 | 746 | """ 747 | 748 | dirpath = os.path.join(os.path.expanduser( 749 | '~/Library/Caches/com.runningwithcrayons.Alfred-2/Workflow Data/'), 750 | self.bundleid) 751 | return self._create(dirpath) 752 | 753 | @property 754 | def datadir(self): 755 | """Path to workflow's data directory. 756 | 757 | :returns: full path to workflow data directory 758 | :rtype: ``unicode`` 759 | 760 | """ 761 | 762 | dirpath = os.path.join(os.path.expanduser( 763 | '~/Library/Application Support/Alfred 2/Workflow Data/'), 764 | self.bundleid) 765 | return self._create(dirpath) 766 | 767 | @property 768 | def workflowdir(self): 769 | """Path to workflow's root directory (where ``info.plist`` is). 770 | 771 | :returns: full path to workflow root directory 772 | :rtype: ``unicode`` 773 | 774 | """ 775 | 776 | if not self._workflowdir: 777 | # climb the directory tree until we find `info.plist` 778 | dirpath = os.path.abspath(os.path.dirname(__file__)) 779 | while True: 780 | dirpath = os.path.dirname(dirpath) 781 | if os.path.exists(os.path.join(dirpath, 'info.plist')): 782 | self._workflowdir = dirpath 783 | break 784 | elif dirpath == '/': # pragma: no cover 785 | # no `info.plist` found 786 | raise IOError("'info.plist' not found in directory tree") 787 | 788 | return self._workflowdir 789 | 790 | def cachefile(self, filename): 791 | """Return full path to ``filename`` within workflow's cache dir. 792 | 793 | :param filename: basename of file 794 | :type filename: ``unicode`` 795 | :returns: full path to file within cache directory 796 | :rtype: ``unicode`` 797 | 798 | """ 799 | 800 | return os.path.join(self.cachedir, filename) 801 | 802 | def datafile(self, filename): 803 | """Return full path to ``filename`` within workflow's data dir. 804 | 805 | :param filename: basename of file 806 | :type filename: ``unicode`` 807 | :returns: full path to file within data directory 808 | :rtype: ``unicode`` 809 | 810 | """ 811 | 812 | return os.path.join(self.datadir, filename) 813 | 814 | def workflowfile(self, filename): 815 | """Return full path to ``filename`` in workflow's root dir 816 | (where ``info.plist`` is). 817 | 818 | :param filename: basename of file 819 | :type filename: ``unicode`` 820 | :returns: full path to file within data directory 821 | :rtype: ``unicode`` 822 | 823 | """ 824 | 825 | return os.path.join(self.workflowdir, filename) 826 | 827 | @property 828 | def logfile(self): 829 | """Return path to logfile 830 | 831 | :returns: path to logfile within workflow's cache directory 832 | :rtype: ``unicode`` 833 | 834 | """ 835 | 836 | return self.cachefile('%s.log' % self.bundleid) 837 | 838 | @property 839 | def logger(self): 840 | """Create and return a logger that logs to both console and 841 | a log file. Use `~Workflow.openlog` to open the log file in Console. 842 | 843 | :returns: an initialised logger 844 | :rtype: `~logging.Logger` instance 845 | 846 | """ 847 | 848 | if self._logger: 849 | return self._logger 850 | 851 | # Initialise new logger and optionally handlers 852 | logger = logging.getLogger('workflow') 853 | 854 | if not logger.handlers: # Only add one set of handlers 855 | logfile = logging.handlers.RotatingFileHandler( 856 | self.logfile, 857 | maxBytes=1024*1024, 858 | backupCount=0) 859 | 860 | console = logging.StreamHandler() 861 | 862 | fmt = logging.Formatter( 863 | '%(asctime)s %(filename)s:%(lineno)s' 864 | ' %(levelname)-8s %(message)s', 865 | datefmt='%H:%M:%S') 866 | 867 | logfile.setFormatter(fmt) 868 | console.setFormatter(fmt) 869 | 870 | logger.addHandler(logfile) 871 | logger.addHandler(console) 872 | 873 | logger.setLevel(logging.DEBUG) 874 | self._logger = logger 875 | 876 | return self._logger 877 | 878 | @logger.setter 879 | def logger(self, logger): 880 | """Set a custom logger. 881 | 882 | :param logger: The logger to use 883 | :type logger: `~logging.Logger` instance 884 | 885 | """ 886 | 887 | self._logger = logger 888 | 889 | @property 890 | def settings_path(self): 891 | """Path to settings file within workflow's data directory. 892 | 893 | :returns: path to ``settings.json`` file 894 | :rtype: ``unicode`` 895 | 896 | """ 897 | 898 | if not self._settings_path: 899 | self._settings_path = self.datafile('settings.json') 900 | return self._settings_path 901 | 902 | @property 903 | def settings(self): 904 | """Return a dictionary subclass that saves itself when changed. 905 | 906 | :returns: :class:`Settings` instance initialised from the data 907 | in JSON file at :attr:`settings_path` or if that doesn't exist, 908 | with the ``default_settings`` ``dict`` passed to :class:`Workflow`. 909 | :rtype: :class:`Settings` instance 910 | 911 | """ 912 | 913 | if not self._settings: 914 | self._settings = Settings(self.settings_path, 915 | self._default_settings) 916 | return self._settings 917 | 918 | def cached_data(self, name, data_func=None, max_age=60): 919 | """Retrieve data from cache or re-generate and re-cache data if 920 | stale/non-existant. If ``max_age`` is 0, return cached data no 921 | matter how old. 922 | 923 | :param name: name of datastore 924 | :type name: ``unicode`` 925 | :param data_func: function to (re-)generate data. 926 | :type data_func: `callable` 927 | :param max_age: maximum age of cached data in seconds 928 | :type max_age: `int` 929 | :returns: cached data, return value of ``data_func`` or ``None`` 930 | if ``data_func`` is not set 931 | :rtype: whatever ``data_func`` returns or ``None`` 932 | 933 | """ 934 | 935 | cache_path = self.cachefile('%s.cache' % name) 936 | age = self.cached_data_age(name) 937 | if (age < max_age or max_age == 0) and os.path.exists(cache_path): 938 | with open(cache_path, 'rb') as file: 939 | self.logger.debug('Loading cached data from : %s', 940 | cache_path) 941 | return pickle.load(file) 942 | if not data_func: 943 | return None 944 | data = data_func() 945 | self.cache_data(name, data) 946 | return data 947 | 948 | def cache_data(self, name, data): 949 | """Save ``data`` to cache under ``name``. 950 | 951 | If ``data`` is ``None``, the corresponding cache file will be deleted. 952 | 953 | :param name: name of datastore 954 | :type name: ``unicode`` 955 | :param data: data to store 956 | :type data: any object supported by :mod:`pickle` 957 | 958 | """ 959 | 960 | cache_path = self.cachefile('%s.cache' % name) 961 | 962 | if data is None: 963 | if os.path.exists(cache_path): 964 | os.unlink(cache_path) 965 | self.logger.debug('Deleted cache file : %s', cache_path) 966 | return 967 | 968 | with open(cache_path, 'wb') as file: 969 | pickle.dump(data, file) 970 | self.logger.debug('Cached data saved at : %s', cache_path) 971 | 972 | def cached_data_fresh(self, name, max_age): 973 | """Is data cached at `name` less than `max_age` old? 974 | 975 | :param name: name of datastore 976 | :type name: ``unicode`` 977 | :param max_age: maximum age of data in seconds 978 | :type max_age: `int` 979 | :returns: ``True`` if data is less than `max_age` old, else ``False`` 980 | :rtype: `Boolean` 981 | 982 | """ 983 | 984 | age = self.cached_data_age(name) 985 | if not age: 986 | return False 987 | return age < max_age 988 | 989 | def cached_data_age(self, name): 990 | """Return age of data cached at `name` in seconds or 0 if 991 | cache doesn't exist 992 | 993 | :param name: name of datastore 994 | :type name: ``unicode`` 995 | :returns: age of datastore in seconds 996 | :rtype: `int` 997 | 998 | """ 999 | 1000 | cache_path = self.cachefile('%s.cache' % name) 1001 | if not os.path.exists(cache_path): 1002 | return 0 1003 | return time.time() - os.stat(cache_path).st_mtime 1004 | 1005 | def filter(self, query, items, key=lambda x: x, empty_query='', 1006 | ascending=False, include_score=False, min_score=0, 1007 | max_results=0, match_on=MATCH_ALL, fold_diacritics=True): 1008 | """Fuzzy search filter. Returns list of ``items`` that match ``query``. 1009 | 1010 | ``query`` is case-insensitive. Any item that does not contain the 1011 | entirety of ``query`` is rejected. 1012 | 1013 | :param query: query to test items against 1014 | :type query: ``unicode`` 1015 | :param items: iterable of items to test 1016 | :type items: ``list`` or ``tuple`` 1017 | :param key: function to get comparison key from ``items``. Must return a 1018 | ``unicode`` string. The default simply returns the item. 1019 | :type key: ``callable`` 1020 | :param ascending: set to ``True`` to get worst matches first 1021 | :type ascending: ``Boolean`` 1022 | :param include_score: Useful for debugging the scoring algorithm. 1023 | If ``True``, results will be a list of tuples 1024 | ``(item, score, rule)``. 1025 | :type include_score: ``Boolean`` 1026 | :param min_score: If non-zero, ignore results with a score lower 1027 | than this. 1028 | :type min_score: ``int`` 1029 | :param max_results: If non-zero, prune results list to this length. 1030 | :type max_results: ``int`` 1031 | :param match_on: Filter option flags. Bitwise-combined list of 1032 | ``MATCH_*`` constants (see below). 1033 | :type match_on: ``int`` 1034 | :param fold_diacritics: Convert search keys to ASCII-only 1035 | characters if ``query`` only contains ASCII characters. 1036 | :type fold_diacritics: ``Boolean`` 1037 | :returns: list of ``items`` matching ``query`` or list of 1038 | ``(item, score, rule)`` `tuples` if ``include_score`` is ``True``. 1039 | ``rule`` is the ``MATCH_`` rule that matched the item. 1040 | :rtype: ``list`` 1041 | 1042 | **Matching rules** 1043 | 1044 | By default, :meth:`filter` uses all of the following flags (i.e. 1045 | :const:`MATCH_ALL`). The tests are always run in the given order: 1046 | 1047 | 1. :const:`MATCH_STARTSWITH` : Item search key startswith ``query`` (case-insensitive). 1048 | 2. :const:`MATCH_CAPITALS` : The list of capital letters in item search key starts with ``query`` (``query`` may be lower-case). E.g., ``of`` would match ``OmniFocus``, ``gc`` would match ``Google Chrome`` 1049 | 3. :const:`MATCH_ATOM` : Search key is split into "atoms" on non-word characters (.,-,' etc.). Matches if ``query`` is one of these atoms (case-insensitive). 1050 | 4. :const:`MATCH_INITIALS_STARTSWITH` : Initials are the first characters of the above-described "atoms" (case-insensitive). 1051 | 5. :const:`MATCH_INITIALS_CONTAIN` : ``query`` is a substring of the above-described initials. 1052 | 6. :const:`MATCH_INITIALS` : Combination of (4) and (5). 1053 | 7. :const:`MATCH_SUBSTRING` : Match if ``query`` is a substring of item search key (case-insensitive). 1054 | 8. :const:`MATCH_ALLCHARS` : Matches if all characters in ``query`` appear in item search key in the same order (case-insensitive). 1055 | 9. :const:`MATCH_ALL` : Combination of all the above. 1056 | 1057 | 1058 | ``MATCH_ALLCHARS`` is considerably slower than the other tests and 1059 | provides much less accurate results. 1060 | 1061 | **Examples:** 1062 | 1063 | To ignore ``MATCH_ALLCHARS`` (tends to provide the worst matches and 1064 | is expensive to run), use ``match_on=MATCH_ALL ^ MATCH_ALLCHARS``. 1065 | 1066 | To match only on capitals, use ``match_on=MATCH_CAPITALS``. 1067 | 1068 | To match only on startswith and substring, use 1069 | ``match_on=MATCH_STARTSWITH | MATCH_SUBSTRING``. 1070 | 1071 | **Diacritic folding** 1072 | 1073 | .. versionadded:: 1.3 1074 | 1075 | If ``fold_diacritics`` is ``True`` (the default), and ``query`` 1076 | contains only ASCII characters, non-ASCII characters in search keys 1077 | will be converted to ASCII equivalents (e.g. *ü* -> *u*, *ß* -> *ss*, 1078 | *é* -> *e*). 1079 | 1080 | See :const:`ASCII_REPLACEMENTS` for all replacements. 1081 | 1082 | If ``query`` contains non-ASCII characters, search keys will not be 1083 | altered. 1084 | 1085 | """ 1086 | 1087 | # Remove preceding/trailing spaces 1088 | query = query.strip() 1089 | 1090 | # Return full data set if query is empty 1091 | if query == empty_query: 1092 | return items 1093 | 1094 | if empty_query: 1095 | query = query.replace(empty_query, '') 1096 | 1097 | # Use user override if there is one 1098 | fold_diacritics = self.settings.get('__workflows_diacritic_folding', 1099 | fold_diacritics) 1100 | 1101 | results = [] 1102 | 1103 | for i, item in enumerate(items): 1104 | skip = False 1105 | score = 0 1106 | words = [s.strip() for s in query.split(' ')] 1107 | value = key(item).strip() 1108 | if value == '': 1109 | continue 1110 | for word in words: 1111 | if word == '': 1112 | continue 1113 | s, r = self._filter_item(value, word, match_on, 1114 | fold_diacritics) 1115 | 1116 | if not s: # Skip items that don't match part of the query 1117 | skip = True 1118 | score += s 1119 | 1120 | if skip: 1121 | continue 1122 | 1123 | if score: 1124 | # use "reversed" `score` (i.e. highest becomes lowest) and 1125 | # `value` as sort key. This means items with the same score 1126 | # will be sorted in alphabetical not reverse alphabetical order 1127 | results.append(((100.0 / score, value.lower(), i), 1128 | (item, score, r))) 1129 | 1130 | # sort on keys, then discard the keys 1131 | results.sort() 1132 | results = [t[1] for t in results] 1133 | 1134 | if max_results and len(results) > max_results: 1135 | results = results[:max_results] 1136 | 1137 | if min_score: 1138 | results = [r for r in results if r[1] > min_score] 1139 | 1140 | if ascending: 1141 | results = results.reverse() 1142 | 1143 | # return list of ``(item, score, rule)`` 1144 | if include_score: 1145 | return results 1146 | # just return list of items 1147 | return [t[0] for t in results] 1148 | 1149 | def _filter_item(self, value, query, match_on, fold_diacritics): 1150 | """Filter ``value`` against ``query`` using rules ``match_on`` 1151 | 1152 | :returns: ``(score, rule)`` 1153 | 1154 | """ 1155 | 1156 | query = query.lower() 1157 | queryset = set(query) 1158 | 1159 | if not isascii(query): 1160 | fold_diacritics = False 1161 | 1162 | rule = None 1163 | score = 0 1164 | 1165 | if fold_diacritics: 1166 | value = self.fold_to_ascii(value) 1167 | 1168 | # pre-filter any items that do not contain all characters 1169 | # of ``query`` to save on running several more expensive tests 1170 | if not queryset <= set(value.lower()): 1171 | return (0, None) 1172 | 1173 | # item starts with query 1174 | if (match_on & MATCH_STARTSWITH and 1175 | value.lower().startswith(query)): 1176 | score = 100.0 - (len(value) / len(query)) 1177 | rule = MATCH_STARTSWITH 1178 | 1179 | if not score and match_on & MATCH_CAPITALS: 1180 | # query matches capitalised letters in item, 1181 | # e.g. of = OmniFocus 1182 | initials = ''.join([c for c in value if c in INITIALS]) 1183 | if initials.lower().startswith(query): 1184 | score = 100.0 - (len(initials) / len(query)) 1185 | rule = MATCH_CAPITALS 1186 | 1187 | if not score: 1188 | if (match_on & MATCH_ATOM or 1189 | match_on & MATCH_INITIALS_CONTAIN or 1190 | match_on & MATCH_INITIALS_STARTSWITH): 1191 | # split the item into "atoms", i.e. words separated by 1192 | # spaces or other non-word characters 1193 | atoms = [s.lower() for s in split_on_delimiters(value)] 1194 | # print('atoms : %s --> %s' % (value, atoms)) 1195 | # initials of the atoms 1196 | initials = ''.join([s[0] for s in atoms if s]) 1197 | 1198 | if match_on & MATCH_ATOM: 1199 | # is `query` one of the atoms in item? 1200 | # similar to substring, but scores more highly, as it's 1201 | # a word within the item 1202 | if query in atoms: 1203 | score = 100.0 - (len(value) / len(query)) 1204 | rule = MATCH_ATOM 1205 | 1206 | if not score: 1207 | # `query` matches start (or all) of the initials of the 1208 | # atoms, e.g. ``himym`` matches "How I Met Your Mother" 1209 | # *and* "how i met your mother" (the ``capitals`` rule only 1210 | # matches the former) 1211 | if (match_on & MATCH_INITIALS_STARTSWITH and 1212 | initials.startswith(query)): 1213 | score = 100.0 - (len(initials) / len(query)) 1214 | rule = MATCH_INITIALS_STARTSWITH 1215 | 1216 | # `query` is a substring of initials, e.g. ``doh`` matches 1217 | # "The Dukes of Hazzard" 1218 | elif (match_on & MATCH_INITIALS_CONTAIN and 1219 | query in initials): 1220 | score = 95.0 - (len(initials) / len(query)) 1221 | rule = MATCH_INITIALS_CONTAIN 1222 | 1223 | if not score: 1224 | # `query` is a substring of item 1225 | if match_on & MATCH_SUBSTRING and query in value.lower(): 1226 | score = 90.0 - (len(value) / len(query)) 1227 | rule = MATCH_SUBSTRING 1228 | 1229 | if not score: 1230 | # finally, assign a score based on how close together the 1231 | # characters in `query` are in item. 1232 | if match_on & MATCH_ALLCHARS: 1233 | search = self._search_for_query(query) 1234 | match = search(value) 1235 | if match: 1236 | score = 100.0 / ((1 + match.start()) * 1237 | (match.end() - match.start() + 1)) 1238 | rule = MATCH_ALLCHARS 1239 | 1240 | if score > 0: 1241 | return (score, rule) 1242 | return (0, None) 1243 | 1244 | def _search_for_query(self, query): 1245 | if query in self._search_pattern_cache: 1246 | return self._search_pattern_cache[query] 1247 | 1248 | # Build pattern: include all characters 1249 | pattern = [] 1250 | for c in query: 1251 | # pattern.append('[^{0}]*{0}'.format(re.escape(c))) 1252 | pattern.append('.*?{0}'.format(re.escape(c))) 1253 | pattern = ''.join(pattern) 1254 | search = re.compile(pattern, re.IGNORECASE).search 1255 | 1256 | self._search_pattern_cache[query] = search 1257 | return search 1258 | 1259 | def run(self, func): 1260 | """Call `func` to run your workflow 1261 | 1262 | `func` will be called with `Workflow` instance as first argument. 1263 | `func` should be the main entry point to your workflow. 1264 | 1265 | Any exceptions raised will be logged and an error message will be 1266 | output to Alfred. 1267 | 1268 | :param func: Callable to call with `self` as first argument. 1269 | 1270 | """ 1271 | 1272 | try: 1273 | func(self) 1274 | except Exception as err: 1275 | self.logger.exception(err) 1276 | if not sys.stdout.isatty(): # Show error in Alfred 1277 | self._items = [] 1278 | if self._name: 1279 | name = self._name 1280 | elif self._bundleid: 1281 | name = self._bundleid 1282 | else: # pragma: no cover 1283 | name = os.path.dirname(__file__) 1284 | self.add_item("Error in workflow '%s'" % name, unicode(err), 1285 | icon=ICON_ERROR) 1286 | self.send_feedback() 1287 | return 1 1288 | return 0 1289 | 1290 | # Alfred feedback methods ------------------------------------------ 1291 | 1292 | def add_item(self, title, subtitle='', modifier_subtitles=None, arg=None, 1293 | autocomplete=None, valid=False, uid=None, icon=None, 1294 | icontype=None, type=None): 1295 | """Add an item to be output to Alfred 1296 | 1297 | :param title: Title shown in Alfred 1298 | :type title: ``unicode`` 1299 | :param subtitle: Subtitle shown in Alfred 1300 | :type subtitle: ``unicode`` 1301 | :param modifier_subtitles: Subtitles shown when modifier 1302 | (CMD, OPT etc.) is pressed. Use a ``dict`` with the lowercase 1303 | keys ``cmd``, ``ctrl``, ``shift``, ``alt`` and ``fn`` 1304 | :type modifier_subtitles: ``dict`` 1305 | :param arg: Argument passed by Alfred as `{query}` when item is 1306 | actioned 1307 | :type arg: ``unicode`` 1308 | :param autocomplete: Text expanded in Alfred when item is TABbed 1309 | :type autocomplete: ``unicode`` 1310 | :param valid: Whether or not item can be actioned 1311 | :type valid: `Boolean` 1312 | :param uid: Used by Alfred to remember/sort items 1313 | :type uid: ``unicode`` 1314 | :param icon: Filename of icon to use 1315 | :type icon: ``unicode`` 1316 | :param icontype: Type of icon. Must be one of ``None`` , ``'filetype'`` 1317 | or ``'fileicon'``. Use ``'filetype'`` when ``icon`` is a filetype 1318 | such as``public.folder``. Use ``'fileicon'`` when you wish to 1319 | use the icon of the file specified as ``icon``, e.g. 1320 | ``icon='/Applications/Safari.app', icontype='fileicon'``. 1321 | Leave as `None` if ``icon`` points to an actual 1322 | icon file. 1323 | :type icontype: ``unicode`` 1324 | :param type: Result type. Currently only ``'file'`` is supported 1325 | (by Alfred). This will tell Alfred to enable file actions for 1326 | this item. 1327 | :type type: ``unicode`` 1328 | :returns: :class:`Item` instance 1329 | 1330 | """ 1331 | 1332 | item = self.item_class(title, subtitle, modifier_subtitles, arg, 1333 | autocomplete, valid, uid, icon, icontype, type) 1334 | self._items.append(item) 1335 | return item 1336 | 1337 | def send_feedback(self): 1338 | """Print stored items to console/Alfred as XML.""" 1339 | root = ET.Element('items') 1340 | for item in self._items: 1341 | root.append(item.elem) 1342 | sys.stdout.write('\n') 1343 | sys.stdout.write(ET.tostring(root).encode('utf-8')) 1344 | sys.stdout.flush() 1345 | 1346 | #################################################################### 1347 | # Keychain password storage methods 1348 | #################################################################### 1349 | 1350 | def save_password(self, account, password, service=None): 1351 | """Save account credentials. 1352 | 1353 | If the account exists, the old password will first be deleted (Keychain 1354 | throws an error otherwise). 1355 | 1356 | If something goes wrong, a `KeychainError` exception will be raised. 1357 | 1358 | :param account: name of the account the password is for, e.g. 1359 | "Pinboard" 1360 | :type account: ``unicode`` 1361 | :param password: the password to secure 1362 | :type password: ``unicode`` 1363 | :param service: Name of the service. By default, this is the workflow's 1364 | bundle ID 1365 | :type service: ``unicode`` 1366 | 1367 | """ 1368 | if not service: 1369 | service = self.bundleid 1370 | try: 1371 | retcode, output = self._call_security('add-generic-password', 1372 | service, account, 1373 | '-w', password) 1374 | self.logger.debug('Saved password : %s:%s', service, account) 1375 | except PasswordExists: 1376 | self.logger.debug('Password exists : %s:%s', service, account) 1377 | current_password = self.get_password(account, service) 1378 | if current_password == password: 1379 | self.logger.debug('Password unchanged') 1380 | else: 1381 | self.delete_password(account, service) 1382 | retcode, output = self._call_security('add-generic-password', 1383 | service, account, 1384 | '-w', password) 1385 | self.logger.debug('save_password : %s:%s', service, account) 1386 | 1387 | def get_password(self, account, service=None): 1388 | """Retrieve the password saved at ``service/account``. Raise 1389 | :class:`PasswordNotFound` exception if password doesn't exist. 1390 | 1391 | :param account: name of the account the password is for, e.g. 1392 | "Pinboard" 1393 | :type account: ``unicode`` 1394 | :param service: Name of the service. By default, this is the workflow's 1395 | bundle ID 1396 | :type service: ``unicode`` 1397 | :returns: account password 1398 | :rtype: ``unicode`` 1399 | 1400 | """ 1401 | 1402 | if not service: 1403 | service = self.bundleid 1404 | retcode, password = self._call_security('find-generic-password', 1405 | service, account, '-w') 1406 | self.logger.debug('get_password : %s:%s', service, account) 1407 | return password 1408 | 1409 | def delete_password(self, account, service=None): 1410 | """Delete the password stored at ``service/account``. Raises 1411 | :class:`PasswordNotFound` if account is unknown. 1412 | 1413 | :param account: name of the account the password is for, e.g. 1414 | "Pinboard" 1415 | :type account: ``unicode`` 1416 | :param service: Name of the service. By default, this is the workflow's 1417 | bundle ID 1418 | :type service: ``unicode`` 1419 | 1420 | """ 1421 | 1422 | if not service: 1423 | service = self.bundleid 1424 | retcode, output = self._call_security('delete-generic-password', 1425 | service, account) 1426 | self.logger.debug('delete_password : %s:%s', service, account) 1427 | 1428 | #################################################################### 1429 | # Methods for workflow:* magic args 1430 | #################################################################### 1431 | 1432 | def clear_cache(self): 1433 | """Delete all files in workflow cache directory.""" 1434 | if os.path.exists(self.cachedir): 1435 | for filename in os.listdir(self.cachedir): 1436 | path = os.path.join(self.cachedir, filename) 1437 | if os.path.isdir(path): 1438 | shutil.rmtree(path) 1439 | else: 1440 | os.unlink(path) 1441 | self.logger.debug('Deleted : %r', path) 1442 | 1443 | def clear_settings(self): 1444 | """Delete settings file.""" 1445 | if os.path.exists(self.settings_path): 1446 | os.unlink(self.settings_path) 1447 | self.logger.debug('Deleted : %r', self.settings_path) 1448 | 1449 | def open_log(self): 1450 | """Open log file in standard application (usually Console.app).""" 1451 | subprocess.call(['open', self.logfile]) # pragma: no cover 1452 | 1453 | def open_cachedir(self): 1454 | """Open the workflow cache directory in Finder.""" 1455 | subprocess.call(['open', self.cachedir]) # pragma: no cover 1456 | 1457 | def open_datadir(self): 1458 | """Open the workflow data directory in Finder.""" 1459 | subprocess.call(['open', self.datadir]) # pragma: no cover 1460 | 1461 | def open_workflowdir(self): 1462 | """Open the workflow directory in Finder.""" 1463 | subprocess.call(['open', self.workflowdir]) # pragma: no cover 1464 | 1465 | def open_terminal(self): 1466 | """Open a Terminal window at workflow directory.""" 1467 | subprocess.call(['open', '-a', 'Terminal', 1468 | self.workflowdir]) # pragma: no cover 1469 | 1470 | #################################################################### 1471 | # Helper methods 1472 | #################################################################### 1473 | 1474 | def decode(self, text, encoding=None, normalization=None): 1475 | """Return ``text`` as normalised unicode. 1476 | 1477 | If ``encoding`` and/or ``normalization`` is ``None``, the 1478 | ``input_encoding``and ``normalization`` parameters passed to 1479 | :class:`Workflow` are used. 1480 | 1481 | :param text: string 1482 | :type text: encoded or Unicode string. If ``text`` is already a 1483 | Unicode string, it will only be normalised. 1484 | :param encoding: The text encoding to use to decode ``text`` to 1485 | Unicode. 1486 | :type encoding: ``unicode`` or ``None`` 1487 | :param normalization: The nomalisation form to apply to ``text``. 1488 | :type normalization: ``unicode`` or ``None`` 1489 | :returns: decoded and normalised ``unicode`` 1490 | 1491 | :class:`Workflow` uses "NFC" normalisation by default. This is the 1492 | standard for Python and will work well with data from the web (via 1493 | :mod:`~workflow.web` or :mod:`json`). 1494 | 1495 | OS X, on the other hand, uses "NFD" normalisation (nearly), so data 1496 | coming from the system (e.g. via :mod:`subprocess` or 1497 | :func:`os.listdir`/:mod:`os.path`) may not match. You should either 1498 | normalise this data, too, or change the default normalisation used by 1499 | :class:`Workflow`. 1500 | 1501 | """ 1502 | 1503 | encoding = encoding or self._input_encoding 1504 | normalization = normalization or self._normalizsation 1505 | if not isinstance(text, unicode): 1506 | text = unicode(text, encoding) 1507 | return unicodedata.normalize(normalization, text) 1508 | 1509 | def fold_to_ascii(self, text): 1510 | """ 1511 | .. versionadded:: 1.3 1512 | 1513 | Convert non-ASCII characters to closest ASCII equivalent. 1514 | 1515 | :param text: text to convert 1516 | :type text: ``unicode`` 1517 | :returns: text containing only ASCII characters 1518 | :rtype: ``unicode`` 1519 | 1520 | """ 1521 | if isascii(text): 1522 | return text 1523 | text = ''.join([ASCII_REPLACEMENTS.get(c, c) for c in text]) 1524 | return unicode(unicodedata.normalize('NFKD', 1525 | text).encode('ascii', 'ignore')) 1526 | 1527 | def _load_info_plist(self): 1528 | """Load workflow info from ``info.plist`` 1529 | 1530 | """ 1531 | 1532 | self._info = plistlib.readPlist(self._info_plist) 1533 | self._info_loaded = True 1534 | 1535 | def _create(self, dirpath): 1536 | """Create directory `dirpath` if it doesn't exist 1537 | 1538 | :param dirpath: path to directory 1539 | :type dirpath: ``unicode`` 1540 | :returns: ``dirpath`` argument 1541 | :rtype: ``unicode`` 1542 | 1543 | """ 1544 | 1545 | if not os.path.exists(dirpath): 1546 | os.makedirs(dirpath) 1547 | return dirpath 1548 | 1549 | def _call_security(self, action, service, account, *args): 1550 | """Call the ``security`` CLI app that provides access to keychains. 1551 | 1552 | 1553 | May raise `PasswordNotFound`, `PasswordExists` or `KeychainError` 1554 | exceptions (the first two are subclasses of `KeychainError`). 1555 | 1556 | :param action: The ``security`` action to call, e.g. 1557 | ``add-generic-password`` 1558 | :type action: ``unicode`` 1559 | :param service: Name of the service. 1560 | :type service: ``unicode`` 1561 | :param account: name of the account the password is for, e.g. 1562 | "Pinboard" 1563 | :type account: ``unicode`` 1564 | :param password: the password to secure 1565 | :type password: ``unicode`` 1566 | :param *args: list of command line arguments to be passed to 1567 | ``security`` 1568 | :type *args: `list` or `tuple` 1569 | :returns: ``(retcode, output)``. ``retcode`` is an `int`, ``output`` a 1570 | ``unicode`` string. 1571 | :rtype: `tuple` (`int`, ``unicode``) 1572 | 1573 | """ 1574 | 1575 | cmd = ['security', action, '-s', service, '-a', account] + list(args) 1576 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE, 1577 | stderr=subprocess.STDOUT) 1578 | retcode, output = p.wait(), p.stdout.read().strip().decode('utf-8') 1579 | if retcode == 44: # password does not exist 1580 | raise PasswordNotFound() 1581 | elif retcode == 45: # password already exists 1582 | raise PasswordExists() 1583 | elif retcode > 0: 1584 | err = KeychainError('Unknown Keychain error : %s' % output) 1585 | err.retcode = retcode 1586 | raise err 1587 | return (retcode, output) 1588 | --------------------------------------------------------------------------------