├── 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 | 
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 |
--------------------------------------------------------------------------------