├── .gitignore ├── LICENSE ├── README.md ├── assets ├── config.png ├── demo.gif └── demosheet.png ├── cheat.py ├── config.py ├── icon.png ├── info.plist ├── lib ├── __init__.py ├── config.py ├── options.py └── parser.py ├── setup.cfg ├── version └── workflow ├── .alfredversionchecked ├── Notify.tgz ├── __init__.py ├── background.py ├── notify.py ├── update.py ├── util.py ├── version ├── web.py ├── workflow.py └── workflow3.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | MANIFEST 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | .hypothesis/ 47 | .pytest_cache/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | db.sqlite3 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # Environments 84 | .env 85 | .venv 86 | env/ 87 | venv/ 88 | ENV/ 89 | env.bak/ 90 | venv.bak/ 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | .spyproject 95 | 96 | # Rope project settings 97 | .ropeproject 98 | 99 | # mkdocs documentation 100 | /site 101 | 102 | # mypy 103 | .mypy_cache/ 104 | .DS_Store 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Wayne Yao 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Alfred-cheat ![GitHub All Releases](https://img.shields.io/github/downloads/wayneyaoo/alfred-cheat/total.svg) 2 | **Start writing your very own cheat sheets in your way and make them searchable using Alfred!** 3 | 4 | # Notice 5 | 6 | - Thanks to [@giovannicoppola](https://github.com/giovannicoppola) for porting a [python3 version dependency](https://github.com/NorthIsUp/alfred-workflow-py3) so that this workflow works on macOS >= 12.3 now where default Python version is Python3. Starting from alfred-cheat 1.2.1, the workflow requires Python3 to run, and any version lower than 1.2.1 requires python2. So depending on you macOS version, you migth want to figure out whether you need to install an extra python. 7 | 8 | - I'm fully switching to Linux DE for personal tasks and Windows for daily work hence won't be able to put a lot of effort on this workflow. It served me well for a long time. I'm still open to new Pull Request if you think new features should be added. But generally this workflow doesn't have a big scope and should always work. 9 | 10 | # Demo 11 | 12 | *Every sheet shown in the demo should be your knowledge base and is totally customizable.* 13 | 14 | ![...loading demo gif](assets/demo.gif) 15 | 16 | # About & Acknowledgement 17 | 18 | This project was initally inspired by [cheat](https://github.com/cheat/cheat). I attempted to wrap around it but failed because that project wasn't intended to be wrapped around. So this project ended up a separate one. These two projects serve similar purpose in different working environments. With the help of alfred, your efficiency in searching your cheat sheets will be significantly boosted. And the better news is, you're in complete control of your cheat sheets unlike [tldr](https://github.com/tldr-pages/tldr) (It's good though if you want it "just work"). 19 | 20 | I built this workflow because: 21 | 22 | 1. I want faster searching than the original [cheat](https://github.com/cheat/cheat) because that project is commandline based. Sometimes I want a very quick view and don't wanna popup a shell. 23 | 24 | 2. I want to build my own knowledge base instead of community-driven cheat sheets like [tldr](https://github.com/tldr-pages/tldr) does. 25 | 26 | **Disclaimer**: 27 | 28 | All codes in directory `workflow` are dependencies from [this project](https://github.com/deanishe/alfred-workflow). They're not my work and is the only "dependency" for this project. Since it's included in this repo, the workflow user doesn't have to concern about dependencies. 29 | 30 | # Download via [release](https://github.com/wx-Yao/alfred-cheat/releases) 31 | 32 | # How it works 33 | 34 | 1. You define a directory to store your cheat sheets, which are essentially text files. And name the file the command your wanna record. e.g, `nmap`, `top`, `tar` etc. (tips: you can start with the cheat sheets provided by [cheat](https://github.com/cheat/cheat)) 35 | 36 | 2. You write your cheat sheet according to the [rules](#parsing-rule) (very intuitive and tolerant) bit by bit. 37 | 38 | 3. Tell the workflow where that directory is and start searching. 39 | 40 | # Usage 41 | 42 | First, you need to specify your sheet directory like this. Otherwise, it doesn't work. Both absolute or relative path will work. 43 | 44 | ![](assets/config.png) 45 | 46 | Then, you're good to go. 47 | 48 | - To list all your cheat: `cheat` 49 | 50 | - To search and list the content of one of your cheat: `cheat `. Fuzzy search and autocomplete is supported. 51 | 52 | - To search in a specific sheet indexed by some keyword: `cheat `. 53 | 54 | - To search across all your sheets for some keyword: `cheat --search/-s ` 55 | 56 | - When you find your desired record and you wanna paste it directly to the app you're using (e.g., Terminal or iTerm2), hit `Enter`. This behavior can be changed in the Alfred setting ([#3](https://github.com/wx-Yao/alfred-cheat/issues/2#issuecomment-509689404)). 57 | 58 | - If you like to just copy, hit `cmd-c`. 59 | 60 | # Parsing rule 61 | 62 | It's not even a rule... You just need to remember two things when writing your cheat sheet: 63 | 64 | 1. Comment first, then the command. 65 | 66 | 2. Separate each `comment, command` pair with 2 newlines. (one newline visually) 67 | 68 | That's it. 69 | 70 | e.g. this cheat sheet is called `demosheet`. Its content is the following: 71 | 72 | ``` 73 | # This is a one line comment. 74 | command one goes here. 75 | 76 | # This is a second comment for the second command 77 | # Yes we can have multiple line comment. 78 | # But remember only the last line will be considered "command". 79 | command two goes here 80 | 81 | # 82 | command three: in rare cases you don't have any comment, keep an empty # above. 83 | 84 | # Any failed parsing will be ignored, like this line because it isn't associated with a command 85 | 86 | or this line because it's a single line. 87 | ``` 88 | 89 | The above sheet will be parsed like this: 90 | 91 | ![](assets/demosheet.png) 92 | 93 | Kindly note that **hidden cheatsheets (starting with `.`) will be ignored and hidden directory will be ignored as well**. Hierachical structure is supported but that's only for your management purpose. This tool will only "flatten" every cheatsheets in the base directory. i.e., `cheat/mydir/yourdir/somecheat` will be equivalent to `cheat/somecheat` in its perspective. Also make sure you don't have duplicated cheatsheets in different directories otherwise only one of them will be dominant. Thanks for [@Blackvz](https://github.com/Blackvz) for the feature suggestion [#4](https://github.com/wx-Yao/alfred-cheat/issues/4). 94 | 95 | # Compatibility 96 | 97 | This workflow works out of the box (zero dependencies). It's tested on **macOS 10.14.5 Mojave** with **Alfred 4**. You need the [powerpack](https://www.alfredapp.com/shop/) to get it working. I believe it works with Alfred3 on any macOS after 10.10 Yosemite but that hasn't been tested. Report an issue if there's a problem. 98 | 99 | # Contribution 100 | 101 | Any idea of improvement will be welcomed. But I don't wanna add the feature of modifying cheat sheet right in Alfred because it isn't what it is supposed to do. Use vim or other editors you like. 102 | -------------------------------------------------------------------------------- /assets/config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneyaoo/alfred-cheat/6ea437ff77c365795baccf8017ec6a5420eaf92e/assets/config.png -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneyaoo/alfred-cheat/6ea437ff77c365795baccf8017ec6a5420eaf92e/assets/demo.gif -------------------------------------------------------------------------------- /assets/demosheet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneyaoo/alfred-cheat/6ea437ff77c365795baccf8017ec6a5420eaf92e/assets/demosheet.png -------------------------------------------------------------------------------- /cheat.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | from sys import exit 3 | from workflow import Workflow3 as Workflow 4 | from lib.parser import Parser 5 | from lib.options import Options 6 | 7 | 8 | def main(workflow): 9 | # Try to read configuration from local disk 10 | config = workflow.stored_data("configuration") 11 | if config is None: 12 | Options.warning("Didn't find your configuration", "Please supply your cheat sheet path using 'cf ~/your/path'", workflow) 13 | workflow.send_feedback() 14 | return -1 15 | 16 | parser = Parser(config.getPath()) 17 | # Note: by pasing workflow as a variable, its state is changed in Options.py logic 18 | options = Options(parser, workflow) 19 | 20 | # Query is whatever comes after "cheat". Stored in one single variable 21 | query = "" if len(workflow.args) == 0 else workflow.args[0] 22 | tokens = query.strip().split(" ", 1) # len 2 list 23 | tokens = [i.strip() for i in tokens if i != ""] 24 | 25 | if len(tokens) == 0: 26 | options.showAvailable() 27 | workflow.send_feedback() 28 | return None 29 | 30 | if len(tokens) == 1 and tokens[0] in ("--search", "-s"): 31 | Options.hint("Globally searching for ...?", "In global mode", workflow) 32 | workflow.send_feedback() 33 | return None 34 | 35 | if len(tokens) == 1 and tokens[0] not in parser.availableSheets(): 36 | options.showAvailable(tokens[0]) 37 | workflow.send_feedback() 38 | return None 39 | 40 | if len(tokens) == 1: 41 | options.list(tokens[0]) 42 | workflow.send_feedback() 43 | return None 44 | 45 | sheetName = None if tokens[0] in ["--search", "-s"] else tokens[0] 46 | searchTerm = tokens[1] 47 | options.searchInSheetByKeyword(sheetName, searchTerm) 48 | 49 | workflow.send_feedback() 50 | return None 51 | 52 | 53 | if __name__ == "__main__": 54 | workflow = Workflow() 55 | exit(workflow.run(main)) 56 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Separated unit for configuration, in case we have extra features in the future. 4 | from workflow import Workflow3 as Workflow 5 | from lib.config import Config 6 | from workflow.notify import notify 7 | 8 | def main(workflow): 9 | path=workflow.args[0].strip() 10 | config=Config(path) 11 | if config.validate(): 12 | # Behavior: overwrite existing data 13 | workflow.store_data("configuration", config) 14 | notify(title="Success!", text="Cheat sheets updated to {}".format(config.getPath())) 15 | else: 16 | notify(title="Error:(", text="The path doesn't exist") 17 | return 0 18 | 19 | if __name__=="__main__": 20 | workflow=Workflow() 21 | exit(workflow.run(main)) 22 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneyaoo/alfred-cheat/6ea437ff77c365795baccf8017ec6a5420eaf92e/icon.png -------------------------------------------------------------------------------- /info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | com.wayneyao.cheat 7 | connections 8 | 9 | 5C498F63-6E9D-4A07-B42A-727B78BE3825 10 | 11 | 12 | destinationuid 13 | 92C80F9D-D58C-469A-9534-FCEC27394F49 14 | modifiers 15 | 0 16 | modifiersubtext 17 | 18 | vitoclose 19 | 20 | 21 | 22 | destinationuid 23 | 01426939-712C-4E7E-A86C-41FB76D04041 24 | modifiers 25 | 0 26 | modifiersubtext 27 | 28 | vitoclose 29 | 30 | 31 | 32 | 8D97B77C-E924-4B74-84F8-DCD90968EEAA 33 | 34 | 35 | destinationuid 36 | A6FA6395-A792-4367-9D59-7B4619B74892 37 | modifiers 38 | 0 39 | modifiersubtext 40 | 41 | vitoclose 42 | 43 | 44 | 45 | A6FA6395-A792-4367-9D59-7B4619B74892 46 | 47 | 48 | createdby 49 | Wayne Yao 50 | description 51 | Manage your self-defined cheat sheets & knowledge base in Alfred 52 | disabled 53 | 54 | name 55 | Cheat 56 | objects 57 | 58 | 59 | config 60 | 61 | autopaste 62 | 63 | clipboardtext 64 | {query} 65 | ignoredynamicplaceholders 66 | 67 | transient 68 | 69 | 70 | type 71 | alfred.workflow.output.clipboard 72 | uid 73 | 92C80F9D-D58C-469A-9534-FCEC27394F49 74 | version 75 | 3 76 | 77 | 78 | config 79 | 80 | alfredfiltersresults 81 | 82 | alfredfiltersresultsmatchmode 83 | 0 84 | argumenttreatemptyqueryasnil 85 | 86 | argumenttrimmode 87 | 1 88 | argumenttype 89 | 1 90 | escaping 91 | 102 92 | keyword 93 | cheat 94 | queuedelaycustom 95 | 1 96 | queuedelayimmediatelyinitially 97 | 98 | queuedelaymode 99 | 2 100 | queuemode 101 | 1 102 | runningsubtext 103 | wait a sec... 104 | script 105 | export PATH="/opt/homebrew/bin:/usr/local/bin:${PATH}" 106 | python3 cheat.py "{query}" 107 | scriptargtype 108 | 0 109 | scriptfile 110 | 111 | subtext 112 | 113 | title 114 | Search through your personal cheat sheets 115 | type 116 | 0 117 | withspace 118 | 119 | 120 | type 121 | alfred.workflow.input.scriptfilter 122 | uid 123 | 5C498F63-6E9D-4A07-B42A-727B78BE3825 124 | version 125 | 3 126 | 127 | 128 | config 129 | 130 | lastpathcomponent 131 | 132 | onlyshowifquerypopulated 133 | 134 | removeextension 135 | 136 | text 137 | command "{query}" pasted 138 | title 139 | Success 140 | 141 | type 142 | alfred.workflow.output.notification 143 | uid 144 | 01426939-712C-4E7E-A86C-41FB76D04041 145 | version 146 | 1 147 | 148 | 149 | config 150 | 151 | argumenttype 152 | 0 153 | keyword 154 | cf 155 | subtext 156 | e.g. /Users/wayne/.cheat or ~/.cheat 157 | text 158 | Configure your cheat sheet directory 159 | withspace 160 | 161 | 162 | type 163 | alfred.workflow.input.keyword 164 | uid 165 | 8D97B77C-E924-4B74-84F8-DCD90968EEAA 166 | version 167 | 1 168 | 169 | 170 | config 171 | 172 | concurrently 173 | 174 | escaping 175 | 102 176 | script 177 | export PATH="/opt/homebrew/bin:/usr/local/bin:${PATH}" 178 | python3 config.py "{query}" 179 | scriptargtype 180 | 0 181 | scriptfile 182 | 183 | type 184 | 0 185 | 186 | type 187 | alfred.workflow.action.script 188 | uid 189 | A6FA6395-A792-4367-9D59-7B4619B74892 190 | version 191 | 2 192 | 193 | 194 | readme 195 | Refer to github https://github.com/wayneyaoo/Alfred-cheat for details. 196 | uidata 197 | 198 | 01426939-712C-4E7E-A86C-41FB76D04041 199 | 200 | xpos 201 | 470 202 | ypos 203 | 160 204 | 205 | 5C498F63-6E9D-4A07-B42A-727B78BE3825 206 | 207 | xpos 208 | 165 209 | ypos 210 | 90 211 | 212 | 8D97B77C-E924-4B74-84F8-DCD90968EEAA 213 | 214 | xpos 215 | 165 216 | ypos 217 | 305 218 | 219 | 92C80F9D-D58C-469A-9534-FCEC27394F49 220 | 221 | xpos 222 | 465 223 | ypos 224 | 15 225 | 226 | A6FA6395-A792-4367-9D59-7B4619B74892 227 | 228 | xpos 229 | 480 230 | ypos 231 | 305 232 | 233 | 234 | userconfigurationconfig 235 | 236 | variablesdontexport 237 | 238 | version 239 | 1.2.1 240 | webaddress 241 | https://github.com/wayneyaoo 242 | 243 | 244 | -------------------------------------------------------------------------------- /lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneyaoo/alfred-cheat/6ea437ff77c365795baccf8017ec6a5420eaf92e/lib/__init__.py -------------------------------------------------------------------------------- /lib/config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import os 3 | 4 | 5 | class Config: 6 | def __init__(self, cheatPath): 7 | self._cheatPath = cheatPath 8 | return None 9 | 10 | def validate(self): 11 | if os.path.exists(os.path.expanduser(self._cheatPath)): 12 | self._cheatPath = os.path.expanduser(self._cheatPath) 13 | return True 14 | return False 15 | 16 | def getPath(self): 17 | return self._cheatPath 18 | -------------------------------------------------------------------------------- /lib/options.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | from workflow.workflow import ICON_HELP as WARNINGICON 3 | from workflow.workflow import ICON_NOTE as HINT 4 | 5 | 6 | # Switches that autually controls the workflow behavior 7 | class Options: 8 | 9 | LARGETEXTPATTERN = "{}\n\n{}" 10 | 11 | def __init__(self, parser, workflow): 12 | self._parser = parser 13 | self._workflow = workflow 14 | return None 15 | 16 | def searchInSheetByKeyword(self, sheetName, keyword): 17 | if sheetName is None: 18 | ret = self._parser.searchAcrossAll(keyword, self._workflow) 19 | else: 20 | if sheetName not in self._parser.availableSheets(): 21 | Options.warning("Cheat sheet not found.", "", self._workflow) 22 | return None 23 | ret = self._parser.searchInSheet(keyword, sheetName, self._workflow) 24 | if ret == []: 25 | Options.warning("Not found", "No match found for search {}".format(keyword), self._workflow) 26 | return None 27 | for item in ret: 28 | self._workflow.add_item( 29 | title=item["command"], 30 | subtitle=item["comment"], 31 | copytext=item.get("command"), 32 | valid=True, 33 | arg=item.get("command"), 34 | largetext=self.LARGETEXTPATTERN.format(item.get("command"), item.get("comment")) 35 | ).add_modifier( 36 | 'cmd', 37 | subtitle="open in editor", 38 | valid=True, 39 | arg=self._parser._sheetMapping.get(sheetName)) 40 | return None 41 | 42 | def list(self, sheetName): 43 | ret = self._parser.list(sheetName) 44 | if ret == []: 45 | Options.hint("Empty cheatsheet", "", self._workflow) 46 | for item in ret: 47 | self._workflow.add_item( 48 | title=item.get("command"), 49 | subtitle=item.get("comment"), 50 | valid=True, 51 | copytext=item.get("command"), 52 | arg=item.get("command"), 53 | largetext=self.LARGETEXTPATTERN.format(item.get("command"), item.get("comment")) 54 | ).add_modifier( 55 | 'cmd', 56 | subtitle="open in editor", 57 | valid=True, 58 | arg=self._parser._sheetMapping.get(sheetName)) 59 | return None 60 | 61 | def showAvailable(self, sheetName=""): 62 | ret = self._FilterSheetName(sheetName) 63 | if ret == []: 64 | Options.warning("Cheat sheet not found.", "", self._workflow) 65 | return None 66 | for sheet in ret: 67 | self._workflow.add_item( 68 | title=sheet, 69 | autocomplete=sheet, 70 | largetext=sheet 71 | ).add_modifier( 72 | 'cmd', 73 | subtitle="open in editor", 74 | valid=True, 75 | arg=self._parser._sheetMapping.get(sheet)) 76 | return None 77 | 78 | def _FilterSheetName(self, query): 79 | names = self._parser.availableSheets() 80 | return self._workflow.filter(query, names, key=lambda x: x) 81 | 82 | @staticmethod 83 | def warning(msg, subtitle, workflow): 84 | workflow.warn_empty( 85 | title=msg, 86 | subtitle=subtitle, 87 | icon=WARNINGICON, 88 | ) 89 | return None 90 | 91 | @staticmethod 92 | def hint(msg, subtitle, workflow): 93 | workflow.warn_empty( 94 | title=msg, 95 | subtitle=subtitle, 96 | icon=HINT, 97 | ) 98 | return None 99 | -------------------------------------------------------------------------------- /lib/parser.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | from workflow import MATCH_ALL, MATCH_ALLCHARS 4 | 5 | 6 | class Parser: 7 | def __init__(self, path): 8 | self._path = path 9 | self._sheetMapping = {} # {cheatsheet: /home/somedir/cheatsheet} 10 | self._available = self._enumAvailableSheets() 11 | return None 12 | 13 | def availableSheets(self): 14 | return self._available 15 | 16 | def _enumAvailableSheets(self): 17 | ret = [] 18 | for root, dirname, files in os.walk(self._path, followlinks=True): 19 | dirname[:] = [d for d in dirname if not d.startswith(".")] 20 | files = [f for f in files if not f.startswith(".")] 21 | ret.extend(files) 22 | # update the cheat sheet mapping so that we can find the file location 23 | self._sheetMapping.update({cheatsheet: "".join([root, "/", cheatsheet]) for cheatsheet in files}) 24 | return ret 25 | 26 | def list(self, sheetName): 27 | return [] if sheetName not in self._available else self.__parseSheet(sheetName) # return value: [{}, {}, {}, ...] 28 | 29 | def searchAcrossAll(self, keyword, workflow): 30 | ret = [] 31 | for sheet in self._available: 32 | ret.extend(self.__parseSheet(sheet)) 33 | return self.filter(ret, keyword, workflow) # [{}, {}, {}...] 34 | 35 | def searchInSheet(self, keyword, sheetName, workflow): 36 | return self.filter(self.__parseSheet(sheetName), keyword, workflow) 37 | 38 | def __parseSheet(self, filename): 39 | with open(self._sheetMapping.get(filename), 'r') as f: 40 | content = f.read().strip() 41 | # Tokenize to get each "item" by spliting the "\n\n". This rule must be repspected 42 | content = [item.strip() for item in content.split("\n\n")] 43 | # ASSUME the item pattern is "comment, comment, comment ..., command" 44 | # An item doesn't have to contain comments but must have a command 45 | items = [] 46 | for item in content: 47 | try: 48 | comment, command = item.rsplit("\n", 1) 49 | except ValueError: 50 | continue 51 | # Or you wanna see which line doesn't comform to the format 52 | # comment="" 53 | # command="Fail to parse: {}".format(item) 54 | # cleanup "#" and \n 55 | comment = comment.replace("#", "").replace("\n", ". ").strip() 56 | command = command.strip() 57 | items.append((comment, command)) 58 | return [dict(comment=comment, command=command) for comment, command in items] # [{}, {}, {}...] 59 | 60 | def filter(self, content, keyword, workflow): 61 | def searchIndex(item): 62 | return u" ".join([item["comment"], item["command"]]) 63 | return workflow.filter(keyword, content, key=searchIndex, match_on=MATCH_ALL ^ MATCH_ALLCHARS, min_score=50) 64 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [install] 2 | prefix= 3 | -------------------------------------------------------------------------------- /version: -------------------------------------------------------------------------------- 1 | 1.2.2 2 | -------------------------------------------------------------------------------- /workflow/.alfredversionchecked: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneyaoo/alfred-cheat/6ea437ff77c365795baccf8017ec6a5420eaf92e/workflow/.alfredversionchecked -------------------------------------------------------------------------------- /workflow/Notify.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wayneyaoo/alfred-cheat/6ea437ff77c365795baccf8017ec6a5420eaf92e/workflow/Notify.tgz -------------------------------------------------------------------------------- /workflow/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 Dean Jackson 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2014-02-15 9 | # 10 | 11 | """A helper library for `Alfred `_ workflows.""" 12 | 13 | import os 14 | 15 | # Filter matching rules 16 | # Icons 17 | # Exceptions 18 | # Workflow objects 19 | from .workflow import ( 20 | ICON_ACCOUNT, 21 | ICON_BURN, 22 | ICON_CLOCK, 23 | ICON_COLOR, 24 | ICON_COLOUR, 25 | ICON_EJECT, 26 | ICON_ERROR, 27 | ICON_FAVORITE, 28 | ICON_FAVOURITE, 29 | ICON_GROUP, 30 | ICON_HELP, 31 | ICON_HOME, 32 | ICON_INFO, 33 | ICON_NETWORK, 34 | ICON_NOTE, 35 | ICON_SETTINGS, 36 | ICON_SWIRL, 37 | ICON_SWITCH, 38 | ICON_SYNC, 39 | ICON_TRASH, 40 | ICON_USER, 41 | ICON_WARNING, 42 | ICON_WEB, 43 | MATCH_ALL, 44 | MATCH_ALLCHARS, 45 | MATCH_ATOM, 46 | MATCH_CAPITALS, 47 | MATCH_INITIALS, 48 | MATCH_INITIALS_CONTAIN, 49 | MATCH_INITIALS_STARTSWITH, 50 | MATCH_STARTSWITH, 51 | MATCH_SUBSTRING, 52 | KeychainError, 53 | PasswordNotFound, 54 | Workflow, 55 | manager, 56 | ) 57 | from .workflow3 import Variables, Workflow3 58 | 59 | __title__ = "Alfred-Workflow" 60 | __version__ = open(os.path.join(os.path.dirname(__file__), "version")).read() 61 | __author__ = "Dean Jackson" 62 | __licence__ = "MIT" 63 | __copyright__ = "Copyright 2014-2019 Dean Jackson" 64 | 65 | __all__ = [ 66 | "Variables", 67 | "Workflow", 68 | "Workflow3", 69 | "manager", 70 | "PasswordNotFound", 71 | "KeychainError", 72 | "ICON_ACCOUNT", 73 | "ICON_BURN", 74 | "ICON_CLOCK", 75 | "ICON_COLOR", 76 | "ICON_COLOUR", 77 | "ICON_EJECT", 78 | "ICON_ERROR", 79 | "ICON_FAVORITE", 80 | "ICON_FAVOURITE", 81 | "ICON_GROUP", 82 | "ICON_HELP", 83 | "ICON_HOME", 84 | "ICON_INFO", 85 | "ICON_NETWORK", 86 | "ICON_NOTE", 87 | "ICON_SETTINGS", 88 | "ICON_SWIRL", 89 | "ICON_SWITCH", 90 | "ICON_SYNC", 91 | "ICON_TRASH", 92 | "ICON_USER", 93 | "ICON_WARNING", 94 | "ICON_WEB", 95 | "MATCH_ALL", 96 | "MATCH_ALLCHARS", 97 | "MATCH_ATOM", 98 | "MATCH_CAPITALS", 99 | "MATCH_INITIALS", 100 | "MATCH_INITIALS_CONTAIN", 101 | "MATCH_INITIALS_STARTSWITH", 102 | "MATCH_STARTSWITH", 103 | "MATCH_SUBSTRING", 104 | ] 105 | -------------------------------------------------------------------------------- /workflow/background.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2014 deanishe@deanishe.net 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2014-04-06 8 | # 9 | 10 | """This module provides an API to run commands in background processes. 11 | 12 | Combine with the :ref:`caching API ` to work from cached data 13 | while you fetch fresh data in the background. 14 | 15 | See :ref:`the User Manual ` for more information 16 | and examples. 17 | """ 18 | 19 | 20 | import os 21 | import pickle 22 | import signal 23 | import subprocess 24 | import sys 25 | 26 | from workflow import Workflow 27 | 28 | __all__ = ["is_running", "run_in_background"] 29 | 30 | _wf = None 31 | 32 | 33 | def wf(): 34 | global _wf 35 | if _wf is None: 36 | _wf = Workflow() 37 | return _wf 38 | 39 | 40 | def _log(): 41 | return wf().logger 42 | 43 | 44 | def _arg_cache(name): 45 | """Return path to pickle cache file for arguments. 46 | 47 | :param name: name of task 48 | :type name: ``unicode`` 49 | :returns: Path to cache file 50 | :rtype: ``unicode`` filepath 51 | 52 | """ 53 | return wf().cachefile(name + ".argcache") 54 | 55 | 56 | def _pid_file(name): 57 | """Return path to PID file for ``name``. 58 | 59 | :param name: name of task 60 | :type name: ``unicode`` 61 | :returns: Path to PID file for task 62 | :rtype: ``unicode`` filepath 63 | 64 | """ 65 | return wf().cachefile(name + ".pid") 66 | 67 | 68 | def _process_exists(pid): 69 | """Check if a process with PID ``pid`` exists. 70 | 71 | :param pid: PID to check 72 | :type pid: ``int`` 73 | :returns: ``True`` if process exists, else ``False`` 74 | :rtype: ``Boolean`` 75 | 76 | """ 77 | try: 78 | os.kill(pid, 0) 79 | except OSError: # not running 80 | return False 81 | return True 82 | 83 | 84 | def _job_pid(name): 85 | """Get PID of job or `None` if job does not exist. 86 | 87 | Args: 88 | name (str): Name of job. 89 | 90 | Returns: 91 | int: PID of job process (or `None` if job doesn't exist). 92 | """ 93 | pidfile = _pid_file(name) 94 | if not os.path.exists(pidfile): 95 | return 96 | 97 | with open(pidfile, "rb") as fp: 98 | read = fp.read() 99 | # print(str(read)) 100 | pid = int.from_bytes(read, sys.byteorder) 101 | # print(pid) 102 | 103 | if _process_exists(pid): 104 | return pid 105 | 106 | os.unlink(pidfile) 107 | 108 | 109 | def is_running(name): 110 | """Test whether task ``name`` is currently running. 111 | 112 | :param name: name of task 113 | :type name: unicode 114 | :returns: ``True`` if task with name ``name`` is running, else ``False`` 115 | :rtype: bool 116 | 117 | """ 118 | if _job_pid(name) is not None: 119 | return True 120 | 121 | return False 122 | 123 | 124 | def _background( 125 | pidfile, stdin="/dev/null", stdout="/dev/null", stderr="/dev/null" 126 | ): # pragma: no cover 127 | """Fork the current process into a background daemon. 128 | 129 | :param pidfile: file to write PID of daemon process to. 130 | :type pidfile: filepath 131 | :param stdin: where to read input 132 | :type stdin: filepath 133 | :param stdout: where to write stdout output 134 | :type stdout: filepath 135 | :param stderr: where to write stderr output 136 | :type stderr: filepath 137 | 138 | """ 139 | 140 | def _fork_and_exit_parent(errmsg, wait=False, write=False): 141 | try: 142 | pid = os.fork() 143 | if pid > 0: 144 | if write: # write PID of child process to `pidfile` 145 | tmp = pidfile + ".tmp" 146 | with open(tmp, "wb") as fp: 147 | fp.write(pid.to_bytes(4, sys.byteorder)) 148 | os.rename(tmp, pidfile) 149 | if wait: # wait for child process to exit 150 | os.waitpid(pid, 0) 151 | os._exit(0) 152 | except OSError as err: 153 | _log().critical("%s: (%d) %s", errmsg, err.errno, err.strerror) 154 | raise err 155 | 156 | # Do first fork and wait for second fork to finish. 157 | _fork_and_exit_parent("fork #1 failed", wait=True) 158 | 159 | # Decouple from parent environment. 160 | os.chdir(wf().workflowdir) 161 | os.setsid() 162 | 163 | # Do second fork and write PID to pidfile. 164 | _fork_and_exit_parent("fork #2 failed", write=True) 165 | 166 | # Now I am a daemon! 167 | # Redirect standard file descriptors. 168 | si = open(stdin, "r", 1) 169 | so = open(stdout, "a+", 1) 170 | se = open(stderr, "a+", 1) 171 | if hasattr(sys.stdin, "fileno"): 172 | os.dup2(si.fileno(), sys.stdin.fileno()) 173 | if hasattr(sys.stdout, "fileno"): 174 | os.dup2(so.fileno(), sys.stdout.fileno()) 175 | if hasattr(sys.stderr, "fileno"): 176 | os.dup2(se.fileno(), sys.stderr.fileno()) 177 | 178 | 179 | def kill(name, sig=signal.SIGTERM): 180 | """Send a signal to job ``name`` via :func:`os.kill`. 181 | 182 | .. versionadded:: 1.29 183 | 184 | Args: 185 | name (str): Name of the job 186 | sig (int, optional): Signal to send (default: SIGTERM) 187 | 188 | Returns: 189 | bool: `False` if job isn't running, `True` if signal was sent. 190 | """ 191 | pid = _job_pid(name) 192 | if pid is None: 193 | return False 194 | 195 | os.kill(pid, sig) 196 | return True 197 | 198 | 199 | def run_in_background(name, args, **kwargs): 200 | r"""Cache arguments then call this script again via :func:`subprocess.call`. 201 | 202 | :param name: name of job 203 | :type name: unicode 204 | :param args: arguments passed as first argument to :func:`subprocess.call` 205 | :param \**kwargs: keyword arguments to :func:`subprocess.call` 206 | :returns: exit code of sub-process 207 | :rtype: int 208 | 209 | When you call this function, it caches its arguments and then calls 210 | ``background.py`` in a subprocess. The Python subprocess will load the 211 | cached arguments, fork into the background, and then run the command you 212 | specified. 213 | 214 | This function will return as soon as the ``background.py`` subprocess has 215 | forked, returning the exit code of *that* process (i.e. not of the command 216 | you're trying to run). 217 | 218 | If that process fails, an error will be written to the log file. 219 | 220 | If a process is already running under the same name, this function will 221 | return immediately and will not run the specified command. 222 | 223 | """ 224 | if is_running(name): 225 | _log().info("[%s] job already running", name) 226 | return 227 | 228 | argcache = _arg_cache(name) 229 | 230 | # Cache arguments 231 | with open(argcache, "wb") as fp: 232 | pickle.dump({"args": args, "kwargs": kwargs}, fp) 233 | _log().debug("[%s] command cached: %s", name, argcache) 234 | 235 | # Call this script 236 | cmd = [sys.executable, "-m", "workflow.background", name] 237 | _log().debug("[%s] passing job to background runner: %r", name, cmd) 238 | retcode = subprocess.call(cmd) 239 | 240 | if retcode: # pragma: no cover 241 | _log().error("[%s] background runner failed with %d", name, retcode) 242 | else: 243 | _log().debug("[%s] background job started", name) 244 | 245 | return retcode 246 | 247 | 248 | def main(wf): # pragma: no cover 249 | """Run command in a background process. 250 | 251 | Load cached arguments, fork into background, then call 252 | :meth:`subprocess.call` with cached arguments. 253 | 254 | """ 255 | log = wf.logger 256 | name = wf.args[0] 257 | argcache = _arg_cache(name) 258 | if not os.path.exists(argcache): 259 | msg = "[{0}] command cache not found: {1}".format(name, argcache) 260 | log.critical(msg) 261 | raise IOError(msg) 262 | 263 | # Fork to background and run command 264 | pidfile = _pid_file(name) 265 | _background(pidfile) 266 | 267 | # Load cached arguments 268 | with open(argcache, "rb") as fp: 269 | data = pickle.load(fp) 270 | 271 | # Cached arguments 272 | args = data["args"] 273 | kwargs = data["kwargs"] 274 | 275 | # Delete argument cache file 276 | os.unlink(argcache) 277 | 278 | try: 279 | # Run the command 280 | log.debug("[%s] running command: %r", name, args) 281 | 282 | retcode = subprocess.call(args, **kwargs) 283 | 284 | if retcode: 285 | log.error("[%s] command failed with status %d", name, retcode) 286 | finally: 287 | os.unlink(pidfile) 288 | 289 | log.debug("[%s] job complete", name) 290 | 291 | 292 | if __name__ == "__main__": # pragma: no cover 293 | wf().run(main) 294 | -------------------------------------------------------------------------------- /workflow/notify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2015 deanishe@deanishe.net 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2015-11-26 9 | # 10 | 11 | # TODO: Exclude this module from test and code coverage in py2.6 12 | 13 | """ 14 | Post notifications via the macOS Notification Center. 15 | 16 | This feature is only available on Mountain Lion (10.8) and later. 17 | It will silently fail on older systems. 18 | 19 | The main API is a single function, :func:`~workflow.notify.notify`. 20 | 21 | It works by copying a simple application to your workflow's data 22 | directory. It replaces the application's icon with your workflow's 23 | icon and then calls the application to post notifications. 24 | """ 25 | 26 | 27 | import os 28 | import plistlib 29 | import shutil 30 | import subprocess 31 | import sys 32 | import tarfile 33 | import tempfile 34 | import uuid 35 | from typing import List 36 | 37 | from . import workflow 38 | 39 | _wf = None 40 | _log = None 41 | 42 | 43 | #: Available system sounds from System Preferences > Sound > Sound Effects 44 | SOUNDS = ( 45 | "Basso", 46 | "Blow", 47 | "Bottle", 48 | "Frog", 49 | "Funk", 50 | "Glass", 51 | "Hero", 52 | "Morse", 53 | "Ping", 54 | "Pop", 55 | "Purr", 56 | "Sosumi", 57 | "Submarine", 58 | "Tink", 59 | ) 60 | 61 | 62 | def wf(): 63 | """Return Workflow object for this module. 64 | 65 | Returns: 66 | workflow.Workflow: Workflow object for current workflow. 67 | """ 68 | global _wf 69 | if _wf is None: 70 | _wf = workflow.Workflow() 71 | return _wf 72 | 73 | 74 | def log(): 75 | """Return logger for this module. 76 | 77 | Returns: 78 | logging.Logger: Logger for this module. 79 | """ 80 | global _log 81 | if _log is None: 82 | _log = wf().logger 83 | return _log 84 | 85 | 86 | def notifier_program(): 87 | """Return path to notifier applet executable. 88 | 89 | Returns: 90 | unicode: Path to Notify.app ``applet`` executable. 91 | """ 92 | return wf().datafile("Notify.app/Contents/MacOS/applet") 93 | 94 | 95 | def notifier_icon_path(): 96 | """Return path to icon file in installed Notify.app. 97 | 98 | Returns: 99 | unicode: Path to ``applet.icns`` within the app bundle. 100 | """ 101 | return wf().datafile("Notify.app/Contents/Resources/applet.icns") 102 | 103 | 104 | def install_notifier(): 105 | """Extract ``Notify.app`` from the workflow to data directory. 106 | 107 | Changes the bundle ID of the installed app and gives it the 108 | workflow's icon. 109 | """ 110 | archive = os.path.join(os.path.dirname(__file__), "Notify.tgz") 111 | destdir = wf().datadir 112 | app_path = os.path.join(destdir, "Notify.app") 113 | n = notifier_program() 114 | log().debug("installing Notify.app to %r ...", destdir) 115 | # z = zipfile.ZipFile(archive, 'r') 116 | # z.extractall(destdir) 117 | tgz = tarfile.open(archive, "r:gz") 118 | tgz.extractall(destdir) 119 | if not os.path.exists(n): # pragma: nocover 120 | raise RuntimeError("Notify.app could not be installed in " + destdir) 121 | 122 | # Replace applet icon 123 | icon = notifier_icon_path() 124 | workflow_icon = wf().workflowfile("icon.png") 125 | if os.path.exists(icon): 126 | os.unlink(icon) 127 | 128 | png_to_icns(workflow_icon, icon) 129 | 130 | # Set file icon 131 | # PyObjC isn't available for 2.6, so this is 2.7 only. Actually, 132 | # none of this code will "work" on pre-10.8 systems. Let it run 133 | # until I figure out a better way of excluding this module 134 | # from coverage in py2.6. 135 | if sys.version_info >= (2, 7): # pragma: no cover 136 | from AppKit import NSImage, NSWorkspace 137 | 138 | ws = NSWorkspace.sharedWorkspace() 139 | img = NSImage.alloc().init() 140 | img.initWithContentsOfFile_(icon) 141 | ws.setIcon_forFile_options_(img, app_path, 0) 142 | 143 | # Change bundle ID of installed app 144 | ip_path = os.path.join(app_path, "Contents/Info.plist") 145 | bundle_id = "{0}.{1}".format(wf().bundleid, uuid.uuid4().hex) 146 | data = plistlib.load(ip_path) 147 | log().debug("changing bundle ID to %r", bundle_id) 148 | data["CFBundleIdentifier"] = bundle_id 149 | plistlib.dump(data, ip_path) 150 | 151 | 152 | def validate_sound(sound): 153 | """Coerce ``sound`` to valid sound name. 154 | 155 | Returns ``None`` for invalid sounds. Sound names can be found 156 | in ``System Preferences > Sound > Sound Effects``. 157 | 158 | Args: 159 | sound (str): Name of system sound. 160 | 161 | Returns: 162 | str: Proper name of sound or ``None``. 163 | """ 164 | if not sound: 165 | return None 166 | 167 | # Case-insensitive comparison of `sound` 168 | if sound.lower() in [s.lower() for s in SOUNDS]: 169 | # Title-case is correct for all system sounds as of macOS 10.11 170 | return sound.title() 171 | return None 172 | 173 | 174 | def notify(title="", text="", sound=None): 175 | """Post notification via Notify.app helper. 176 | 177 | Args: 178 | title (str, optional): Notification title. 179 | text (str, optional): Notification body text. 180 | sound (str, optional): Name of sound to play. 181 | 182 | Raises: 183 | ValueError: Raised if both ``title`` and ``text`` are empty. 184 | 185 | Returns: 186 | bool: ``True`` if notification was posted, else ``False``. 187 | """ 188 | if title == text == "": 189 | raise ValueError("Empty notification") 190 | 191 | sound = validate_sound(sound) or "" 192 | 193 | n = notifier_program() 194 | 195 | if not os.path.exists(n): 196 | install_notifier() 197 | 198 | env = os.environ.copy() 199 | enc = "utf-8" 200 | env["NOTIFY_TITLE"] = title.encode(enc) 201 | env["NOTIFY_MESSAGE"] = text.encode(enc) 202 | env["NOTIFY_SOUND"] = sound.encode(enc) 203 | cmd = [n] 204 | retcode = subprocess.call(cmd, env=env) 205 | if retcode == 0: 206 | return True 207 | 208 | log().error("Notify.app exited with status {0}.".format(retcode)) 209 | return False 210 | 211 | 212 | def usr_bin_env(*args: str) -> List[str]: 213 | return ["/usr/bin/env", f'PATH={os.environ["PATH"]}'] + list(args) 214 | 215 | 216 | def convert_image(inpath, outpath, size): 217 | """Convert an image file using ``sips``. 218 | 219 | Args: 220 | inpath (str): Path of source file. 221 | outpath (str): Path to destination file. 222 | size (int): Width and height of destination image in pixels. 223 | 224 | Raises: 225 | RuntimeError: Raised if ``sips`` exits with non-zero status. 226 | """ 227 | cmd = ["sips", "-z", str(size), str(size), inpath, "--out", outpath] 228 | # log().debug(cmd) 229 | with open(os.devnull, "w") as pipe: 230 | retcode = subprocess.call( 231 | cmd, shell=True, stdout=pipe, stderr=subprocess.STDOUT 232 | ) 233 | 234 | if retcode != 0: 235 | raise RuntimeError("sips exited with %d" % retcode) 236 | 237 | 238 | def png_to_icns(png_path, icns_path): 239 | """Convert PNG file to ICNS using ``iconutil``. 240 | 241 | Create an iconset from the source PNG file. Generate PNG files 242 | in each size required by macOS, then call ``iconutil`` to turn 243 | them into a single ICNS file. 244 | 245 | Args: 246 | png_path (str): Path to source PNG file. 247 | icns_path (str): Path to destination ICNS file. 248 | 249 | Raises: 250 | RuntimeError: Raised if ``iconutil`` or ``sips`` fail. 251 | """ 252 | tempdir = tempfile.mkdtemp(prefix="aw-", dir=wf().datadir) 253 | 254 | try: 255 | iconset = os.path.join(tempdir, "Icon.iconset") 256 | 257 | if os.path.exists(iconset): # pragma: nocover 258 | raise RuntimeError("iconset already exists: " + iconset) 259 | 260 | os.makedirs(iconset) 261 | 262 | # Copy source icon to icon set and generate all the other 263 | # sizes needed 264 | configs = [] 265 | for i in (16, 32, 128, 256, 512): 266 | configs.append(("icon_{0}x{0}.png".format(i), i)) 267 | configs.append((("icon_{0}x{0}@2x.png".format(i), i * 2))) 268 | 269 | shutil.copy(png_path, os.path.join(iconset, "icon_256x256.png")) 270 | shutil.copy(png_path, os.path.join(iconset, "icon_128x128@2x.png")) 271 | 272 | for name, size in configs: 273 | outpath = os.path.join(iconset, name) 274 | if os.path.exists(outpath): 275 | continue 276 | convert_image(png_path, outpath, size) 277 | 278 | cmd = ["iconutil", "-c", "icns", "-o", icns_path, iconset] 279 | 280 | retcode = subprocess.call(cmd) 281 | if retcode != 0: 282 | raise RuntimeError("iconset exited with %d" % retcode) 283 | 284 | if not os.path.exists(icns_path): # pragma: nocover 285 | raise ValueError("generated ICNS file not found: " + repr(icns_path)) 286 | finally: 287 | try: 288 | shutil.rmtree(tempdir) 289 | except OSError: # pragma: no cover 290 | pass 291 | 292 | 293 | if __name__ == "__main__": # pragma: nocover 294 | # Simple command-line script to test module with 295 | # This won't work on 2.6, as `argparse` isn't available 296 | # by default. 297 | import argparse 298 | from unicodedata import normalize 299 | 300 | def ustr(s): 301 | """Coerce `s` to normalised Unicode.""" 302 | return normalize("NFD", s.decode("utf-8")) 303 | 304 | p = argparse.ArgumentParser() 305 | p.add_argument("-p", "--png", help="PNG image to convert to ICNS.") 306 | p.add_argument( 307 | "-l", "--list-sounds", help="Show available sounds.", action="store_true" 308 | ) 309 | p.add_argument("-t", "--title", help="Notification title.", type=ustr, default="") 310 | p.add_argument( 311 | "-s", "--sound", type=ustr, help="Optional notification sound.", default="" 312 | ) 313 | p.add_argument( 314 | "text", type=ustr, help="Notification body text.", default="", nargs="?" 315 | ) 316 | o = p.parse_args() 317 | 318 | # List available sounds 319 | if o.list_sounds: 320 | for sound in SOUNDS: 321 | print(sound) 322 | sys.exit(0) 323 | 324 | # Convert PNG to ICNS 325 | if o.png: 326 | icns = os.path.join( 327 | os.path.dirname(o.png), 328 | os.path.splitext(os.path.basename(o.png))[0] + ".icns", 329 | ) 330 | 331 | print("converting {0!r} to {1!r} ...".format(o.png, icns), file=sys.stderr) 332 | 333 | if os.path.exists(icns): 334 | raise ValueError("destination file already exists: " + icns) 335 | 336 | png_to_icns(o.png, icns) 337 | sys.exit(0) 338 | 339 | # Post notification 340 | if o.title == o.text == "": 341 | print("ERROR: empty notification.", file=sys.stderr) 342 | sys.exit(1) 343 | else: 344 | notify(o.title, o.text, o.sound) 345 | -------------------------------------------------------------------------------- /workflow/update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 Fabio Niephaus , 5 | # Dean Jackson 6 | # 7 | # MIT Licence. See http://opensource.org/licenses/MIT 8 | # 9 | # Created on 2014-08-16 10 | # 11 | 12 | """Self-updating from GitHub. 13 | 14 | .. versionadded:: 1.9 15 | 16 | .. note:: 17 | 18 | This module is not intended to be used directly. Automatic updates 19 | are controlled by the ``update_settings`` :class:`dict` passed to 20 | :class:`~workflow.workflow.Workflow` objects. 21 | 22 | """ 23 | 24 | 25 | import json 26 | import os 27 | import re 28 | import subprocess 29 | import tempfile 30 | from collections import defaultdict 31 | from functools import total_ordering 32 | from itertools import zip_longest 33 | from urllib import request 34 | 35 | from workflow.util import atomic_writer 36 | 37 | from . import workflow 38 | 39 | # __all__ = [] 40 | 41 | 42 | RELEASES_BASE = "https://api.github.com/repos/{}/releases" 43 | match_workflow = re.compile(r"\.alfred(\d+)?workflow$").search 44 | 45 | _wf = None 46 | 47 | 48 | def wf(): 49 | """Lazy `Workflow` object.""" 50 | global _wf 51 | if _wf is None: 52 | _wf = workflow.Workflow() 53 | return _wf 54 | 55 | 56 | @total_ordering 57 | class Download(object): 58 | """A workflow file that is available for download. 59 | 60 | .. versionadded: 1.37 61 | 62 | Attributes: 63 | url (str): URL of workflow file. 64 | filename (str): Filename of workflow file. 65 | version (Version): Semantic version of workflow. 66 | prerelease (bool): Whether version is a pre-release. 67 | alfred_version (Version): Minimum compatible version 68 | of Alfred. 69 | 70 | """ 71 | 72 | @classmethod 73 | def from_dict(cls, d): 74 | """Create a `Download` from a `dict`.""" 75 | return cls( 76 | url=d["url"], 77 | filename=d["filename"], 78 | version=Version(d["version"]), 79 | prerelease=d["prerelease"], 80 | ) 81 | 82 | @classmethod 83 | def from_releases(cls, js): 84 | """Extract downloads from GitHub releases. 85 | 86 | Searches releases with semantic tags for assets with 87 | file extension .alfredworkflow or .alfredXworkflow where 88 | X is a number. 89 | 90 | Files are returned sorted by latest version first. Any 91 | releases containing multiple files with the same (workflow) 92 | extension are rejected as ambiguous. 93 | 94 | Args: 95 | js (str): JSON response from GitHub's releases endpoint. 96 | 97 | Returns: 98 | list: Sequence of `Download`. 99 | """ 100 | releases = json.loads(js) 101 | downloads = [] 102 | for release in releases: 103 | tag = release["tag_name"] 104 | dupes = defaultdict(int) 105 | try: 106 | version = Version(tag) 107 | except ValueError as err: 108 | wf().logger.debug('ignored release: bad version "%s": %s', tag, err) 109 | continue 110 | 111 | dls = [] 112 | for asset in release.get("assets", []): 113 | url = asset.get("browser_download_url") 114 | filename = os.path.basename(url) 115 | m = match_workflow(filename) 116 | if not m: 117 | wf().logger.debug("unwanted file: %s", filename) 118 | continue 119 | 120 | ext = m.group(0) 121 | dupes[ext] = dupes[ext] + 1 122 | dls.append(Download(url, filename, version, release["prerelease"])) 123 | 124 | valid = True 125 | for ext, n in list(dupes.items()): 126 | if n > 1: 127 | wf().logger.debug( 128 | 'ignored release "%s": multiple assets ' 'with extension "%s"', 129 | tag, 130 | ext, 131 | ) 132 | valid = False 133 | break 134 | 135 | if valid: 136 | downloads.extend(dls) 137 | 138 | downloads.sort(reverse=True) 139 | return downloads 140 | 141 | def __init__(self, url, filename, version, prerelease=False): 142 | """Create a new Download. 143 | 144 | Args: 145 | url (str): URL of workflow file. 146 | filename (str): Filename of workflow file. 147 | version (Version): Version of workflow. 148 | prerelease (bool, optional): Whether version is 149 | pre-release. Defaults to False. 150 | 151 | """ 152 | if isinstance(version, str): 153 | version = Version(version) 154 | 155 | self.url = url 156 | self.filename = filename 157 | self.version = version 158 | self.prerelease = prerelease 159 | 160 | @property 161 | def alfred_version(self): 162 | """Minimum Alfred version based on filename extension.""" 163 | m = match_workflow(self.filename) 164 | if not m or not m.group(1): 165 | return Version("0") 166 | return Version(m.group(1)) 167 | 168 | @property 169 | def dict(self): 170 | """Convert `Download` to `dict`.""" 171 | return dict( 172 | url=self.url, 173 | filename=self.filename, 174 | version=str(self.version), 175 | prerelease=self.prerelease, 176 | ) 177 | 178 | def __str__(self): 179 | """Format `Download` for printing.""" 180 | return ( 181 | "Download(" 182 | "url={dl.url!r}, " 183 | "filename={dl.filename!r}, " 184 | "version={dl.version!r}, " 185 | "prerelease={dl.prerelease!r}" 186 | ")" 187 | ).format(dl=self) 188 | 189 | def __repr__(self): 190 | """Code-like representation of `Download`.""" 191 | return str(self) 192 | 193 | def __eq__(self, other): 194 | """Compare Downloads based on version numbers.""" 195 | if ( 196 | self.url != other.url 197 | or self.filename != other.filename 198 | or self.version != other.version 199 | or self.prerelease != other.prerelease 200 | ): 201 | return False 202 | return True 203 | 204 | def __ne__(self, other): 205 | """Compare Downloads based on version numbers.""" 206 | return not self.__eq__(other) 207 | 208 | def __lt__(self, other): 209 | """Compare Downloads based on version numbers.""" 210 | if self.version != other.version: 211 | return self.version < other.version 212 | return self.alfred_version < other.alfred_version 213 | 214 | 215 | class Version(object): 216 | """Mostly semantic versioning. 217 | 218 | The main difference to proper :ref:`semantic versioning ` 219 | is that this implementation doesn't require a minor or patch version. 220 | 221 | Version strings may also be prefixed with "v", e.g.: 222 | 223 | >>> v = Version('v1.1.1') 224 | >>> v.tuple 225 | (1, 1, 1, '') 226 | 227 | >>> v = Version('2.0') 228 | >>> v.tuple 229 | (2, 0, 0, '') 230 | 231 | >>> Version('3.1-beta').tuple 232 | (3, 1, 0, 'beta') 233 | 234 | >>> Version('1.0.1') > Version('0.0.1') 235 | True 236 | """ 237 | 238 | #: Match version and pre-release/build information in version strings 239 | match_version = re.compile(r"([0-9][0-9\.]*)(.+)?").match 240 | 241 | def __init__(self, vstr): 242 | """Create new `Version` object. 243 | 244 | Args: 245 | vstr (basestring): Semantic version string. 246 | """ 247 | if not vstr: 248 | raise ValueError("invalid version number: {!r}".format(vstr)) 249 | 250 | self.vstr = vstr 251 | self.major = 0 252 | self.minor = 0 253 | self.patch = 0 254 | self.suffix = "" 255 | self.build = "" 256 | self._parse(vstr) 257 | 258 | def _parse(self, vstr): 259 | vstr = str(vstr) 260 | if vstr.startswith("v"): 261 | m = self.match_version(vstr[1:]) 262 | else: 263 | m = self.match_version(vstr) 264 | if not m: 265 | raise ValueError("invalid version number: " + vstr) 266 | 267 | version, suffix = m.groups() 268 | parts = self._parse_dotted_string(version) 269 | self.major = parts.pop(0) 270 | if len(parts): 271 | self.minor = parts.pop(0) 272 | if len(parts): 273 | self.patch = parts.pop(0) 274 | if not len(parts) == 0: 275 | raise ValueError("version number too long: " + vstr) 276 | 277 | if suffix: 278 | # Build info 279 | idx = suffix.find("+") 280 | if idx > -1: 281 | self.build = suffix[idx + 1 :] 282 | suffix = suffix[:idx] 283 | if suffix: 284 | if not suffix.startswith("-"): 285 | raise ValueError("suffix must start with - : " + suffix) 286 | self.suffix = suffix[1:] 287 | 288 | def _parse_dotted_string(self, s): 289 | """Parse string ``s`` into list of ints and strings.""" 290 | parsed = [] 291 | parts = s.split(".") 292 | for p in parts: 293 | if p.isdigit(): 294 | p = int(p) 295 | parsed.append(p) 296 | return parsed 297 | 298 | @property 299 | def tuple(self): 300 | """Version number as a tuple of major, minor, patch, pre-release.""" 301 | return (self.major, self.minor, self.patch, self.suffix) 302 | 303 | def __lt__(self, other): 304 | """Implement comparison.""" 305 | if not isinstance(other, Version): 306 | raise ValueError("not a Version instance: {0!r}".format(other)) 307 | t = self.tuple[:3] 308 | o = other.tuple[:3] 309 | if t < o: 310 | return True 311 | if t == o: # We need to compare suffixes 312 | if self.suffix and not other.suffix: 313 | return True 314 | if other.suffix and not self.suffix: 315 | return False 316 | 317 | self_suffix = self._parse_dotted_string(self.suffix) 318 | other_suffix = self._parse_dotted_string(other.suffix) 319 | 320 | for s, o in zip_longest(self_suffix, other_suffix): 321 | if s is None: # shorter value wins 322 | return True 323 | elif o is None: # longer value loses 324 | return False 325 | elif type(s) != type(o): # type coersion 326 | s, o = str(s), str(o) 327 | if s == o: # next if the same compare 328 | continue 329 | return s < o # finally compare 330 | # t > o 331 | return False 332 | 333 | def __eq__(self, other): 334 | """Implement comparison.""" 335 | if not isinstance(other, Version): 336 | raise ValueError("not a Version instance: {0!r}".format(other)) 337 | return self.tuple == other.tuple 338 | 339 | def __ne__(self, other): 340 | """Implement comparison.""" 341 | return not self.__eq__(other) 342 | 343 | def __gt__(self, other): 344 | """Implement comparison.""" 345 | if not isinstance(other, Version): 346 | raise ValueError("not a Version instance: {0!r}".format(other)) 347 | return other.__lt__(self) 348 | 349 | def __le__(self, other): 350 | """Implement comparison.""" 351 | if not isinstance(other, Version): 352 | raise ValueError("not a Version instance: {0!r}".format(other)) 353 | return not other.__lt__(self) 354 | 355 | def __ge__(self, other): 356 | """Implement comparison.""" 357 | return not self.__lt__(other) 358 | 359 | def __str__(self): 360 | """Return semantic version string.""" 361 | vstr = "{0}.{1}.{2}".format(self.major, self.minor, self.patch) 362 | if self.suffix: 363 | vstr = "{0}-{1}".format(vstr, self.suffix) 364 | if self.build: 365 | vstr = "{0}+{1}".format(vstr, self.build) 366 | return vstr 367 | 368 | def __repr__(self): 369 | """Return 'code' representation of `Version`.""" 370 | return "Version('{0}')".format(str(self)) 371 | 372 | 373 | def retrieve_download(dl): 374 | """Saves a download to a temporary file and returns path. 375 | 376 | .. versionadded: 1.37 377 | 378 | Args: 379 | url (unicode): URL to .alfredworkflow file in GitHub repo 380 | 381 | Returns: 382 | unicode: path to downloaded file 383 | 384 | """ 385 | if not match_workflow(dl.filename): 386 | raise ValueError("attachment not a workflow: " + dl.filename) 387 | 388 | path = os.path.join(tempfile.gettempdir(), dl.filename) 389 | wf().logger.debug("downloading update from " "%r to %r ...", dl.url, path) 390 | 391 | r = request.urlopen(dl.url) 392 | 393 | with atomic_writer(path, "wb") as file_obj: 394 | file_obj.write(r.read()) 395 | 396 | return path 397 | 398 | 399 | def build_api_url(repo): 400 | """Generate releases URL from GitHub repo. 401 | 402 | Args: 403 | repo (unicode): Repo name in form ``username/repo`` 404 | 405 | Returns: 406 | unicode: URL to the API endpoint for the repo's releases 407 | 408 | """ 409 | if len(repo.split("/")) != 2: 410 | raise ValueError("invalid GitHub repo: {!r}".format(repo)) 411 | 412 | return RELEASES_BASE.format(repo) 413 | 414 | 415 | def get_downloads(repo): 416 | """Load available ``Download``s for GitHub repo. 417 | 418 | .. versionadded: 1.37 419 | 420 | Args: 421 | repo (unicode): GitHub repo to load releases for. 422 | 423 | Returns: 424 | list: Sequence of `Download` contained in GitHub releases. 425 | """ 426 | url = build_api_url(repo) 427 | 428 | def _fetch(): 429 | wf().logger.info("retrieving releases for %r ...", repo) 430 | r = request.urlopen(url) 431 | return r.read() 432 | 433 | key = "github-releases-" + repo.replace("/", "-") 434 | js = wf().cached_data(key, _fetch, max_age=60) 435 | 436 | return Download.from_releases(js) 437 | 438 | 439 | def latest_download(dls, alfred_version=None, prereleases=False): 440 | """Return newest `Download`.""" 441 | alfred_version = alfred_version or os.getenv("alfred_version") 442 | version = None 443 | if alfred_version: 444 | version = Version(alfred_version) 445 | 446 | dls.sort(reverse=True) 447 | for dl in dls: 448 | if dl.prerelease and not prereleases: 449 | wf().logger.debug("ignored prerelease: %s", dl.version) 450 | continue 451 | if version and dl.alfred_version > version: 452 | wf().logger.debug( 453 | "ignored incompatible (%s > %s): %s", 454 | dl.alfred_version, 455 | version, 456 | dl.filename, 457 | ) 458 | continue 459 | 460 | wf().logger.debug("latest version: %s (%s)", dl.version, dl.filename) 461 | return dl 462 | 463 | return None 464 | 465 | 466 | def check_update(repo, current_version, prereleases=False, alfred_version=None): 467 | """Check whether a newer release is available on GitHub. 468 | 469 | Args: 470 | repo (unicode): ``username/repo`` for workflow's GitHub repo 471 | current_version (unicode): the currently installed version of the 472 | workflow. :ref:`Semantic versioning ` is required. 473 | prereleases (bool): Whether to include pre-releases. 474 | alfred_version (unicode): version of currently-running Alfred. 475 | if empty, defaults to ``$alfred_version`` environment variable. 476 | 477 | Returns: 478 | bool: ``True`` if an update is available, else ``False`` 479 | 480 | If an update is available, its version number and download URL will 481 | be cached. 482 | 483 | """ 484 | key = "__workflow_latest_version" 485 | # data stored when no update is available 486 | no_update = {"available": False, "download": None, "version": None} 487 | current = Version(current_version) 488 | 489 | dls = get_downloads(repo) 490 | if not len(dls): 491 | wf().logger.warning("no valid downloads for %s", repo) 492 | wf().cache_data(key, no_update) 493 | return False 494 | 495 | wf().logger.info("%d download(s) for %s", len(dls), repo) 496 | 497 | dl = latest_download(dls, alfred_version, prereleases) 498 | 499 | if not dl: 500 | wf().logger.warning("no compatible downloads for %s", repo) 501 | wf().cache_data(key, no_update) 502 | return False 503 | 504 | wf().logger.debug("latest=%r, installed=%r", dl.version, current) 505 | 506 | if dl.version > current: 507 | wf().cache_data( 508 | key, {"version": str(dl.version), "download": dl.dict, "available": True} 509 | ) 510 | return True 511 | 512 | wf().cache_data(key, no_update) 513 | return False 514 | 515 | 516 | def install_update(): 517 | """If a newer release is available, download and install it. 518 | 519 | :returns: ``True`` if an update is installed, else ``False`` 520 | 521 | """ 522 | key = "__workflow_latest_version" 523 | # data stored when no update is available 524 | no_update = {"available": False, "download": None, "version": None} 525 | status = wf().cached_data(key, max_age=0) 526 | 527 | if not status or not status.get("available"): 528 | wf().logger.info("no update available") 529 | return False 530 | 531 | dl = status.get("download") 532 | if not dl: 533 | wf().logger.info("no download information") 534 | return False 535 | 536 | path = retrieve_download(Download.from_dict(dl)) 537 | 538 | wf().logger.info("installing updated workflow ...") 539 | subprocess.call(["open", path]) # nosec 540 | 541 | wf().cache_data(key, no_update) 542 | return True 543 | 544 | 545 | if __name__ == "__main__": # pragma: nocover 546 | import sys 547 | 548 | prereleases = False 549 | 550 | def show_help(status=0): 551 | """Print help message.""" 552 | print("usage: update.py (check|install) " "[--prereleases] ") 553 | sys.exit(status) 554 | 555 | argv = sys.argv[:] 556 | if "-h" in argv or "--help" in argv: 557 | show_help() 558 | 559 | if "--prereleases" in argv: 560 | argv.remove("--prereleases") 561 | prereleases = True 562 | 563 | if len(argv) != 4: 564 | show_help(1) 565 | 566 | action = argv[1] 567 | repo = argv[2] 568 | version = argv[3] 569 | 570 | try: 571 | 572 | if action == "check": 573 | check_update(repo, version, prereleases) 574 | elif action == "install": 575 | install_update() 576 | else: 577 | show_help(1) 578 | 579 | except Exception as err: # ensure traceback is in log file 580 | wf().logger.exception(err) 581 | raise err 582 | -------------------------------------------------------------------------------- /workflow/util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2017 Dean Jackson 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2017-12-17 9 | # 10 | 11 | """A selection of helper functions useful for building workflows.""" 12 | 13 | 14 | import atexit 15 | import errno 16 | import fcntl 17 | import functools 18 | import json 19 | import os 20 | import signal 21 | import subprocess 22 | import sys 23 | import time 24 | from collections import namedtuple 25 | from contextlib import contextmanager 26 | from threading import Event 27 | 28 | # JXA scripts to call Alfred's API via the Scripting Bridge 29 | # {app} is automatically replaced with "Alfred 3" or 30 | # "com.runningwithcrayons.Alfred" depending on version. 31 | # 32 | # Open Alfred in search (regular) mode 33 | JXA_SEARCH = "Application({app}).search({arg});" 34 | # Open Alfred's File Actions on an argument 35 | JXA_ACTION = "Application({app}).action({arg});" 36 | # Open Alfred's navigation mode at path 37 | JXA_BROWSE = "Application({app}).browse({arg});" 38 | # Set the specified theme 39 | JXA_SET_THEME = "Application({app}).setTheme({arg});" 40 | # Call an External Trigger 41 | JXA_TRIGGER = "Application({app}).runTrigger({arg}, {opts});" 42 | # Save a variable to the workflow configuration sheet/info.plist 43 | JXA_SET_CONFIG = "Application({app}).setConfiguration({arg}, {opts});" 44 | # Delete a variable from the workflow configuration sheet/info.plist 45 | JXA_UNSET_CONFIG = "Application({app}).removeConfiguration({arg}, {opts});" 46 | # Tell Alfred to reload a workflow from disk 47 | JXA_RELOAD_WORKFLOW = "Application({app}).reloadWorkflow({arg});" 48 | 49 | 50 | class AcquisitionError(Exception): 51 | """Raised if a lock cannot be acquired.""" 52 | 53 | 54 | AppInfo = namedtuple("AppInfo", ["name", "path", "bundleid"]) 55 | """Information about an installed application. 56 | 57 | Returned by :func:`appinfo`. All attributes are Unicode. 58 | 59 | .. py:attribute:: name 60 | 61 | Name of the application, e.g. ``u'Safari'``. 62 | 63 | .. py:attribute:: path 64 | 65 | Path to the application bundle, e.g. ``u'/Applications/Safari.app'``. 66 | 67 | .. py:attribute:: bundleid 68 | 69 | Application's bundle ID, e.g. ``u'com.apple.Safari'``. 70 | 71 | """ 72 | 73 | 74 | def jxa_app_name(): 75 | """Return name of application to call currently running Alfred. 76 | 77 | .. versionadded: 1.37 78 | 79 | Returns 'Alfred 3' or 'com.runningwithcrayons.Alfred' depending 80 | on which version of Alfred is running. 81 | 82 | This name is suitable for use with ``Application(name)`` in JXA. 83 | 84 | Returns: 85 | unicode: Application name or ID. 86 | 87 | """ 88 | if os.getenv("alfred_version", "").startswith("3"): 89 | # Alfred 3 90 | return "Alfred 3" 91 | # Alfred 4+ 92 | return "com.runningwithcrayons.Alfred" 93 | 94 | 95 | def unicodify(s, encoding="utf-8", norm=None): 96 | """Ensure string is Unicode. 97 | 98 | .. versionadded:: 1.31 99 | 100 | Decode encoded strings using ``encoding`` and normalise Unicode 101 | to form ``norm`` if specified. 102 | 103 | Args: 104 | s (str): String to decode. May also be Unicode. 105 | encoding (str, optional): Encoding to use on bytestrings. 106 | norm (None, optional): Normalisation form to apply to Unicode string. 107 | 108 | Returns: 109 | unicode: Decoded, optionally normalised, Unicode string. 110 | 111 | """ 112 | if not isinstance(s, str): 113 | s = str(s, encoding) 114 | 115 | if norm: 116 | from unicodedata import normalize 117 | 118 | s = normalize(norm, s) 119 | 120 | return s 121 | 122 | 123 | def utf8ify(s): 124 | """Ensure string is a bytestring. 125 | 126 | .. versionadded:: 1.31 127 | 128 | Returns `str` objects unchanced, encodes `unicode` objects to 129 | UTF-8, and calls :func:`str` on anything else. 130 | 131 | Args: 132 | s (object): A Python object 133 | 134 | Returns: 135 | str: UTF-8 string or string representation of s. 136 | 137 | """ 138 | if isinstance(s, str): 139 | return s 140 | 141 | if isinstance(s, str): 142 | return s.encode("utf-8") 143 | 144 | return str(s) 145 | 146 | 147 | def applescriptify(s): 148 | """Escape string for insertion into an AppleScript string. 149 | 150 | .. versionadded:: 1.31 151 | 152 | Replaces ``"`` with `"& quote &"`. Use this function if you want 153 | to insert a string into an AppleScript script: 154 | 155 | >>> applescriptify('g "python" test') 156 | 'g " & quote & "python" & quote & "test' 157 | 158 | Args: 159 | s (unicode): Unicode string to escape. 160 | 161 | Returns: 162 | unicode: Escaped string. 163 | 164 | """ 165 | return s.replace('"', '" & quote & "') 166 | 167 | 168 | def run_command(cmd, **kwargs): 169 | """Run a command and return the output. 170 | 171 | .. versionadded:: 1.31 172 | 173 | A thin wrapper around :func:`subprocess.check_output` that ensures 174 | all arguments are encoded to UTF-8 first. 175 | 176 | Args: 177 | cmd (list): Command arguments to pass to :func:`~subprocess.check_output`. 178 | **kwargs: Keyword arguments to pass to :func:`~subprocess.check_output`. 179 | 180 | Returns: 181 | str: Output returned by :func:`~subprocess.check_output`. 182 | 183 | """ 184 | cmd = [str(s) for s in cmd] 185 | return subprocess.check_output(cmd, **kwargs).decode() 186 | 187 | 188 | def run_applescript(script, *args, **kwargs): 189 | """Execute an AppleScript script and return its output. 190 | 191 | .. versionadded:: 1.31 192 | 193 | Run AppleScript either by filepath or code. If ``script`` is a valid 194 | filepath, that script will be run, otherwise ``script`` is treated 195 | as code. 196 | 197 | Args: 198 | script (str, optional): Filepath of script or code to run. 199 | *args: Optional command-line arguments to pass to the script. 200 | **kwargs: Pass ``lang`` to run a language other than AppleScript. 201 | Any other keyword arguments are passed to :func:`run_command`. 202 | 203 | Returns: 204 | str: Output of run command. 205 | 206 | """ 207 | lang = "AppleScript" 208 | if "lang" in kwargs: 209 | lang = kwargs["lang"] 210 | del kwargs["lang"] 211 | 212 | cmd = ["/usr/bin/osascript", "-l", lang] 213 | 214 | if os.path.exists(script): 215 | cmd += [script] 216 | else: 217 | cmd += ["-e", script] 218 | 219 | cmd.extend(args) 220 | 221 | return run_command(cmd, **kwargs) 222 | 223 | 224 | def run_jxa(script, *args): 225 | """Execute a JXA script and return its output. 226 | 227 | .. versionadded:: 1.31 228 | 229 | Wrapper around :func:`run_applescript` that passes ``lang=JavaScript``. 230 | 231 | Args: 232 | script (str): Filepath of script or code to run. 233 | *args: Optional command-line arguments to pass to script. 234 | 235 | Returns: 236 | str: Output of script. 237 | 238 | """ 239 | return run_applescript(script, *args, lang="JavaScript") 240 | 241 | 242 | def run_trigger(name, bundleid=None, arg=None): 243 | """Call an Alfred External Trigger. 244 | 245 | .. versionadded:: 1.31 246 | 247 | If ``bundleid`` is not specified, the bundle ID of the calling 248 | workflow is used. 249 | 250 | Args: 251 | name (str): Name of External Trigger to call. 252 | bundleid (str, optional): Bundle ID of workflow trigger belongs to. 253 | arg (str, optional): Argument to pass to trigger. 254 | 255 | """ 256 | bundleid = bundleid or os.getenv("alfred_workflow_bundleid") 257 | appname = jxa_app_name() 258 | opts = {"inWorkflow": bundleid} 259 | if arg: 260 | opts["withArgument"] = arg 261 | 262 | script = JXA_TRIGGER.format( 263 | app=json.dumps(appname), 264 | arg=json.dumps(name), 265 | opts=json.dumps(opts, sort_keys=True), 266 | ) 267 | 268 | run_applescript(script, lang="JavaScript") 269 | 270 | 271 | def set_theme(theme_name): 272 | """Change Alfred's theme. 273 | 274 | .. versionadded:: 1.39.0 275 | 276 | Args: 277 | theme_name (unicode): Name of theme Alfred should use. 278 | 279 | """ 280 | appname = jxa_app_name() 281 | script = JXA_SET_THEME.format(app=json.dumps(appname), arg=json.dumps(theme_name)) 282 | run_applescript(script, lang="JavaScript") 283 | 284 | 285 | def set_config(name, value, bundleid=None, exportable=False): 286 | """Set a workflow variable in ``info.plist``. 287 | 288 | .. versionadded:: 1.33 289 | 290 | If ``bundleid`` is not specified, the bundle ID of the calling 291 | workflow is used. 292 | 293 | Args: 294 | name (str): Name of variable to set. 295 | value (str): Value to set variable to. 296 | bundleid (str, optional): Bundle ID of workflow variable belongs to. 297 | exportable (bool, optional): Whether variable should be marked 298 | as exportable (Don't Export checkbox). 299 | 300 | """ 301 | bundleid = bundleid or os.getenv("alfred_workflow_bundleid") 302 | appname = jxa_app_name() 303 | opts = {"toValue": value, "inWorkflow": bundleid, "exportable": exportable} 304 | 305 | script = JXA_SET_CONFIG.format( 306 | app=json.dumps(appname), 307 | arg=json.dumps(name), 308 | opts=json.dumps(opts, sort_keys=True), 309 | ) 310 | 311 | run_applescript(script, lang="JavaScript") 312 | 313 | 314 | def unset_config(name, bundleid=None): 315 | """Delete a workflow variable from ``info.plist``. 316 | 317 | .. versionadded:: 1.33 318 | 319 | If ``bundleid`` is not specified, the bundle ID of the calling 320 | workflow is used. 321 | 322 | Args: 323 | name (str): Name of variable to delete. 324 | bundleid (str, optional): Bundle ID of workflow variable belongs to. 325 | 326 | """ 327 | bundleid = bundleid or os.getenv("alfred_workflow_bundleid") 328 | appname = jxa_app_name() 329 | opts = {"inWorkflow": bundleid} 330 | 331 | script = JXA_UNSET_CONFIG.format( 332 | app=json.dumps(appname), 333 | arg=json.dumps(name), 334 | opts=json.dumps(opts, sort_keys=True), 335 | ) 336 | 337 | run_applescript(script, lang="JavaScript") 338 | 339 | 340 | def search_in_alfred(query=None): 341 | """Open Alfred with given search query. 342 | 343 | .. versionadded:: 1.39.0 344 | 345 | Omit ``query`` to simply open Alfred's main window. 346 | 347 | Args: 348 | query (unicode, optional): Search query. 349 | 350 | """ 351 | query = query or "" 352 | appname = jxa_app_name() 353 | script = JXA_SEARCH.format(app=json.dumps(appname), arg=json.dumps(query)) 354 | run_applescript(script, lang="JavaScript") 355 | 356 | 357 | def browse_in_alfred(path): 358 | """Open Alfred's filesystem navigation mode at ``path``. 359 | 360 | .. versionadded:: 1.39.0 361 | 362 | Args: 363 | path (unicode): File or directory path. 364 | 365 | """ 366 | appname = jxa_app_name() 367 | script = JXA_BROWSE.format(app=json.dumps(appname), arg=json.dumps(path)) 368 | run_applescript(script, lang="JavaScript") 369 | 370 | 371 | def action_in_alfred(paths): 372 | """Action the give filepaths in Alfred. 373 | 374 | .. versionadded:: 1.39.0 375 | 376 | Args: 377 | paths (list): Unicode paths to files/directories to action. 378 | 379 | """ 380 | appname = jxa_app_name() 381 | script = JXA_ACTION.format(app=json.dumps(appname), arg=json.dumps(paths)) 382 | run_applescript(script, lang="JavaScript") 383 | 384 | 385 | def reload_workflow(bundleid=None): 386 | """Tell Alfred to reload a workflow from disk. 387 | 388 | .. versionadded:: 1.39.0 389 | 390 | If ``bundleid`` is not specified, the bundle ID of the calling 391 | workflow is used. 392 | 393 | Args: 394 | bundleid (unicode, optional): Bundle ID of workflow to reload. 395 | 396 | """ 397 | bundleid = bundleid or os.getenv("alfred_workflow_bundleid") 398 | appname = jxa_app_name() 399 | script = JXA_RELOAD_WORKFLOW.format( 400 | app=json.dumps(appname), arg=json.dumps(bundleid) 401 | ) 402 | 403 | run_applescript(script, lang="JavaScript") 404 | 405 | 406 | def appinfo(name): 407 | """Get information about an installed application. 408 | 409 | .. versionadded:: 1.31 410 | 411 | Args: 412 | name (str): Name of application to look up. 413 | 414 | Returns: 415 | AppInfo: :class:`AppInfo` tuple or ``None`` if app isn't found. 416 | 417 | """ 418 | cmd = [ 419 | "mdfind", 420 | "-onlyin", 421 | "/Applications", 422 | "-onlyin", 423 | "/System/Applications", 424 | "-onlyin", 425 | os.path.expanduser("~/Applications"), 426 | "(kMDItemContentTypeTree == com.apple.application &&" 427 | '(kMDItemDisplayName == "{0}" || kMDItemFSName == "{0}.app"))'.format(name), 428 | ] 429 | 430 | output = run_command(cmd).strip() 431 | if not output: 432 | return None 433 | 434 | path = output.split("\n")[0] 435 | 436 | cmd = ["mdls", "-raw", "-name", "kMDItemCFBundleIdentifier", path] 437 | bid = run_command(cmd).strip() 438 | if not bid: # pragma: no cover 439 | return None 440 | 441 | return AppInfo(name, path, bid) 442 | 443 | 444 | @contextmanager 445 | def atomic_writer(fpath, mode): 446 | """Atomic file writer. 447 | 448 | .. versionadded:: 1.12 449 | 450 | Context manager that ensures the file is only written if the write 451 | succeeds. The data is first written to a temporary file. 452 | 453 | :param fpath: path of file to write to. 454 | :type fpath: ``unicode`` 455 | :param mode: sames as for :func:`open` 456 | :type mode: string 457 | 458 | """ 459 | suffix = ".{}.tmp".format(os.getpid()) 460 | temppath = fpath + suffix 461 | with open(temppath, mode) as fp: 462 | try: 463 | yield fp 464 | os.rename(temppath, fpath) 465 | finally: 466 | try: 467 | os.remove(temppath) 468 | except OSError: 469 | pass 470 | 471 | 472 | class LockFile(object): 473 | """Context manager to protect filepaths with lockfiles. 474 | 475 | .. versionadded:: 1.13 476 | 477 | Creates a lockfile alongside ``protected_path``. Other ``LockFile`` 478 | instances will refuse to lock the same path. 479 | 480 | >>> path = '/path/to/file' 481 | >>> with LockFile(path): 482 | >>> with open(path, 'w') as fp: 483 | >>> fp.write(data) 484 | 485 | Args: 486 | protected_path (unicode): File to protect with a lockfile 487 | timeout (float, optional): Raises an :class:`AcquisitionError` 488 | if lock cannot be acquired within this number of seconds. 489 | If ``timeout`` is 0 (the default), wait forever. 490 | delay (float, optional): How often to check (in seconds) if 491 | lock has been released. 492 | 493 | Attributes: 494 | delay (float): How often to check (in seconds) whether the lock 495 | can be acquired. 496 | lockfile (unicode): Path of the lockfile. 497 | timeout (float): How long to wait to acquire the lock. 498 | 499 | """ 500 | 501 | def __init__(self, protected_path, timeout=0.0, delay=0.05): 502 | """Create new :class:`LockFile` object.""" 503 | self.lockfile = protected_path + ".lock" 504 | self._lockfile = None 505 | self.timeout = timeout 506 | self.delay = delay 507 | self._lock = Event() 508 | atexit.register(self.release) 509 | 510 | @property 511 | def locked(self): 512 | """``True`` if file is locked by this instance.""" 513 | return self._lock.is_set() 514 | 515 | def acquire(self, blocking=True): 516 | """Acquire the lock if possible. 517 | 518 | If the lock is in use and ``blocking`` is ``False``, return 519 | ``False``. 520 | 521 | Otherwise, check every :attr:`delay` seconds until it acquires 522 | lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`. 523 | 524 | """ 525 | if self.locked and not blocking: 526 | return False 527 | 528 | start = time.time() 529 | while True: 530 | # Raise error if we've been waiting too long to acquire the lock 531 | if self.timeout and (time.time() - start) >= self.timeout: 532 | raise AcquisitionError("lock acquisition timed out") 533 | 534 | # If already locked, wait then try again 535 | if self.locked: 536 | time.sleep(self.delay) 537 | continue 538 | 539 | # Create in append mode so we don't lose any contents 540 | if self._lockfile is None: 541 | self._lockfile = open(self.lockfile, "a") 542 | 543 | # Try to acquire the lock 544 | try: 545 | fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB) 546 | self._lock.set() 547 | break 548 | except IOError as err: # pragma: no cover 549 | if err.errno not in (errno.EACCES, errno.EAGAIN): 550 | raise 551 | 552 | # Don't try again 553 | if not blocking: # pragma: no cover 554 | return False 555 | 556 | # Wait, then try again 557 | time.sleep(self.delay) 558 | 559 | return True 560 | 561 | def release(self): 562 | """Release the lock by deleting `self.lockfile`.""" 563 | if not self._lock.is_set(): 564 | return False 565 | 566 | try: 567 | fcntl.lockf(self._lockfile, fcntl.LOCK_UN) 568 | except IOError: # pragma: no cover 569 | pass 570 | finally: 571 | self._lock.clear() 572 | self._lockfile = None 573 | try: 574 | os.unlink(self.lockfile) 575 | except OSError: # pragma: no cover 576 | pass 577 | 578 | return True # noqa: B012 579 | 580 | def __enter__(self): 581 | """Acquire lock.""" 582 | self.acquire() 583 | return self 584 | 585 | def __exit__(self, typ, value, traceback): 586 | """Release lock.""" 587 | self.release() 588 | 589 | def __del__(self): 590 | """Clear up `self.lockfile`.""" 591 | self.release() # pragma: no cover 592 | 593 | 594 | class uninterruptible(object): 595 | """Decorator that postpones SIGTERM until wrapped function returns. 596 | 597 | .. versionadded:: 1.12 598 | 599 | .. important:: This decorator is NOT thread-safe. 600 | 601 | As of version 2.7, Alfred allows Script Filters to be killed. If 602 | your workflow is killed in the middle of critical code (e.g. 603 | writing data to disk), this may corrupt your workflow's data. 604 | 605 | Use this decorator to wrap critical functions that *must* complete. 606 | If the script is killed while a wrapped function is executing, 607 | the SIGTERM will be caught and handled after your function has 608 | finished executing. 609 | 610 | Alfred-Workflow uses this internally to ensure its settings, data 611 | and cache writes complete. 612 | 613 | """ 614 | 615 | def __init__(self, func, class_name=""): 616 | """Decorate `func`.""" 617 | self.func = func 618 | functools.update_wrapper(self, func) 619 | self._caught_signal = None 620 | 621 | def signal_handler(self, signum, frame): 622 | """Called when process receives SIGTERM.""" 623 | self._caught_signal = (signum, frame) 624 | 625 | def __call__(self, *args, **kwargs): 626 | """Trap ``SIGTERM`` and call wrapped function.""" 627 | self._caught_signal = None 628 | # Register handler for SIGTERM, then call `self.func` 629 | self.old_signal_handler = signal.getsignal(signal.SIGTERM) 630 | signal.signal(signal.SIGTERM, self.signal_handler) 631 | 632 | self.func(*args, **kwargs) 633 | 634 | # Restore old signal handler 635 | signal.signal(signal.SIGTERM, self.old_signal_handler) 636 | 637 | # Handle any signal caught during execution 638 | if self._caught_signal is not None: 639 | signum, frame = self._caught_signal 640 | if callable(self.old_signal_handler): 641 | self.old_signal_handler(signum, frame) 642 | elif self.old_signal_handler == signal.SIG_DFL: 643 | sys.exit(0) 644 | 645 | def __get__(self, obj=None, klass=None): 646 | """Decorator API.""" 647 | return self.__class__(self.func.__get__(obj, klass), klass.__name__) 648 | -------------------------------------------------------------------------------- /workflow/version: -------------------------------------------------------------------------------- 1 | 1.40.0 -------------------------------------------------------------------------------- /workflow/web.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2014 Dean Jackson 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # modified on 2022-02-04 by Kyeongwon Lee 8 | # 9 | 10 | """Lightweight HTTP library with a requests-like interface.""" 11 | 12 | from __future__ import absolute_import, print_function 13 | 14 | import codecs 15 | import json 16 | import mimetypes 17 | import os 18 | import random 19 | import re 20 | import socket 21 | import string 22 | import unicodedata 23 | import urllib 24 | import urllib.parse as urlparse 25 | import urllib.request as request3 26 | import zlib 27 | 28 | __version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read() 29 | 30 | USER_AGENT = (u'Alfred-Workflow/' + __version__ + 31 | ' (+http://www.deanishe.net/alfred-workflow)') 32 | 33 | # Valid characters for multipart form data boundaries 34 | BOUNDARY_CHARS = string.digits + string.ascii_letters 35 | 36 | # HTTP response codes 37 | RESPONSES = { 38 | 100: 'Continue', 39 | 101: 'Switching Protocols', 40 | 200: 'OK', 41 | 201: 'Created', 42 | 202: 'Accepted', 43 | 203: 'Non-Authoritative Information', 44 | 204: 'No Content', 45 | 205: 'Reset Content', 46 | 206: 'Partial Content', 47 | 300: 'Multiple Choices', 48 | 301: 'Moved Permanently', 49 | 302: 'Found', 50 | 303: 'See Other', 51 | 304: 'Not Modified', 52 | 305: 'Use Proxy', 53 | 307: 'Temporary Redirect', 54 | 400: 'Bad Request', 55 | 401: 'Unauthorized', 56 | 402: 'Payment Required', 57 | 403: 'Forbidden', 58 | 404: 'Not Found', 59 | 405: 'Method Not Allowed', 60 | 406: 'Not Acceptable', 61 | 407: 'Proxy Authentication Required', 62 | 408: 'Request Timeout', 63 | 409: 'Conflict', 64 | 410: 'Gone', 65 | 411: 'Length Required', 66 | 412: 'Precondition Failed', 67 | 413: 'Request Entity Too Large', 68 | 414: 'Request-URI Too Long', 69 | 415: 'Unsupported Media Type', 70 | 416: 'Requested Range Not Satisfiable', 71 | 417: 'Expectation Failed', 72 | 500: 'Internal Server Error', 73 | 501: 'Not Implemented', 74 | 502: 'Bad Gateway', 75 | 503: 'Service Unavailable', 76 | 504: 'Gateway Timeout', 77 | 505: 'HTTP Version Not Supported' 78 | } 79 | 80 | 81 | def str_dict(dic): 82 | """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`. 83 | 84 | :param dic: Mapping of Unicode strings 85 | :type dic: dict 86 | :returns: Dictionary containing only UTF-8 strings 87 | :rtype: dict 88 | 89 | """ 90 | if isinstance(dic, CaseInsensitiveDictionary): 91 | dic2 = CaseInsensitiveDictionary() 92 | else: 93 | dic2 = {} 94 | for k, v in dic.items(): 95 | if isinstance(k, str): 96 | k = k.encode('utf-8') 97 | if isinstance(v, str): 98 | v = v.encode('utf-8') 99 | dic2[k] = v 100 | return dic2 101 | 102 | class NoRedirectHandler(request3.HTTPRedirectHandler): 103 | """Prevent redirections.""" 104 | 105 | def redirect_request(self, *args): 106 | """Ignore redirect.""" 107 | return None 108 | 109 | 110 | # Adapted from https://gist.github.com/babakness/3901174 111 | class CaseInsensitiveDictionary(dict): 112 | """Dictionary with caseless key search. 113 | 114 | Enables case insensitive searching while preserving case sensitivity 115 | when keys are listed, ie, via keys() or items() methods. 116 | 117 | Works by storing a lowercase version of the key as the new key and 118 | stores the original key-value pair as the key's value 119 | (values become dictionaries). 120 | 121 | """ 122 | 123 | def __init__(self, initval=None): 124 | """Create new case-insensitive dictionary.""" 125 | if isinstance(initval, dict): 126 | for key, value in initval.items(): 127 | self.__setitem__(key, value) 128 | 129 | elif isinstance(initval, list): 130 | for (key, value) in initval: 131 | self.__setitem__(key, value) 132 | 133 | def __contains__(self, key): 134 | return dict.__contains__(self, key.lower()) 135 | 136 | def __getitem__(self, key): 137 | return dict.__getitem__(self, key.lower())['val'] 138 | 139 | def __setitem__(self, key, value): 140 | return dict.__setitem__(self, key.lower(), {'key': key, 'val': value}) 141 | 142 | def get(self, key, default=None): 143 | """Return value for case-insensitive key or default.""" 144 | try: 145 | v = dict.__getitem__(self, key.lower()) 146 | except KeyError: 147 | return default 148 | else: 149 | return v['val'] 150 | 151 | def update(self, other): 152 | """Update values from other ``dict``.""" 153 | for k, v in other.items(): 154 | self[k] = v 155 | 156 | def items(self): 157 | """Return ``(key, value)`` pairs.""" 158 | return [(v['key'], v['val']) for v in iter(dict(self).values())] 159 | 160 | def keys(self): 161 | """Return original keys.""" 162 | return [v['key'] for v in iter(dict(self).values())] 163 | 164 | def values(self): 165 | """Return all values.""" 166 | return [v['val'] for v in iter(dict(self).values())] 167 | 168 | def iteritems(self): 169 | """Iterate over ``(key, value)`` pairs.""" 170 | for v in iter(dict(self).values()): 171 | yield v['key'], v['val'] 172 | 173 | def iterkeys(self): 174 | """Iterate over original keys.""" 175 | for v in iter(dict(self).values()): 176 | yield v['key'] 177 | 178 | def itervalues(self): 179 | """Interate over values.""" 180 | for v in iter(dict(self).values()): 181 | yield v['val'] 182 | 183 | 184 | class Request(request3.Request): 185 | """Subclass of :class:`request3.Request` that supports custom methods.""" 186 | 187 | def __init__(self, *args, **kwargs): 188 | """Create a new :class:`Request`.""" 189 | self._method = kwargs.pop('method', None) 190 | request3.Request.__init__(self, *args, **kwargs) 191 | 192 | def get_method(self): 193 | return self._method.upper() 194 | 195 | 196 | class Response(object): 197 | """ 198 | Returned by :func:`request` / :func:`get` / :func:`post` functions. 199 | 200 | Simplified version of the ``Response`` object in the ``requests`` library. 201 | 202 | >>> r = request('http://www.google.com') 203 | >>> r.status_code 204 | 200 205 | >>> r.encoding 206 | ISO-8859-1 207 | >>> r.content # bytes 208 | ... 209 | >>> r.text # unicode, decoded according to charset in HTTP header/meta tag 210 | u' ...' 211 | >>> r.json() # content parsed as JSON 212 | 213 | """ 214 | 215 | def __init__(self, request, stream=False): 216 | """Call `request` with :mod:`urllib2` and process results. 217 | 218 | :param request: :class:`Request` instance 219 | :param stream: Whether to stream response or retrieve it all at once 220 | :type stream: bool 221 | 222 | """ 223 | self.request = request 224 | self._stream = stream 225 | self.url = None 226 | self.raw = None 227 | self._encoding = None 228 | self.error = None 229 | self.status_code = None 230 | self.reason = None 231 | self.headers = CaseInsensitiveDictionary() 232 | self._content = None 233 | self._content_loaded = False 234 | self._gzipped = False 235 | 236 | # Execute query 237 | try: 238 | self.raw = request3.urlopen(request) 239 | except request3.HTTPError as err: 240 | self.error = err 241 | try: 242 | self.url = err.geturl() 243 | # sometimes (e.g. when authentication fails) 244 | # urllib can't get a URL from an HTTPError 245 | # This behaviour changes across Python versions, 246 | # so no test cover (it isn't important). 247 | except AttributeError: # pragma: no cover 248 | pass 249 | self.status_code = err.code 250 | else: 251 | self.status_code = self.raw.getcode() 252 | self.url = self.raw.geturl() 253 | self.reason = RESPONSES.get(self.status_code) 254 | 255 | # Parse additional info if request succeeded 256 | if not self.error: 257 | headers = self.raw.info() 258 | self.transfer_encoding = headers.get_content_charset() 259 | self.mimetype = headers.get("content-type") 260 | for key in headers.keys(): 261 | self.headers[key.lower()] = headers.get(key) 262 | 263 | # Is content gzipped? 264 | # Transfer-Encoding appears to not be used in the wild 265 | # (contrary to the HTTP standard), but no harm in testing 266 | # for it 267 | if 'gzip' in headers.get('content-encoding', '') or \ 268 | 'gzip' in headers.get('transfer-encoding', ''): 269 | self._gzipped = True 270 | 271 | @property 272 | def stream(self): 273 | """Whether response is streamed. 274 | 275 | Returns: 276 | bool: `True` if response is streamed. 277 | 278 | """ 279 | return self._stream 280 | 281 | @stream.setter 282 | def stream(self, value): 283 | if self._content_loaded: 284 | raise RuntimeError("`content` has already been read from " 285 | "this Response.") 286 | 287 | self._stream = value 288 | 289 | def json(self): 290 | """Decode response contents as JSON. 291 | 292 | :returns: object decoded from JSON 293 | :rtype: list, dict or unicode 294 | 295 | """ 296 | return json.loads(self.content) 297 | 298 | @property 299 | def encoding(self): 300 | """Text encoding of document or ``None``. 301 | 302 | :returns: Text encoding if found. 303 | :rtype: str or ``None`` 304 | 305 | """ 306 | if not self._encoding: 307 | self._encoding = self._get_encoding() 308 | 309 | return self._encoding 310 | 311 | @property 312 | def content(self): 313 | """Raw content of response (i.e. bytes). 314 | 315 | :returns: Body of HTTP response 316 | :rtype: str 317 | 318 | """ 319 | if not self._content: 320 | 321 | # Decompress gzipped content 322 | if self._gzipped: 323 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) 324 | self._content = decoder.decompress(self.raw.read()) 325 | 326 | else: 327 | self._content = self.raw.read() 328 | 329 | self._content_loaded = True 330 | 331 | return self._content 332 | 333 | @property 334 | def text(self): 335 | """Unicode-decoded content of response body. 336 | 337 | If no encoding can be determined from HTTP headers or the content 338 | itself, the encoded response body will be returned instead. 339 | 340 | :returns: Body of HTTP response 341 | :rtype: unicode or str 342 | 343 | """ 344 | if self.encoding: 345 | return unicodedata.normalize('NFC', str(self.content)) 346 | return self.content 347 | 348 | def iter_content(self, chunk_size=4096, decode_unicode=False): 349 | """Iterate over response data. 350 | 351 | .. versionadded:: 1.6 352 | 353 | :param chunk_size: Number of bytes to read into memory 354 | :type chunk_size: int 355 | :param decode_unicode: Decode to Unicode using detected encoding 356 | :type decode_unicode: bool 357 | :returns: iterator 358 | 359 | """ 360 | if not self.stream: 361 | raise RuntimeError("You cannot call `iter_content` on a " 362 | "Response unless you passed `stream=True`" 363 | " to `get()`/`post()`/`request()`.") 364 | 365 | if self._content_loaded: 366 | raise RuntimeError( 367 | "`content` has already been read from this Response.") 368 | 369 | def decode_stream(iterator, r): 370 | dec = codecs.getincrementaldecoder(r.encoding)(errors='replace') 371 | 372 | for chunk in iterator: 373 | data = dec.decode(chunk) 374 | if data: 375 | yield data 376 | 377 | data = dec.decode(b'', final=True) 378 | if data: # pragma: no cover 379 | yield data 380 | 381 | def generate(): 382 | if self._gzipped: 383 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) 384 | 385 | while True: 386 | chunk = self.raw.read(chunk_size) 387 | if not chunk: 388 | break 389 | 390 | if self._gzipped: 391 | chunk = decoder.decompress(chunk) 392 | 393 | yield chunk 394 | 395 | chunks = generate() 396 | 397 | if decode_unicode and self.encoding: 398 | chunks = decode_stream(chunks, self) 399 | 400 | return chunks 401 | 402 | def save_to_path(self, filepath): 403 | """Save retrieved data to file at ``filepath``. 404 | 405 | .. versionadded: 1.9.6 406 | 407 | :param filepath: Path to save retrieved data. 408 | 409 | """ 410 | filepath = os.path.abspath(filepath) 411 | dirname = os.path.dirname(filepath) 412 | if not os.path.exists(dirname): 413 | os.makedirs(dirname) 414 | 415 | self.stream = True 416 | 417 | with open(filepath, 'wb') as fileobj: 418 | for data in self.iter_content(): 419 | fileobj.write(data) 420 | 421 | def raise_for_status(self): 422 | """Raise stored error if one occurred. 423 | 424 | error will be instance of :class:`request3.HTTPError` 425 | """ 426 | if self.error is not None: 427 | raise self.error 428 | return 429 | 430 | def _get_encoding(self): 431 | """Get encoding from HTTP headers or content. 432 | 433 | :returns: encoding or `None` 434 | :rtype: unicode or ``None`` 435 | 436 | """ 437 | headers = self.raw.info() 438 | encoding = None 439 | 440 | if headers.get_param('charset'): 441 | encoding = headers.get_param('charset') 442 | 443 | if not self.stream: # Try sniffing response content 444 | # Encoding declared in document should override HTTP headers 445 | if self.mimetype == 'text/html': # sniff HTML headers 446 | m = re.search(r"""""", 447 | self.content) 448 | if m: 449 | encoding = m.group(1) 450 | 451 | elif ((self.mimetype.startswith('application/') 452 | or self.mimetype.startswith('text/')) 453 | and 'xml' in self.mimetype): 454 | m = re.search(r"""]*\?>""", 455 | self.content) 456 | if m: 457 | encoding = m.group(1) 458 | 459 | # Format defaults 460 | if self.mimetype == 'application/json' and not encoding: 461 | # The default encoding for JSON 462 | encoding = 'utf-8' 463 | 464 | elif self.mimetype == 'application/xml' and not encoding: 465 | # The default for 'application/xml' 466 | encoding = 'utf-8' 467 | 468 | if encoding: 469 | encoding = encoding.lower() 470 | 471 | return encoding 472 | 473 | 474 | def request(method, url, params=None, data=None, headers=None, cookies=None, 475 | files=None, auth=None, timeout=60, allow_redirects=False, 476 | stream=False): 477 | """Initiate an HTTP(S) request. Returns :class:`Response` object. 478 | 479 | :param method: 'GET' or 'POST' 480 | :type method: unicode 481 | :param url: URL to open 482 | :type url: unicode 483 | :param params: mapping of URL parameters 484 | :type params: dict 485 | :param data: mapping of form data ``{'field_name': 'value'}`` or 486 | :class:`str` 487 | :type data: dict or str 488 | :param headers: HTTP headers 489 | :type headers: dict 490 | :param cookies: cookies to send to server 491 | :type cookies: dict 492 | :param files: files to upload (see below). 493 | :type files: dict 494 | :param auth: username, password 495 | :type auth: tuple 496 | :param timeout: connection timeout limit in seconds 497 | :type timeout: int 498 | :param allow_redirects: follow redirections 499 | :type allow_redirects: bool 500 | :param stream: Stream content instead of fetching it all at once. 501 | :type stream: bool 502 | :returns: Response object 503 | :rtype: :class:`Response` 504 | 505 | 506 | The ``files`` argument is a dictionary:: 507 | 508 | {'fieldname' : { 'filename': 'blah.txt', 509 | 'content': '', 510 | 'mimetype': 'text/plain'} 511 | } 512 | 513 | * ``fieldname`` is the name of the field in the HTML form. 514 | * ``mimetype`` is optional. If not provided, :mod:`mimetypes` will 515 | be used to guess the mimetype, or ``application/octet-stream`` 516 | will be used. 517 | 518 | """ 519 | # TODO: cookies 520 | socket.setdefaulttimeout(timeout) 521 | 522 | # Default handlers 523 | openers = [request3.ProxyHandler(request3.getproxies())] 524 | 525 | if not allow_redirects: 526 | openers.append(NoRedirectHandler()) 527 | 528 | if auth is not None: # Add authorisation handler 529 | username, password = auth 530 | password_manager = request3.HTTPPasswordMgrWithDefaultRealm() 531 | password_manager.add_password(None, url, username, password) 532 | auth_manager = request3.HTTPBasicAuthHandler(password_manager) 533 | openers.append(auth_manager) 534 | 535 | # Install our custom chain of openers 536 | opener = request3.build_opener(*openers) 537 | request3.install_opener(opener) 538 | 539 | if not headers: 540 | headers = CaseInsensitiveDictionary() 541 | else: 542 | headers = CaseInsensitiveDictionary(headers) 543 | 544 | if 'user-agent' not in headers: 545 | headers['user-agent'] = USER_AGENT 546 | 547 | # Accept gzip-encoded content 548 | encodings = [s.strip() for s in 549 | headers.get('accept-encoding', '').split(',')] 550 | if 'gzip' not in encodings: 551 | encodings.append('gzip') 552 | 553 | headers['accept-encoding'] = ', '.join(encodings) 554 | 555 | if files: 556 | if not data: 557 | data = {} 558 | new_headers, data = encode_multipart_formdata(data, files) 559 | headers.update(new_headers) 560 | elif data and isinstance(data, dict): 561 | data = urlparse.urlencode(str_dict(data)) 562 | 563 | # Make sure everything is encoded text 564 | headers = str_dict(headers) 565 | 566 | # if isinstance(url, str): 567 | # url = url.encode('utf-8') 568 | 569 | if params: # GET args (POST args are handled in encode_multipart_formdata) 570 | 571 | scheme, netloc, path, query, fragment = urlparse.urlsplit(url) 572 | 573 | if query: # Combine query string and `params` 574 | url_params = urlparse.parse_qs(query) 575 | # `params` take precedence over URL query string 576 | url_params.update(params) 577 | params = url_params 578 | 579 | query = urlparse.urlencode(str_dict(params), doseq=True) 580 | url = urlparse.urlunsplit((scheme, netloc, path, query, fragment)) 581 | 582 | req = Request(url, data, headers, method=method) 583 | return Response(req, stream) 584 | 585 | 586 | def get(url, params=None, headers=None, cookies=None, auth=None, 587 | timeout=60, allow_redirects=True, stream=False): 588 | """Initiate a GET request. Arguments as for :func:`request`. 589 | 590 | :returns: :class:`Response` instance 591 | 592 | """ 593 | return request('GET', url, params, headers=headers, cookies=cookies, 594 | auth=auth, timeout=timeout, allow_redirects=allow_redirects, 595 | stream=stream) 596 | 597 | 598 | def delete(url, params=None, data=None, headers=None, cookies=None, auth=None, 599 | timeout=60, allow_redirects=True, stream=False): 600 | """Initiate a DELETE request. Arguments as for :func:`request`. 601 | 602 | :returns: :class:`Response` instance 603 | 604 | """ 605 | return request('DELETE', url, params, data, headers=headers, 606 | cookies=cookies, auth=auth, timeout=timeout, 607 | allow_redirects=allow_redirects, stream=stream) 608 | 609 | 610 | def post(url, params=None, data=None, headers=None, cookies=None, files=None, 611 | auth=None, timeout=60, allow_redirects=False, stream=False): 612 | """Initiate a POST request. Arguments as for :func:`request`. 613 | 614 | :returns: :class:`Response` instance 615 | 616 | """ 617 | return request('POST', url, params, data, headers, cookies, files, auth, 618 | timeout, allow_redirects, stream) 619 | 620 | 621 | def put(url, params=None, data=None, headers=None, cookies=None, files=None, 622 | auth=None, timeout=60, allow_redirects=False, stream=False): 623 | """Initiate a PUT request. Arguments as for :func:`request`. 624 | 625 | :returns: :class:`Response` instance 626 | 627 | """ 628 | return request('PUT', url, params, data, headers, cookies, files, auth, 629 | timeout, allow_redirects, stream) 630 | 631 | 632 | def encode_multipart_formdata(fields, files): 633 | """Encode form data (``fields``) and ``files`` for POST request. 634 | 635 | :param fields: mapping of ``{name : value}`` pairs for normal form fields. 636 | :type fields: dict 637 | :param files: dictionary of fieldnames/files elements for file data. 638 | See below for details. 639 | :type files: dict of :class:`dict` 640 | :returns: ``(headers, body)`` ``headers`` is a 641 | :class:`dict` of HTTP headers 642 | :rtype: 2-tuple ``(dict, str)`` 643 | 644 | The ``files`` argument is a dictionary:: 645 | 646 | {'fieldname' : { 'filename': 'blah.txt', 647 | 'content': '', 648 | 'mimetype': 'text/plain'} 649 | } 650 | 651 | - ``fieldname`` is the name of the field in the HTML form. 652 | - ``mimetype`` is optional. If not provided, :mod:`mimetypes` will 653 | be used to guess the mimetype, or ``application/octet-stream`` 654 | will be used. 655 | 656 | """ 657 | def get_content_type(filename): 658 | """Return or guess mimetype of ``filename``. 659 | 660 | :param filename: filename of file 661 | :type filename: unicode/str 662 | :returns: mime-type, e.g. ``text/html`` 663 | :rtype: str 664 | 665 | """ 666 | return mimetypes.guess_type(filename)[0] or 'application/octet-stream' 667 | 668 | boundary = '-----' + ''.join(random.choice(BOUNDARY_CHARS) 669 | for i in range(30)) 670 | CRLF = '\r\n' 671 | output = [] 672 | 673 | # Normal form fields 674 | for (name, value) in fields.items(): 675 | if isinstance(name, str): 676 | name = name.encode('utf-8') 677 | if isinstance(value, str): 678 | value = value.encode('utf-8') 679 | output.append('--' + boundary) 680 | output.append('Content-Disposition: form-data; name="%s"' % name) 681 | output.append('') 682 | output.append(value) 683 | 684 | # Files to upload 685 | for name, d in files.items(): 686 | filename = d[u'filename'] 687 | content = d[u'content'] 688 | if u'mimetype' in d: 689 | mimetype = d[u'mimetype'] 690 | else: 691 | mimetype = get_content_type(filename) 692 | if isinstance(name, str): 693 | name = name.encode('utf-8') 694 | if isinstance(filename, str): 695 | filename = filename.encode('utf-8') 696 | if isinstance(mimetype, str): 697 | mimetype = mimetype.encode('utf-8') 698 | output.append('--' + boundary) 699 | output.append('Content-Disposition: form-data; ' 700 | 'name="%s"; filename="%s"' % (name, filename)) 701 | output.append('Content-Type: %s' % mimetype) 702 | output.append('') 703 | output.append(content) 704 | 705 | output.append('--' + boundary + '--') 706 | output.append('') 707 | body = CRLF.join(output) 708 | headers = { 709 | 'Content-Type': 'multipart/form-data; boundary=%s' % boundary, 710 | 'Content-Length': str(len(body)), 711 | } 712 | return (headers, body) 713 | -------------------------------------------------------------------------------- /workflow/workflow3.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2016 Dean Jackson 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2016-06-25 8 | # 9 | 10 | """An Alfred 3+ version of :class:`~workflow.Workflow`. 11 | 12 | :class:`~workflow.Workflow3` supports new features, such as 13 | setting :ref:`workflow-variables` and 14 | :class:`the more advanced modifiers ` supported by Alfred 3+. 15 | 16 | In order for the feedback mechanism to work correctly, it's important 17 | to create :class:`Item3` and :class:`Modifier` objects via the 18 | :meth:`Workflow3.add_item()` and :meth:`Item3.add_modifier()` methods 19 | respectively. If you instantiate :class:`Item3` or :class:`Modifier` 20 | objects directly, the current :class:`Workflow3` object won't be aware 21 | of them, and they won't be sent to Alfred when you call 22 | :meth:`Workflow3.send_feedback()`. 23 | 24 | """ 25 | 26 | 27 | import json 28 | import os 29 | import sys 30 | 31 | from .workflow import ICON_WARNING, Workflow 32 | 33 | 34 | class Variables(dict): 35 | """Workflow variables for Run Script actions. 36 | 37 | .. versionadded: 1.26 38 | 39 | This class allows you to set workflow variables from 40 | Run Script actions. 41 | 42 | It is a subclass of :class:`dict`. 43 | 44 | >>> v = Variables(username='deanishe', password='hunter2') 45 | >>> v.arg = u'output value' 46 | >>> print(v) 47 | 48 | See :ref:`variables-run-script` in the User Guide for more 49 | information. 50 | 51 | Args: 52 | arg (unicode or list, optional): Main output/``{query}``. 53 | **variables: Workflow variables to set. 54 | 55 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a 56 | :class:`list` or :class:`tuple`. 57 | 58 | Attributes: 59 | arg (unicode or list): Output value (``{query}``). 60 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a 61 | :class:`list` or :class:`tuple`. 62 | config (dict): Configuration for downstream workflow element. 63 | 64 | """ 65 | 66 | def __init__(self, arg=None, **variables): 67 | """Create a new `Variables` object.""" 68 | self.arg = arg 69 | self.config = {} 70 | super(Variables, self).__init__(**variables) 71 | 72 | @property 73 | def obj(self): 74 | """``alfredworkflow`` :class:`dict`.""" 75 | o = {} 76 | if self: 77 | d2 = {} 78 | for k, v in list(self.items()): 79 | d2[k] = v 80 | o["variables"] = d2 81 | 82 | if self.config: 83 | o["config"] = self.config 84 | 85 | if self.arg is not None: 86 | o["arg"] = self.arg 87 | 88 | return {"alfredworkflow": o} 89 | 90 | def __str__(self): 91 | """Convert to ``alfredworkflow`` JSON object. 92 | 93 | Returns: 94 | unicode: ``alfredworkflow`` JSON object 95 | 96 | """ 97 | if not self and not self.config: 98 | if not self.arg: 99 | return "" 100 | if isinstance(self.arg, str): 101 | return self.arg 102 | 103 | return json.dumps(self.obj) 104 | 105 | 106 | class Modifier(object): 107 | """Modify :class:`Item3` arg/icon/variables when modifier key is pressed. 108 | 109 | Don't use this class directly (as it won't be associated with any 110 | :class:`Item3`), but rather use :meth:`Item3.add_modifier()` 111 | to add modifiers to results. 112 | 113 | >>> it = wf.add_item('Title', 'Subtitle', valid=True) 114 | >>> it.setvar('name', 'default') 115 | >>> m = it.add_modifier('cmd') 116 | >>> m.setvar('name', 'alternate') 117 | 118 | See :ref:`workflow-variables` in the User Guide for more information 119 | and :ref:`example usage `. 120 | 121 | Args: 122 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. 123 | subtitle (unicode, optional): Override default subtitle. 124 | arg (unicode, optional): Argument to pass for this modifier. 125 | valid (bool, optional): Override item's validity. 126 | icon (unicode, optional): Filepath/UTI of icon to use 127 | icontype (unicode, optional): Type of icon. See 128 | :meth:`Workflow.add_item() ` 129 | for valid values. 130 | 131 | Attributes: 132 | arg (unicode): Arg to pass to following action. 133 | config (dict): Configuration for a downstream element, such as 134 | a File Filter. 135 | icon (unicode): Filepath/UTI of icon. 136 | icontype (unicode): Type of icon. See 137 | :meth:`Workflow.add_item() ` 138 | for valid values. 139 | key (unicode): Modifier key (see above). 140 | subtitle (unicode): Override item subtitle. 141 | valid (bool): Override item validity. 142 | variables (dict): Workflow variables set by this modifier. 143 | 144 | """ 145 | 146 | def __init__( 147 | self, key, subtitle=None, arg=None, valid=None, icon=None, icontype=None 148 | ): 149 | """Create a new :class:`Modifier`. 150 | 151 | Don't use this class directly (as it won't be associated with any 152 | :class:`Item3`), but rather use :meth:`Item3.add_modifier()` 153 | to add modifiers to results. 154 | 155 | Args: 156 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. 157 | subtitle (unicode, optional): Override default subtitle. 158 | arg (unicode, optional): Argument to pass for this modifier. 159 | valid (bool, optional): Override item's validity. 160 | icon (unicode, optional): Filepath/UTI of icon to use 161 | icontype (unicode, optional): Type of icon. See 162 | :meth:`Workflow.add_item() ` 163 | for valid values. 164 | 165 | """ 166 | self.key = key 167 | self.subtitle = subtitle 168 | self.arg = arg 169 | self.valid = valid 170 | self.icon = icon 171 | self.icontype = icontype 172 | 173 | self.config = {} 174 | self.variables = {} 175 | 176 | def setvar(self, name, value): 177 | """Set a workflow variable for this Item. 178 | 179 | Args: 180 | name (unicode): Name of variable. 181 | value (unicode): Value of variable. 182 | 183 | """ 184 | self.variables[name] = value 185 | 186 | def getvar(self, name, default=None): 187 | """Return value of workflow variable for ``name`` or ``default``. 188 | 189 | Args: 190 | name (unicode): Variable name. 191 | default (None, optional): Value to return if variable is unset. 192 | 193 | Returns: 194 | unicode or ``default``: Value of variable if set or ``default``. 195 | 196 | """ 197 | return self.variables.get(name, default) 198 | 199 | @property 200 | def obj(self): 201 | """Modifier formatted for JSON serialization for Alfred 3. 202 | 203 | Returns: 204 | dict: Modifier for serializing to JSON. 205 | 206 | """ 207 | o = {} 208 | 209 | if self.subtitle is not None: 210 | o["subtitle"] = self.subtitle 211 | 212 | if self.arg is not None: 213 | o["arg"] = self.arg 214 | 215 | if self.valid is not None: 216 | o["valid"] = self.valid 217 | 218 | if self.variables: 219 | o["variables"] = self.variables 220 | 221 | if self.config: 222 | o["config"] = self.config 223 | 224 | icon = self._icon() 225 | if icon: 226 | o["icon"] = icon 227 | 228 | return o 229 | 230 | def _icon(self): 231 | """Return `icon` object for item. 232 | 233 | Returns: 234 | dict: Mapping for item `icon` (may be empty). 235 | 236 | """ 237 | icon = {} 238 | if self.icon is not None: 239 | icon["path"] = self.icon 240 | 241 | if self.icontype is not None: 242 | icon["type"] = self.icontype 243 | 244 | return icon 245 | 246 | 247 | class Item3(object): 248 | """Represents a feedback item for Alfred 3+. 249 | 250 | Generates Alfred-compliant JSON for a single item. 251 | 252 | Don't use this class directly (as it then won't be associated with 253 | any :class:`Workflow3 ` object), but rather use 254 | :meth:`Workflow3.add_item() `. 255 | See :meth:`~workflow.Workflow3.add_item` for details of arguments. 256 | 257 | """ 258 | 259 | def __init__( 260 | self, 261 | title, 262 | subtitle="", 263 | arg=None, 264 | autocomplete=None, 265 | match=None, 266 | valid=False, 267 | uid=None, 268 | icon=None, 269 | icontype=None, 270 | type=None, 271 | largetext=None, 272 | copytext=None, 273 | quicklookurl=None, 274 | ): 275 | """Create a new :class:`Item3` object. 276 | 277 | Use same arguments as for 278 | :class:`Workflow.Item `. 279 | 280 | Argument ``subtitle_modifiers`` is not supported. 281 | 282 | """ 283 | self.title = title 284 | self.subtitle = subtitle 285 | self.arg = arg 286 | self.autocomplete = autocomplete 287 | self.match = match 288 | self.valid = valid 289 | self.uid = uid 290 | self.icon = icon 291 | self.icontype = icontype 292 | self.type = type 293 | self.quicklookurl = quicklookurl 294 | self.largetext = largetext 295 | self.copytext = copytext 296 | 297 | self.modifiers = {} 298 | 299 | self.config = {} 300 | self.variables = {} 301 | 302 | def setvar(self, name, value): 303 | """Set a workflow variable for this Item. 304 | 305 | Args: 306 | name (unicode): Name of variable. 307 | value (unicode): Value of variable. 308 | 309 | """ 310 | self.variables[name] = value 311 | 312 | def getvar(self, name, default=None): 313 | """Return value of workflow variable for ``name`` or ``default``. 314 | 315 | Args: 316 | name (unicode): Variable name. 317 | default (None, optional): Value to return if variable is unset. 318 | 319 | Returns: 320 | unicode or ``default``: Value of variable if set or ``default``. 321 | 322 | """ 323 | return self.variables.get(name, default) 324 | 325 | def add_modifier( 326 | self, key, subtitle=None, arg=None, valid=None, icon=None, icontype=None 327 | ): 328 | """Add alternative values for a modifier key. 329 | 330 | Args: 331 | key (unicode): Modifier key, e.g. ``"cmd"`` or ``"alt"`` 332 | subtitle (unicode, optional): Override item subtitle. 333 | arg (unicode, optional): Input for following action. 334 | valid (bool, optional): Override item validity. 335 | icon (unicode, optional): Filepath/UTI of icon. 336 | icontype (unicode, optional): Type of icon. See 337 | :meth:`Workflow.add_item() ` 338 | for valid values. 339 | 340 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a 341 | :class:`list` or :class:`tuple`. 342 | 343 | Returns: 344 | Modifier: Configured :class:`Modifier`. 345 | 346 | """ 347 | mod = Modifier(key, subtitle, arg, valid, icon, icontype) 348 | 349 | # Add Item variables to Modifier 350 | mod.variables.update(self.variables) 351 | 352 | self.modifiers[key] = mod 353 | 354 | return mod 355 | 356 | @property 357 | def obj(self): 358 | """Item formatted for JSON serialization. 359 | 360 | Returns: 361 | dict: Data suitable for Alfred 3 feedback. 362 | 363 | """ 364 | # Required values 365 | o = {"title": self.title, "subtitle": self.subtitle, "valid": self.valid} 366 | 367 | # Optional values 368 | if self.arg is not None: 369 | o["arg"] = self.arg 370 | 371 | if self.autocomplete is not None: 372 | o["autocomplete"] = self.autocomplete 373 | 374 | if self.match is not None: 375 | o["match"] = self.match 376 | 377 | if self.uid is not None: 378 | o["uid"] = self.uid 379 | 380 | if self.type is not None: 381 | o["type"] = self.type 382 | 383 | if self.quicklookurl is not None: 384 | o["quicklookurl"] = self.quicklookurl 385 | 386 | if self.variables: 387 | o["variables"] = self.variables 388 | 389 | if self.config: 390 | o["config"] = self.config 391 | 392 | # Largetype and copytext 393 | text = self._text() 394 | if text: 395 | o["text"] = text 396 | 397 | icon = self._icon() 398 | if icon: 399 | o["icon"] = icon 400 | 401 | # Modifiers 402 | mods = self._modifiers() 403 | if mods: 404 | o["mods"] = mods 405 | 406 | return o 407 | 408 | def _icon(self): 409 | """Return `icon` object for item. 410 | 411 | Returns: 412 | dict: Mapping for item `icon` (may be empty). 413 | 414 | """ 415 | icon = {} 416 | if self.icon is not None: 417 | icon["path"] = self.icon 418 | 419 | if self.icontype is not None: 420 | icon["type"] = self.icontype 421 | 422 | return icon 423 | 424 | def _text(self): 425 | """Return `largetext` and `copytext` object for item. 426 | 427 | Returns: 428 | dict: `text` mapping (may be empty) 429 | 430 | """ 431 | text = {} 432 | if self.largetext is not None: 433 | text["largetype"] = self.largetext 434 | 435 | if self.copytext is not None: 436 | text["copy"] = self.copytext 437 | 438 | return text 439 | 440 | def _modifiers(self): 441 | """Build `mods` dictionary for JSON feedback. 442 | 443 | Returns: 444 | dict: Modifier mapping or `None`. 445 | 446 | """ 447 | if self.modifiers: 448 | mods = {} 449 | for k, mod in list(self.modifiers.items()): 450 | mods[k] = mod.obj 451 | 452 | return mods 453 | 454 | return None 455 | 456 | 457 | class Workflow3(Workflow): 458 | """Workflow class that generates Alfred 3+ feedback. 459 | 460 | It is a subclass of :class:`~workflow.Workflow` and most of its 461 | methods are documented there. 462 | 463 | Attributes: 464 | item_class (class): Class used to generate feedback items. 465 | variables (dict): Top level workflow variables. 466 | 467 | """ 468 | 469 | item_class = Item3 470 | 471 | def __init__(self, **kwargs): 472 | """Create a new :class:`Workflow3` object. 473 | 474 | See :class:`~workflow.Workflow` for documentation. 475 | 476 | """ 477 | Workflow.__init__(self, **kwargs) 478 | self.variables = {} 479 | self._rerun = 0 480 | # Get session ID from environment if present 481 | self._session_id = os.getenv("_WF_SESSION_ID") or None 482 | if self._session_id: 483 | self.setvar("_WF_SESSION_ID", self._session_id) 484 | 485 | @property 486 | def _default_cachedir(self): 487 | """Alfred 4's default cache directory.""" 488 | return os.path.join( 489 | os.path.expanduser( 490 | "~/Library/Caches/com.runningwithcrayons.Alfred/" "Workflow Data/" 491 | ), 492 | self.bundleid, 493 | ) 494 | 495 | @property 496 | def _default_datadir(self): 497 | """Alfred 4's default data directory.""" 498 | return os.path.join( 499 | os.path.expanduser("~/Library/Application Support/Alfred/Workflow Data/"), 500 | self.bundleid, 501 | ) 502 | 503 | @property 504 | def rerun(self): 505 | """How often (in seconds) Alfred should re-run the Script Filter.""" 506 | return self._rerun 507 | 508 | @rerun.setter 509 | def rerun(self, seconds): 510 | """Interval at which Alfred should re-run the Script Filter. 511 | 512 | Args: 513 | seconds (int): Interval between runs. 514 | """ 515 | self._rerun = seconds 516 | 517 | @property 518 | def session_id(self): 519 | """A unique session ID every time the user uses the workflow. 520 | 521 | .. versionadded:: 1.25 522 | 523 | The session ID persists while the user is using this workflow. 524 | It expires when the user runs a different workflow or closes 525 | Alfred. 526 | 527 | """ 528 | if not self._session_id: 529 | from uuid import uuid4 530 | 531 | self._session_id = uuid4().hex 532 | self.setvar("_WF_SESSION_ID", self._session_id) 533 | 534 | return self._session_id 535 | 536 | def setvar(self, name, value, persist=False): 537 | """Set a "global" workflow variable. 538 | 539 | .. versionchanged:: 1.33 540 | 541 | These variables are always passed to downstream workflow objects. 542 | 543 | If you have set :attr:`rerun`, these variables are also passed 544 | back to the script when Alfred runs it again. 545 | 546 | Args: 547 | name (unicode): Name of variable. 548 | value (unicode): Value of variable. 549 | persist (bool, optional): Also save variable to ``info.plist``? 550 | 551 | """ 552 | self.variables[name] = value 553 | if persist: 554 | from .util import set_config 555 | 556 | set_config(name, value, self.bundleid) 557 | self.logger.debug( 558 | "saved variable %r with value %r to info.plist", name, value 559 | ) 560 | 561 | def getvar(self, name, default=None): 562 | """Return value of workflow variable for ``name`` or ``default``. 563 | 564 | Args: 565 | name (unicode): Variable name. 566 | default (None, optional): Value to return if variable is unset. 567 | 568 | Returns: 569 | unicode or ``default``: Value of variable if set or ``default``. 570 | 571 | """ 572 | return self.variables.get(name, default) 573 | 574 | def add_item( 575 | self, 576 | title, 577 | subtitle="", 578 | arg=None, 579 | autocomplete=None, 580 | valid=False, 581 | uid=None, 582 | icon=None, 583 | icontype=None, 584 | type=None, 585 | largetext=None, 586 | copytext=None, 587 | quicklookurl=None, 588 | match=None, 589 | ): 590 | """Add an item to be output to Alfred. 591 | 592 | Args: 593 | match (unicode, optional): If you have "Alfred filters results" 594 | turned on for your Script Filter, Alfred (version 3.5 and 595 | above) will filter against this field, not ``title``. 596 | 597 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a 598 | :class:`list` or :class:`tuple`. 599 | 600 | See :meth:`Workflow.add_item() ` for 601 | the main documentation and other parameters. 602 | 603 | The key difference is that this method does not support the 604 | ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()` 605 | method instead on the returned item instead. 606 | 607 | Returns: 608 | Item3: Alfred feedback item. 609 | 610 | """ 611 | item = self.item_class( 612 | title, 613 | subtitle, 614 | arg, 615 | autocomplete, 616 | match, 617 | valid, 618 | uid, 619 | icon, 620 | icontype, 621 | type, 622 | largetext, 623 | copytext, 624 | quicklookurl, 625 | ) 626 | 627 | # Add variables to child item 628 | item.variables.update(self.variables) 629 | 630 | self._items.append(item) 631 | return item 632 | 633 | @property 634 | def _session_prefix(self): 635 | """Filename prefix for current session.""" 636 | return "_wfsess-{0}-".format(self.session_id) 637 | 638 | def _mk_session_name(self, name): 639 | """New cache name/key based on session ID.""" 640 | return self._session_prefix + name 641 | 642 | def cache_data(self, name, data, session=False): 643 | """Cache API with session-scoped expiry. 644 | 645 | .. versionadded:: 1.25 646 | 647 | Args: 648 | name (str): Cache key 649 | data (object): Data to cache 650 | session (bool, optional): Whether to scope the cache 651 | to the current session. 652 | 653 | ``name`` and ``data`` are the same as for the 654 | :meth:`~workflow.Workflow.cache_data` method on 655 | :class:`~workflow.Workflow`. 656 | 657 | If ``session`` is ``True``, then ``name`` is prefixed 658 | with :attr:`session_id`. 659 | 660 | """ 661 | if session: 662 | name = self._mk_session_name(name) 663 | 664 | return super(Workflow3, self).cache_data(name, data) 665 | 666 | def cached_data(self, name, data_func=None, max_age=60, session=False): 667 | """Cache API with session-scoped expiry. 668 | 669 | .. versionadded:: 1.25 670 | 671 | Args: 672 | name (str): Cache key 673 | data_func (callable): Callable that returns fresh data. It 674 | is called if the cache has expired or doesn't exist. 675 | max_age (int): Maximum allowable age of cache in seconds. 676 | session (bool, optional): Whether to scope the cache 677 | to the current session. 678 | 679 | ``name``, ``data_func`` and ``max_age`` are the same as for the 680 | :meth:`~workflow.Workflow.cached_data` method on 681 | :class:`~workflow.Workflow`. 682 | 683 | If ``session`` is ``True``, then ``name`` is prefixed 684 | with :attr:`session_id`. 685 | 686 | """ 687 | if session: 688 | name = self._mk_session_name(name) 689 | 690 | return super(Workflow3, self).cached_data(name, data_func, max_age) 691 | 692 | def clear_session_cache(self, current=False): 693 | """Remove session data from the cache. 694 | 695 | .. versionadded:: 1.25 696 | .. versionchanged:: 1.27 697 | 698 | By default, data belonging to the current session won't be 699 | deleted. Set ``current=True`` to also clear current session. 700 | 701 | Args: 702 | current (bool, optional): If ``True``, also remove data for 703 | current session. 704 | 705 | """ 706 | 707 | def _is_session_file(filename): 708 | if current: 709 | return filename.startswith("_wfsess-") 710 | return filename.startswith("_wfsess-") and not filename.startswith( 711 | self._session_prefix 712 | ) 713 | 714 | self.clear_cache(_is_session_file) 715 | 716 | @property 717 | def obj(self): 718 | """Feedback formatted for JSON serialization. 719 | 720 | Returns: 721 | dict: Data suitable for Alfred 3 feedback. 722 | 723 | """ 724 | items = [] 725 | for item in self._items: 726 | items.append(item.obj) 727 | 728 | o = {"items": items} 729 | if self.variables: 730 | o["variables"] = self.variables 731 | if self.rerun: 732 | o["rerun"] = self.rerun 733 | return o 734 | 735 | def warn_empty(self, title, subtitle="", icon=None): 736 | """Add a warning to feedback if there are no items. 737 | 738 | .. versionadded:: 1.31 739 | 740 | Add a "warning" item to Alfred feedback if no other items 741 | have been added. This is a handy shortcut to prevent Alfred 742 | from showing its fallback searches, which is does if no 743 | items are returned. 744 | 745 | Args: 746 | title (unicode): Title of feedback item. 747 | subtitle (unicode, optional): Subtitle of feedback item. 748 | icon (str, optional): Icon for feedback item. If not 749 | specified, ``ICON_WARNING`` is used. 750 | 751 | Returns: 752 | Item3: Newly-created item. 753 | 754 | """ 755 | if len(self._items): 756 | return 757 | 758 | icon = icon or ICON_WARNING 759 | return self.add_item(title, subtitle, icon=icon) 760 | 761 | def send_feedback(self): 762 | """Print stored items to console/Alfred as JSON.""" 763 | if self.debugging: 764 | json.dump(self.obj, sys.stdout, indent=2, separators=(",", ": ")) 765 | else: 766 | json.dump(self.obj, sys.stdout) 767 | sys.stdout.flush() 768 | --------------------------------------------------------------------------------