├── .gitignore ├── Gitea.alfredworkflow ├── LICENSE ├── README.md ├── gitea-transparent.png └── source ├── 7721B240-9DC1-49E4-A480-A4D422557660.png ├── bin └── chardetect ├── clear-cache.py ├── gitea.py ├── home.py ├── icon.png ├── info.plist ├── mureq.py ├── reset.py ├── thumbnails.py ├── thumbnails.pyc ├── update.py └── workflow ├── Notify.tgz ├── __init__.py ├── background.py ├── notify.py ├── update.py ├── util.py ├── version ├── workflow.py └── workflow3.py /.gitignore: -------------------------------------------------------------------------------- 1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig 2 | 3 | # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos 4 | # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,macos 5 | 6 | ### macOS ### 7 | # General 8 | .DS_Store 9 | .AppleDouble 10 | .LSOverride 11 | 12 | # Icon must end with two \r 13 | Icon 14 | 15 | # Thumbnails 16 | ._* 17 | 18 | # Files that might appear in the root of a volume 19 | .DocumentRevisions-V100 20 | .fseventsd 21 | .Spotlight-V100 22 | .TemporaryItems 23 | .Trashes 24 | .VolumeIcon.icns 25 | .com.apple.timemachine.donotpresent 26 | 27 | # Directories potentially created on remote AFP share 28 | .AppleDB 29 | .AppleDesktop 30 | Network Trash Folder 31 | Temporary Items 32 | .apdisk 33 | 34 | ### VisualStudioCode ### 35 | .vscode/* 36 | !.vscode/settings.json 37 | !.vscode/tasks.json 38 | !.vscode/launch.json 39 | !.vscode/extensions.json 40 | *.code-workspace 41 | 42 | # Local History for Visual Studio Code 43 | .history/ 44 | 45 | ### VisualStudioCode Patch ### 46 | # Ignore all local history of files 47 | .history 48 | .ionide 49 | 50 | # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos 51 | 52 | # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) 53 | 54 | -------------------------------------------------------------------------------- /Gitea.alfredworkflow: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pat-s/alfred-gitea/be3f05c450a77dc02c1916b525cddfd8099f7072/Gitea.alfredworkflow -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Patrick Schratz 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-gitea 2 | 3 | - [alfred-gitea](#alfred-gitea) 4 | - [Installation](#installation) 5 | - [Usage](#usage) 6 | - [Quick Open](#quick-open) 7 | - [Starting fresh](#starting-fresh) 8 | - [Acknowledgment](#acknowledgment) 9 | - [Misc](#misc) 10 | 11 | ## Installation 12 | 13 | - Download the [alfredworkflow](https://github.com/pat-s/alfred-gitea/releases/download/v1.0.0/Gitea.alfredworkflow) from GitHub 14 | - Install from [packal](http://www.packal.org/workflow/gitea) 15 | 16 | 1. Set your Gitea Access Token via `tea set token ` or `tea set key ` (both do the same) 17 | 2. Set your Gitea URL via `tea set url `. 18 | The workflow also works without access token but will be restricted to public repos. 19 | 20 | The workflow makes calls to the `/users/repos` endpoint and will only list repos which are accessible via the given access token. 21 | 22 | **Python3 and macOS > 12.3** 23 | 24 | macOS 12.3 does not ship with a Python2 interpreter anymore, hence `alfred-gitea` versions < 2.0.0 will not work. 25 | Please update to `alfred-gitea` >= 2.0.0. 26 | 27 | ## Usage 28 | 29 | `tea ` shows all repos using the repos slug `owner/repo` 30 | The next menu let's you make a selection which subpart of the repo to open: 31 | 32 | - Code (repo home) 33 | - Issues 34 | - Pull Requests 35 | - Releases 36 | - Wiki 37 | - Projects 38 | - Settings 39 | 40 | The workflow learns from usage and will sort the returned results by this criteria. 41 | 42 | ### Quick Open 43 | 44 | If you hold the ⌥ (`option`) key and then hit ⏎ (`return`), the subfolder selection will be skipped and the repository will be opened directly. 45 | This behavior can also be set as the default by setting variable `quick_open` to `true` in the Alfred workflow options. 46 | Note however that this will not allow selecting a specific location (repo, settings, etc.) anymore. 47 | 48 |

49 | alfred-gitea-gif 50 |

51 | 52 | ### Starting fresh 53 | 54 | If you want to reset the learned behavior or switch to a new Gitea instance, call `tea reset`. 55 | This will delete the cache, access token and the URL. 56 | 57 | If you **only** want to delete the repo cache, call `tea clearcache`. 58 | This will keep the URL and API key set and only invalidate the repo cache. 59 | 60 | ## Acknowledgment 61 | 62 | - Inspired by [alfred-gitlab](https://github.com/lukewaite/alfred-gitlab) 63 | - Python2 version was initially powered by the [Alfred-Workflow](https://www.deanishe.net/alfred-workflow/index.html) python module 64 | - Python3 conversion was adapted from [TribuneX/alfred-gitlab](https://github.com/TribuneX/alfred-gitlab) 65 | - Webscraping functionality comes from [slingamn/mureq](https://github.com/slingamn/mureq) 66 | 67 | ## Misc 68 | 69 | - [How to add dynamic repo thumbnails in alfred-workflow](https://github.com/deanishe/alfred-workflow/issues/106#issuecomment-737505965) 70 | - Dev info: The Gitea API does not return `x-next-page` and `x-page` headers, therefore the total number of pages needs to be calculated on the fly in every run 71 | 72 | **What about repo avatars?** 73 | 74 | There is in fact a working solution for repo avatars. 75 | However it requires the download of all avatars on each run and a subsequent thumbnail conversion. 76 | This adds a delay of at least two seconds until the first match appears in Alfred. 77 | Hence I decided to use fixed repo icons. 78 | -------------------------------------------------------------------------------- /gitea-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pat-s/alfred-gitea/be3f05c450a77dc02c1916b525cddfd8099f7072/gitea-transparent.png -------------------------------------------------------------------------------- /source/7721B240-9DC1-49E4-A480-A4D422557660.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pat-s/alfred-gitea/be3f05c450a77dc02c1916b525cddfd8099f7072/source/7721B240-9DC1-49E4-A480-A4D422557660.png -------------------------------------------------------------------------------- /source/bin/chardetect: -------------------------------------------------------------------------------- 1 | #!/usr/local/opt/python@3.9/bin/python3.9 2 | # -*- coding: utf-8 -*- 3 | import re 4 | import sys 5 | from chardet.cli.chardetect import main 6 | if __name__ == '__main__': 7 | sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) 8 | sys.exit(main()) 9 | -------------------------------------------------------------------------------- /source/clear-cache.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | from workflow import Workflow 3 | 4 | # log = None 5 | 6 | def main(wf): 7 | wf.clear_cache() 8 | 9 | if __name__ == u"__main__": 10 | wf = Workflow() 11 | log = wf.logger 12 | wf.run(main) 13 | -------------------------------------------------------------------------------- /source/gitea.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # from __future__ import unicode_literals 3 | import sys 4 | import argparse 5 | from workflow import Workflow3, ICON_WEB, ICON_ERROR, ICON_WARNING, ICON_INFO, web, PasswordNotFound, util 6 | from workflow.background import run_in_background, is_running 7 | from workflow.util import run_command 8 | # install into workflow folder via pip install --target=. requests 9 | import requests 10 | from thumbnails import Thumbs 11 | 12 | log = None 13 | 14 | 15 | def search_for_project(project): 16 | """Generate a string search key for a project""" 17 | elements = [] 18 | elements.append(project['full_name']) 19 | elements.append(project['description']) 20 | return u' '.join(elements) 21 | 22 | 23 | def main(wf): 24 | # build argument parser to parse script args and collect their 25 | # values 26 | parser = argparse.ArgumentParser() 27 | # add an optional (nargs='?') --setkey argument and save its 28 | # value to 'apikey' (dest). This will be called from a separate "Run Script" 29 | # action with the API key 30 | parser.add_argument('--setkey', dest='apikey', nargs='?', default=None) 31 | parser.add_argument('--seturl', dest='apiurl', nargs='?', default=None) 32 | parser.add_argument('query', nargs='?', default=None) 33 | # parse the script's arguments 34 | args = parser.parse_args(wf.args) 35 | 36 | #################################################################### 37 | # Save the provided API key or URL 38 | #################################################################### 39 | 40 | # decide what to do based on arguments 41 | if args.apikey: # Script was passed an API key 42 | log.info("Setting API Key") 43 | wf.save_password('gitea_api_key', args.apikey) 44 | return 0 # 0 means script exited cleanly 45 | 46 | #################################################################### 47 | # Check that we have an API key saved 48 | #################################################################### 49 | 50 | try: 51 | wf.get_password('gitea_api_key') 52 | except PasswordNotFound: # API key has not yet been set 53 | wf.add_item('No API key set', 54 | 'Please use `tea set key` to set your Gitea API key.', 55 | valid=False, 56 | icon=ICON_ERROR) 57 | wf.send_feedback() 58 | return 0 59 | 60 | #################################################################### 61 | # Check for the URL 62 | #################################################################### 63 | 64 | log.info(args.apiurl) 65 | # if user passed a new URL 66 | if args.apiurl: 67 | wf.settings['base_url'] = args.apiurl 68 | wf.settings['api_url'] = args.apiurl + "/api/v1/repos/search" 69 | # if not yet set, fall back to try.gitea.io 70 | elif not wf.settings.get('base_url'): 71 | wf.settings['base_url'] = 'https://try.gitea.io' 72 | wf.settings['api_url'] = 'https://try.gitea.io/api/v1/repos/search' 73 | return 0 74 | 75 | log.debug("Setting API URL to {url}".format( 76 | url=wf.settings.get('api_url'))) 77 | 78 | #################################################################### 79 | # View/filter gitea Projects 80 | #################################################################### 81 | 82 | query = args.query 83 | 84 | projects_gitea = wf.cached_data('projects_gitea', None, max_age=0) 85 | 86 | # log.debug('XXX %s', projects_gitea) 87 | 88 | if wf.update_available: 89 | # Add a notification to top of Script Filter results 90 | wf.add_item('New version available', 91 | 'Action this item to install the update', 92 | autocomplete='workflow:update', 93 | icon=ICON_INFO) 94 | 95 | # Notify the user if the cache is being updated 96 | if is_running('update') and not projects_gitea: 97 | log.info("Updating project list via gitea..") 98 | wf.rerun = 0.5 99 | wf.add_item('Updating project list via gitea...', 100 | subtitle=u'This can take some time if you have a large number of projects.', 101 | valid=False, 102 | icon=ICON_INFO) 103 | 104 | # Start update script if cached data is too old (or doesn't exist) 105 | if not wf.cached_data_fresh('projects_gitea', max_age=1) and not is_running('update'): 106 | cmd = ['/usr/bin/python3', wf.workflowfile('update.py')] 107 | run_in_background('update', cmd) 108 | # foreground for debugging 109 | # run_command(cmd) 110 | wf.rerun = 0.5 111 | 112 | # If script was passed a query, use it to filter projects 113 | if query and projects_gitea: 114 | projects_gitea = wf.filter( 115 | query, projects_gitea, key=search_for_project, min_score=20) 116 | 117 | if not projects_gitea: # we have no data to show, so show a warning and stop 118 | wf.add_item('No projects found', icon=ICON_WARNING) 119 | wf.send_feedback() 120 | return 0 121 | 122 | # log.debug('%s', projects_gitea) 123 | 124 | # Create thumbnails from repo avatars - slow! 125 | # thumbs = Thumbs(wf.datafile('thumbs')) 126 | 127 | # Loop through the returned posts and add an item for each to 128 | # the list of results for Alfred 129 | for project in projects_gitea: 130 | # icon = 131 | wf.add_item(title=project['full_name'], 132 | subtitle=project['description'], 133 | arg=project['html_url'], 134 | valid=True, 135 | # Create thumbnails from repo avatars - slow! 136 | # icon=thumbs.thumbnail(requests.get(project['owner']['avatar_url']).url), 137 | icon=wf.workflowfile('gitea-transparent.png'), 138 | largetext=project['full_name'], 139 | quicklookurl=project['html_url'], 140 | copytext=project['html_url'], 141 | uid=project['id']) 142 | 143 | # Send the results to Alfred as XML 144 | wf.send_feedback() 145 | 146 | # Create thumbnails from repo avatars - slow! 147 | # thumbs.save_queue() 148 | # if thumbs.has_queue: 149 | # thumbs.process_queue() 150 | # if not is_running('generate_thumbnails'): 151 | # run_in_background('generate_thumbnails', 152 | # ['/usr/bin/python3', 153 | # wf.workflowfile('thumbnails.py')]) 154 | 155 | # return 0 156 | 157 | 158 | if __name__ == u"__main__": 159 | wf = Workflow3(update_settings={ 160 | 'github_slug': 'pat-s/alfred-gitea', 161 | }) 162 | log = wf.logger 163 | sys.exit(wf.run(main)) 164 | -------------------------------------------------------------------------------- /source/home.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # encoding: utf-8 3 | from workflow import Workflow3 4 | 5 | wf = Workflow3(update_settings={ 6 | 'github_slug': 'pat-s/alfred-gitea', 7 | }) 8 | url = wf.settings.get('base_url') 9 | print(url) 10 | -------------------------------------------------------------------------------- /source/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pat-s/alfred-gitea/be3f05c450a77dc02c1916b525cddfd8099f7072/source/icon.png -------------------------------------------------------------------------------- /source/info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | com.pat-s.gitea 7 | connections 8 | 9 | 062602E1-BCE3-4512-BE62-C2B983892F6B 10 | 11 | 12 | destinationuid 13 | 673BA5EB-6B97-4DB7-B4ED-524980FCD44A 14 | modifiers 15 | 0 16 | modifiersubtext 17 | 18 | vitoclose 19 | 20 | 21 | 22 | 2652FC1D-FD24-4B86-9AA5-7F4BD14CF14F 23 | 24 | 25 | destinationuid 26 | 7EAFE45A-C48E-48BD-927D-05310B90F465 27 | modifiers 28 | 0 29 | modifiersubtext 30 | 31 | vitoclose 32 | 33 | 34 | 35 | 3FD25D48-DC7D-4AF9-A885-8CA7835F38CE 36 | 37 | 38 | destinationuid 39 | 9DE2138E-3113-45AF-AADC-3FC96C3E70BF 40 | modifiers 41 | 0 42 | modifiersubtext 43 | 44 | vitoclose 45 | 46 | 47 | 48 | 43190A21-5507-4EC7-AAE7-08524F652621 49 | 50 | 51 | destinationuid 52 | E29B633A-0288-4B74-9281-7E0C7CB89952 53 | modifiers 54 | 0 55 | modifiersubtext 56 | 57 | vitoclose 58 | 59 | 60 | 61 | 5F2AB842-E03F-4CCC-A460-C012CD9AABB8 62 | 63 | 64 | destinationuid 65 | 8AF1A569-3E8E-40A2-8D88-32D557568F28 66 | modifiers 67 | 0 68 | modifiersubtext 69 | 70 | vitoclose 71 | 72 | 73 | 74 | 673BA5EB-6B97-4DB7-B4ED-524980FCD44A 75 | 76 | 77 | destinationuid 78 | 8AF1A569-3E8E-40A2-8D88-32D557568F28 79 | modifiers 80 | 0 81 | modifiersubtext 82 | 83 | vitoclose 84 | 85 | 86 | 87 | 673DCF4B-FFDA-4691-B380-A5C09F57F396 88 | 89 | 90 | destinationuid 91 | 2652FC1D-FD24-4B86-9AA5-7F4BD14CF14F 92 | modifiers 93 | 0 94 | modifiersubtext 95 | 96 | vitoclose 97 | 98 | 99 | 100 | 70E08222-8577-40BF-880A-64806BC42552 101 | 102 | 103 | destinationuid 104 | 5F2AB842-E03F-4CCC-A460-C012CD9AABB8 105 | modifiers 106 | 0 107 | modifiersubtext 108 | 109 | vitoclose 110 | 111 | 112 | 113 | 7721B240-9DC1-49E4-A480-A4D422557660 114 | 115 | 116 | destinationuid 117 | 062602E1-BCE3-4512-BE62-C2B983892F6B 118 | modifiers 119 | 0 120 | modifiersubtext 121 | 122 | vitoclose 123 | 124 | 125 | 126 | destinationuid 127 | 3FD25D48-DC7D-4AF9-A885-8CA7835F38CE 128 | modifiers 129 | 0 130 | modifiersubtext 131 | 132 | vitoclose 133 | 134 | 135 | 136 | 9DE2138E-3113-45AF-AADC-3FC96C3E70BF 137 | 138 | 139 | destinationuid 140 | 70E08222-8577-40BF-880A-64806BC42552 141 | modifiers 142 | 0 143 | modifiersubtext 144 | 145 | vitoclose 146 | 147 | 148 | 149 | A5D12149-ECF6-41B4-BD7B-494393E20017 150 | 151 | 152 | destinationuid 153 | BDAD15D7-A5DE-4832-BBCD-1F965BC10E92 154 | modifiers 155 | 0 156 | modifiersubtext 157 | 158 | vitoclose 159 | 160 | 161 | 162 | BDAD15D7-A5DE-4832-BBCD-1F965BC10E92 163 | 164 | 165 | destinationuid 166 | 219A33B7-3B8F-4841-99B2-FFEE86AEEBB9 167 | modifiers 168 | 0 169 | modifiersubtext 170 | 171 | vitoclose 172 | 173 | 174 | 175 | C6E5186C-7FE6-462B-A507-4C14FE7BD257 176 | 177 | 178 | destinationuid 179 | E733578E-EEE5-4E86-BC2C-843D47757881 180 | modifiers 181 | 0 182 | modifiersubtext 183 | 184 | vitoclose 185 | 186 | 187 | 188 | E29B633A-0288-4B74-9281-7E0C7CB89952 189 | 190 | 191 | destinationuid 192 | 0A6B15E3-80F5-4D19-BE9E-77E1814CF370 193 | modifiers 194 | 0 195 | modifiersubtext 196 | 197 | vitoclose 198 | 199 | 200 | 201 | F58CD04C-B3C4-4FB7-A6A8-6413827C2055 202 | 203 | 204 | destinationuid 205 | C6E5186C-7FE6-462B-A507-4C14FE7BD257 206 | modifiers 207 | 0 208 | modifiersubtext 209 | 210 | vitoclose 211 | 212 | 213 | 214 | 215 | createdby 216 | Patrick Schratz 217 | description 218 | Alfred Gitea workflow 219 | disabled 220 | 221 | name 222 | Gitea 223 | objects 224 | 225 | 226 | config 227 | 228 | browser 229 | 230 | spaces 231 | 232 | url 233 | {var:repo}/{var:subfolder} 234 | utf8 235 | 236 | 237 | type 238 | alfred.workflow.action.openurl 239 | uid 240 | 8AF1A569-3E8E-40A2-8D88-32D557568F28 241 | version 242 | 1 243 | 244 | 245 | config 246 | 247 | argument 248 | 249 | passthroughargument 250 | 251 | variables 252 | 253 | repo 254 | {query} 255 | subfolder 256 | 257 | 258 | 259 | type 260 | alfred.workflow.utility.argument 261 | uid 262 | 673BA5EB-6B97-4DB7-B4ED-524980FCD44A 263 | version 264 | 1 265 | 266 | 267 | config 268 | 269 | inputstring 270 | {var:quick_open} 271 | matchcasesensitive 272 | 273 | matchmode 274 | 0 275 | matchstring 276 | true 277 | 278 | type 279 | alfred.workflow.utility.filter 280 | uid 281 | 062602E1-BCE3-4512-BE62-C2B983892F6B 282 | version 283 | 1 284 | 285 | 286 | config 287 | 288 | alfredfiltersresults 289 | 290 | alfredfiltersresultsmatchmode 291 | 0 292 | argumenttreatemptyqueryasnil 293 | 294 | argumenttrimmode 295 | 0 296 | argumenttype 297 | 1 298 | escaping 299 | 102 300 | keyword 301 | tea 302 | queuedelaycustom 303 | 3 304 | queuedelayimmediatelyinitially 305 | 306 | queuedelaymode 307 | 0 308 | queuemode 309 | 1 310 | runningsubtext 311 | Fetching Gitea projects... 312 | script 313 | python gitea.py "{query}" 314 | scriptargtype 315 | 0 316 | scriptfile 317 | 318 | subtext 319 | 320 | title 321 | Search for Gitea projects 322 | type 323 | 0 324 | withspace 325 | 326 | 327 | type 328 | alfred.workflow.input.scriptfilter 329 | uid 330 | 7721B240-9DC1-49E4-A480-A4D422557660 331 | version 332 | 3 333 | 334 | 335 | config 336 | 337 | argumenttrimmode 338 | 0 339 | argumenttype 340 | 2 341 | fixedorder 342 | 343 | items 344 | [{"title":"Code"},{"title":"Issues","arg":"issues"},{"title":"Pull Request","arg":"pulls"},{"title":"Releases","arg":"releases"}] 345 | keyword 346 | vget 347 | runningsubtext 348 | 349 | subtext 350 | 351 | title 352 | Overview 353 | withspace 354 | 355 | 356 | type 357 | alfred.workflow.input.listfilter 358 | uid 359 | 70E08222-8577-40BF-880A-64806BC42552 360 | version 361 | 1 362 | 363 | 364 | config 365 | 366 | argument 367 | 368 | passthroughargument 369 | 370 | variables 371 | 372 | subfolder 373 | {query} 374 | 375 | 376 | type 377 | alfred.workflow.utility.argument 378 | uid 379 | 5F2AB842-E03F-4CCC-A460-C012CD9AABB8 380 | version 381 | 1 382 | 383 | 384 | config 385 | 386 | inputstring 387 | {var:quick_open} 388 | matchcasesensitive 389 | 390 | matchmode 391 | 1 392 | matchstring 393 | true 394 | 395 | type 396 | alfred.workflow.utility.filter 397 | uid 398 | 3FD25D48-DC7D-4AF9-A885-8CA7835F38CE 399 | version 400 | 1 401 | 402 | 403 | config 404 | 405 | argument 406 | 407 | passthroughargument 408 | 409 | variables 410 | 411 | repo 412 | {query} 413 | 414 | 415 | type 416 | alfred.workflow.utility.argument 417 | uid 418 | 9DE2138E-3113-45AF-AADC-3FC96C3E70BF 419 | version 420 | 1 421 | 422 | 423 | config 424 | 425 | concurrently 426 | 427 | escaping 428 | 102 429 | script 430 | python gitea.py --setkey "{query}" 431 | scriptargtype 432 | 0 433 | scriptfile 434 | 435 | type 436 | 0 437 | 438 | type 439 | alfred.workflow.action.script 440 | uid 441 | C6E5186C-7FE6-462B-A507-4C14FE7BD257 442 | version 443 | 2 444 | 445 | 446 | config 447 | 448 | lastpathcomponent 449 | 450 | onlyshowifquerypopulated 451 | 452 | removeextension 453 | 454 | text 455 | Your Gitea Access Token has been saved. 456 | title 457 | Saved Access Token 458 | 459 | type 460 | alfred.workflow.output.notification 461 | uid 462 | E733578E-EEE5-4E86-BC2C-843D47757881 463 | version 464 | 1 465 | 466 | 467 | config 468 | 469 | argumenttype 470 | 0 471 | keyword 472 | tea set key 473 | subtext 474 | Enter your Gitea Access Token and hit ENTER 475 | text 476 | Set your Gitea Access Token 477 | withspace 478 | 479 | 480 | type 481 | alfred.workflow.input.keyword 482 | uid 483 | F58CD04C-B3C4-4FB7-A6A8-6413827C2055 484 | version 485 | 1 486 | 487 | 488 | config 489 | 490 | lastpathcomponent 491 | 492 | onlyshowifquerypopulated 493 | 494 | removeextension 495 | 496 | text 497 | Your Gitea URL has been saved. 498 | title 499 | Saved Gitea URL 500 | 501 | type 502 | alfred.workflow.output.notification 503 | uid 504 | 7EAFE45A-C48E-48BD-927D-05310B90F465 505 | version 506 | 1 507 | 508 | 509 | config 510 | 511 | concurrently 512 | 513 | escaping 514 | 102 515 | script 516 | python gitea.py --seturl "{query}" 517 | scriptargtype 518 | 0 519 | scriptfile 520 | 521 | type 522 | 0 523 | 524 | type 525 | alfred.workflow.action.script 526 | uid 527 | 2652FC1D-FD24-4B86-9AA5-7F4BD14CF14F 528 | version 529 | 2 530 | 531 | 532 | config 533 | 534 | argumenttype 535 | 0 536 | keyword 537 | tea set url 538 | subtext 539 | Enter your Gitea URL and hit ENTER 540 | text 541 | Set your Gitea URL 542 | withspace 543 | 544 | 545 | type 546 | alfred.workflow.input.keyword 547 | uid 548 | 673DCF4B-FFDA-4691-B380-A5C09F57F396 549 | version 550 | 1 551 | 552 | 553 | config 554 | 555 | concurrently 556 | 557 | escaping 558 | 102 559 | script 560 | python reset.py 561 | scriptargtype 562 | 1 563 | scriptfile 564 | 565 | type 566 | 0 567 | 568 | type 569 | alfred.workflow.action.script 570 | uid 571 | E29B633A-0288-4B74-9281-7E0C7CB89952 572 | version 573 | 2 574 | 575 | 576 | config 577 | 578 | lastpathcomponent 579 | 580 | onlyshowifquerypopulated 581 | 582 | removeextension 583 | 584 | text 585 | 586 | title 587 | Resetted 'tea' workflow settings 588 | 589 | type 590 | alfred.workflow.output.notification 591 | uid 592 | 0A6B15E3-80F5-4D19-BE9E-77E1814CF370 593 | version 594 | 1 595 | 596 | 597 | config 598 | 599 | argumenttype 600 | 2 601 | keyword 602 | tea reset 603 | subtext 604 | Removes cache, url and tokens set 605 | text 606 | Reset 'tea' workflow settings 607 | withspace 608 | 609 | 610 | type 611 | alfred.workflow.input.keyword 612 | uid 613 | 43190A21-5507-4EC7-AAE7-08524F652621 614 | version 615 | 1 616 | 617 | 618 | config 619 | 620 | lastpathcomponent 621 | 622 | onlyshowifquerypopulated 623 | 624 | removeextension 625 | 626 | text 627 | 628 | title 629 | Cleared 'tea' workflow cache 630 | 631 | type 632 | alfred.workflow.output.notification 633 | uid 634 | 219A33B7-3B8F-4841-99B2-FFEE86AEEBB9 635 | version 636 | 1 637 | 638 | 639 | config 640 | 641 | concurrently 642 | 643 | escaping 644 | 102 645 | script 646 | python clear-cache.py 647 | scriptargtype 648 | 1 649 | scriptfile 650 | 651 | type 652 | 0 653 | 654 | type 655 | alfred.workflow.action.script 656 | uid 657 | BDAD15D7-A5DE-4832-BBCD-1F965BC10E92 658 | version 659 | 2 660 | 661 | 662 | config 663 | 664 | argumenttype 665 | 2 666 | keyword 667 | tea clearcache 668 | subtext 669 | Removes the cache 670 | text 671 | Clear the 'tea' workflow cache 672 | withspace 673 | 674 | 675 | type 676 | alfred.workflow.input.keyword 677 | uid 678 | A5D12149-ECF6-41B4-BD7B-494393E20017 679 | version 680 | 1 681 | 682 | 683 | readme 684 | Setup and Usage 685 | 686 | * Generate a Gitea access token (https://<host>/user/settings/applications then run `tea set key <yourkey>` 687 | 688 | * (Optionally) Tell it where the Gitea API you want to connect to is by running `tea set url https://<host>/api/v1/repos/search` 689 | * Defaults to try.gitea.io domain 690 | 691 | * search for projects with `tea <search>` 692 | uidata 693 | 694 | 062602E1-BCE3-4512-BE62-C2B983892F6B 695 | 696 | xpos 697 | 200 698 | ypos 699 | 30 700 | 701 | 0A6B15E3-80F5-4D19-BE9E-77E1814CF370 702 | 703 | xpos 704 | 455 705 | ypos 706 | 620 707 | 708 | 219A33B7-3B8F-4841-99B2-FFEE86AEEBB9 709 | 710 | xpos 711 | 450 712 | ypos 713 | 745 714 | 715 | 2652FC1D-FD24-4B86-9AA5-7F4BD14CF14F 716 | 717 | xpos 718 | 260 719 | ypos 720 | 410 721 | 722 | 3FD25D48-DC7D-4AF9-A885-8CA7835F38CE 723 | 724 | xpos 725 | 200 726 | ypos 727 | 160 728 | 729 | 43190A21-5507-4EC7-AAE7-08524F652621 730 | 731 | xpos 732 | 35 733 | ypos 734 | 620 735 | 736 | 5F2AB842-E03F-4CCC-A460-C012CD9AABB8 737 | 738 | xpos 739 | 600 740 | ypos 741 | 160 742 | 743 | 673BA5EB-6B97-4DB7-B4ED-524980FCD44A 744 | 745 | xpos 746 | 300 747 | ypos 748 | 30 749 | 750 | 673DCF4B-FFDA-4691-B380-A5C09F57F396 751 | 752 | xpos 753 | 30 754 | ypos 755 | 410 756 | 757 | 70E08222-8577-40BF-880A-64806BC42552 758 | 759 | xpos 760 | 410 761 | ypos 762 | 130 763 | 764 | 7721B240-9DC1-49E4-A480-A4D422557660 765 | 766 | xpos 767 | 10 768 | ypos 769 | 50 770 | 771 | 7EAFE45A-C48E-48BD-927D-05310B90F465 772 | 773 | xpos 774 | 470 775 | ypos 776 | 410 777 | 778 | 8AF1A569-3E8E-40A2-8D88-32D557568F28 779 | 780 | xpos 781 | 690 782 | ypos 783 | 10 784 | 785 | 9DE2138E-3113-45AF-AADC-3FC96C3E70BF 786 | 787 | xpos 788 | 300 789 | ypos 790 | 160 791 | 792 | A5D12149-ECF6-41B4-BD7B-494393E20017 793 | 794 | xpos 795 | 35 796 | ypos 797 | 745 798 | 799 | BDAD15D7-A5DE-4832-BBCD-1F965BC10E92 800 | 801 | xpos 802 | 305 803 | ypos 804 | 745 805 | 806 | C6E5186C-7FE6-462B-A507-4C14FE7BD257 807 | 808 | xpos 809 | 255 810 | ypos 811 | 290 812 | 813 | E29B633A-0288-4B74-9281-7E0C7CB89952 814 | 815 | xpos 816 | 305 817 | ypos 818 | 620 819 | 820 | E733578E-EEE5-4E86-BC2C-843D47757881 821 | 822 | xpos 823 | 465 824 | ypos 825 | 290 826 | 827 | F58CD04C-B3C4-4FB7-A6A8-6413827C2055 828 | 829 | xpos 830 | 25 831 | ypos 832 | 290 833 | 834 | 835 | variables 836 | 837 | quick_open 838 | 839 | 840 | variablesdontexport 841 | 842 | quick_open 843 | 844 | version 845 | 1.0.0 846 | webaddress 847 | https://github.com/pat-s/alfred-gitea 848 | 849 | 850 | -------------------------------------------------------------------------------- /source/mureq.py: -------------------------------------------------------------------------------- 1 | """ 2 | mureq is a replacement for python-requests, intended to be vendored 3 | in-tree by Linux systems software and other lightweight applications. 4 | 5 | mureq is copyright 2021 by its contributors and is released under the 6 | 0BSD ("zero-clause BSD") license. 7 | """ 8 | import contextlib 9 | import io 10 | import os.path 11 | import socket 12 | import ssl 13 | import sys 14 | import urllib.parse 15 | from http.client import HTTPConnection, HTTPSConnection, HTTPMessage, HTTPException 16 | 17 | __version__ = '0.2.0' 18 | 19 | __all__ = ['HTTPException', 'TooManyRedirects', 'Response', 20 | 'yield_response', 'request', 'get', 'post', 'head', 'put', 'patch', 'delete'] 21 | 22 | DEFAULT_TIMEOUT = 15.0 23 | 24 | # e.g. "Python 3.8.10" 25 | DEFAULT_UA = "Python " + sys.version.split()[0] 26 | 27 | 28 | def request(method, url, *, read_limit=None, **kwargs): 29 | """request performs an HTTP request and reads the entire response body. 30 | 31 | :param str method: HTTP method to request (e.g. 'GET', 'POST') 32 | :param str url: URL to request 33 | :param read_limit: maximum number of bytes to read from the body, or None for no limit 34 | :type read_limit: int or None 35 | :param kwargs: optional arguments defined by yield_response 36 | :return: Response object 37 | :rtype: Response 38 | :raises: HTTPException 39 | """ 40 | with yield_response(method, url, **kwargs) as response: 41 | try: 42 | body = response.read(read_limit) 43 | except HTTPException: 44 | raise 45 | except IOError as e: 46 | raise HTTPException(str(e)) from e 47 | return Response(response.url, response.status, _prepare_incoming_headers(response.headers), body) 48 | 49 | 50 | def get(url, **kwargs): 51 | """get performs an HTTP GET request.""" 52 | return request('GET', url=url, **kwargs) 53 | 54 | 55 | def post(url, body=None, **kwargs): 56 | """post performs an HTTP POST request.""" 57 | return request('POST', url=url, body=body, **kwargs) 58 | 59 | 60 | def head(url, **kwargs): 61 | """head performs an HTTP HEAD request.""" 62 | return request('HEAD', url=url, **kwargs) 63 | 64 | 65 | def put(url, body=None, **kwargs): 66 | """put performs an HTTP PUT request.""" 67 | return request('PUT', url=url, body=body, **kwargs) 68 | 69 | 70 | def patch(url, body=None, **kwargs): 71 | """patch performs an HTTP PATCH request.""" 72 | return request('PATCH', url=url, body=body, **kwargs) 73 | 74 | 75 | def delete(url, **kwargs): 76 | """delete performs an HTTP DELETE request.""" 77 | return request('DELETE', url=url, **kwargs) 78 | 79 | 80 | @contextlib.contextmanager 81 | def yield_response(method, url, *, unix_socket=None, timeout=DEFAULT_TIMEOUT, headers=None, 82 | params=None, body=None, form=None, json=None, verify=True, source_address=None, 83 | max_redirects=None, ssl_context=None): 84 | """yield_response is a low-level API that exposes the actual 85 | http.client.HTTPResponse via a contextmanager. 86 | 87 | Note that unlike mureq.Response, http.client.HTTPResponse does not 88 | automatically canonicalize multiple appearances of the same header by 89 | joining them together with a comma delimiter. To retrieve canonicalized 90 | headers from the response, use response.getheader(): 91 | https://docs.python.org/3/library/http.client.html#http.client.HTTPResponse.getheader 92 | 93 | :param str method: HTTP method to request (e.g. 'GET', 'POST') 94 | :param str url: URL to request 95 | :param unix_socket: path to Unix domain socket to query, or None for a normal TCP request 96 | :type unix_socket: str or None 97 | :param timeout: timeout in seconds, or None for no timeout (default: 15 seconds) 98 | :type timeout: float or None 99 | :param headers: HTTP headers as a mapping or list of key-value pairs 100 | :param params: parameters to be URL-encoded and added to the query string, as a mapping or list of key-value pairs 101 | :param body: payload body of the request 102 | :type body: bytes or None 103 | :param form: parameters to be form-encoded and sent as the payload body, as a mapping or list of key-value pairs 104 | :param json: object to be serialized as JSON and sent as the payload body 105 | :param bool verify: whether to verify TLS certificates (default: True) 106 | :param source_address: source address to bind to for TCP 107 | :type source_address: str or tuple(str, int) or None 108 | :param max_redirects: maximum number of redirects to follow, or None (the default) for no redirection 109 | :type max_redirects: int or None 110 | :param ssl_context: TLS config to control certificate validation, or None for default behavior 111 | :type ssl_context: ssl.SSLContext or None 112 | :return: http.client.HTTPResponse, yielded as context manager 113 | :rtype: http.client.HTTPResponse 114 | :raises: HTTPException 115 | """ 116 | method = method.upper() 117 | headers = _prepare_outgoing_headers(headers) 118 | enc_params = _prepare_params(params) 119 | body = _prepare_body(body, form, json, headers) 120 | 121 | visited_urls = [] 122 | 123 | while max_redirects is None or len(visited_urls) <= max_redirects: 124 | url, conn, path = _prepare_request(method, url, enc_params=enc_params, timeout=timeout, unix_socket=unix_socket, verify=verify, source_address=source_address, ssl_context=ssl_context) 125 | enc_params = '' # don't reappend enc_params if we get redirected 126 | visited_urls.append(url) 127 | try: 128 | try: 129 | conn.request(method, path, headers=headers, body=body) 130 | response = conn.getresponse() 131 | except HTTPException: 132 | raise 133 | except IOError as e: 134 | # wrap any IOError that is not already an HTTPException 135 | # in HTTPException, exposing a uniform API for remote errors 136 | raise HTTPException(str(e)) from e 137 | redirect_url = _check_redirect(url, response.status, response.headers) 138 | if max_redirects is None or redirect_url is None: 139 | response.url = url # https://bugs.python.org/issue42062 140 | yield response 141 | return 142 | else: 143 | url = redirect_url 144 | if response.status == 303: 145 | # 303 See Other: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303 146 | method = 'GET' 147 | finally: 148 | conn.close() 149 | 150 | raise TooManyRedirects(visited_urls) 151 | 152 | 153 | class Response: 154 | """Response contains a completely consumed HTTP response. 155 | 156 | :ivar str url: the retrieved URL, indicating whether a redirection occurred 157 | :ivar int status_code: the HTTP status code 158 | :ivar http.client.HTTPMessage headers: the HTTP headers 159 | :ivar bytes body: the payload body of the response 160 | """ 161 | 162 | __slots__ = ('url', 'status_code', 'headers', 'body') 163 | 164 | def __init__(self, url, status_code, headers, body): 165 | self.url, self.status_code, self.headers, self.body = url, status_code, headers, body 166 | 167 | def __repr__(self): 168 | return f"Response(status_code={self.status_code:d})" 169 | 170 | @property 171 | def ok(self): 172 | """ok returns whether the response had a successful status code 173 | (anything other than a 40x or 50x).""" 174 | return not (400 <= self.status_code < 600) 175 | 176 | @property 177 | def content(self): 178 | """content returns the response body (the `body` member). This is an 179 | alias for compatibility with requests.Response.""" 180 | return self.body 181 | 182 | def raise_for_status(self): 183 | """raise_for_status checks the response's success code, raising an 184 | exception for error codes.""" 185 | if not self.ok: 186 | raise HTTPErrorStatus(self.status_code) 187 | 188 | def json(self): 189 | """Attempts to deserialize the response body as UTF-8 encoded JSON.""" 190 | import json as jsonlib 191 | return jsonlib.loads(self.body) 192 | 193 | def _debugstr(self): 194 | buf = io.StringIO() 195 | print("HTTP", self.status_code, file=buf) 196 | for k, v in self.headers.items(): 197 | print(f"{k}: {v}", file=buf) 198 | print(file=buf) 199 | try: 200 | print(self.body.decode('utf-8'), file=buf) 201 | except UnicodeDecodeError: 202 | print(f"<{len(self.body)} bytes binary data>", file=buf) 203 | return buf.getvalue() 204 | 205 | 206 | class TooManyRedirects(HTTPException): 207 | """TooManyRedirects is raised when automatic following of redirects was 208 | enabled, but the server redirected too many times without completing.""" 209 | pass 210 | 211 | 212 | class HTTPErrorStatus(HTTPException): 213 | """HTTPErrorStatus is raised by Response.raise_for_status() to indicate an 214 | HTTP error code (a 40x or a 50x). Note that a well-formed response with an 215 | error code does not result in an exception unless raise_for_status() is 216 | called explicitly. 217 | """ 218 | 219 | def __init__(self, status_code): 220 | self.status_code = status_code 221 | 222 | def __str__(self): 223 | return f"HTTP response returned error code {self.status_code:d}" 224 | 225 | 226 | # end public API, begin internal implementation details 227 | 228 | _JSON_CONTENTTYPE = 'application/json' 229 | _FORM_CONTENTTYPE = 'application/x-www-form-urlencoded' 230 | 231 | 232 | class UnixHTTPConnection(HTTPConnection): 233 | """UnixHTTPConnection is a subclass of HTTPConnection that connects to a 234 | Unix domain stream socket instead of a TCP address. 235 | """ 236 | 237 | def __init__(self, path, timeout=DEFAULT_TIMEOUT): 238 | super(UnixHTTPConnection, self).__init__('localhost', timeout=timeout) 239 | self._unix_path = path 240 | 241 | def connect(self): 242 | sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 243 | try: 244 | sock.settimeout(self.timeout) 245 | sock.connect(self._unix_path) 246 | except Exception: 247 | sock.close() 248 | raise 249 | self.sock = sock 250 | 251 | 252 | def _check_redirect(url, status, response_headers): 253 | """Return the URL to redirect to, or None for no redirection.""" 254 | if status not in (301, 302, 303, 307, 308): 255 | return None 256 | location = response_headers.get('Location') 257 | if not location: 258 | return None 259 | parsed_location = urllib.parse.urlparse(location) 260 | if parsed_location.scheme: 261 | # absolute URL 262 | return location 263 | 264 | old_url = urllib.parse.urlparse(url) 265 | if location.startswith('/'): 266 | # absolute path on old hostname 267 | return urllib.parse.urlunparse((old_url.scheme, old_url.netloc, 268 | parsed_location.path, parsed_location.params, 269 | parsed_location.query, parsed_location.fragment)) 270 | 271 | # relative path on old hostname 272 | old_dir, _old_file = os.path.split(old_url.path) 273 | new_path = os.path.join(old_dir, location) 274 | return urllib.parse.urlunparse((old_url.scheme, old_url.netloc, 275 | new_path, parsed_location.params, 276 | parsed_location.query, parsed_location.fragment)) 277 | 278 | 279 | def _prepare_outgoing_headers(headers): 280 | if headers is None: 281 | headers = HTTPMessage() 282 | elif not isinstance(headers, HTTPMessage): 283 | new_headers = HTTPMessage() 284 | if hasattr(headers, 'items'): 285 | iterator = headers.items() 286 | else: 287 | iterator = iter(headers) 288 | for k, v in iterator: 289 | new_headers[k] = v 290 | headers = new_headers 291 | _setdefault_header(headers, 'User-Agent', DEFAULT_UA) 292 | return headers 293 | 294 | 295 | # XXX join multi-headers together so that get(), __getitem__(), 296 | # etc. behave intuitively, then stuff them back in an HTTPMessage. 297 | def _prepare_incoming_headers(headers): 298 | headers_dict = {} 299 | for k, v in headers.items(): 300 | headers_dict.setdefault(k, []).append(v) 301 | result = HTTPMessage() 302 | # note that iterating over headers_dict preserves the original 303 | # insertion order in all versions since Python 3.6: 304 | for k, vlist in headers_dict.items(): 305 | result[k] = ','.join(vlist) 306 | return result 307 | 308 | 309 | def _setdefault_header(headers, name, value): 310 | if name not in headers: 311 | headers[name] = value 312 | 313 | 314 | def _prepare_body(body, form, json, headers): 315 | if body is not None: 316 | if not isinstance(body, bytes): 317 | raise TypeError('body must be bytes or None', type(body)) 318 | return body 319 | 320 | if json is not None: 321 | _setdefault_header(headers, 'Content-Type', _JSON_CONTENTTYPE) 322 | import json as jsonlib 323 | return jsonlib.dumps(json).encode('utf-8') 324 | 325 | if form is not None: 326 | _setdefault_header(headers, 'Content-Type', _FORM_CONTENTTYPE) 327 | return urllib.parse.urlencode(form, doseq=True) 328 | 329 | return None 330 | 331 | 332 | def _prepare_params(params): 333 | if params is None: 334 | return '' 335 | return urllib.parse.urlencode(params, doseq=True) 336 | 337 | 338 | def _prepare_request(method, url, *, enc_params='', timeout=DEFAULT_TIMEOUT, source_address=None, unix_socket=None, verify=True, ssl_context=None): 339 | """Parses the URL, returns the path and the right HTTPConnection subclass.""" 340 | parsed_url = urllib.parse.urlparse(url) 341 | 342 | is_unix = (unix_socket is not None) 343 | scheme = parsed_url.scheme.lower() 344 | if scheme.endswith('+unix'): 345 | scheme = scheme[:-5] 346 | is_unix = True 347 | if scheme == 'https': 348 | raise ValueError("https+unix is not implemented") 349 | 350 | if scheme not in ('http', 'https'): 351 | raise ValueError("unrecognized scheme", scheme) 352 | 353 | is_https = (scheme == 'https') 354 | host = parsed_url.hostname 355 | port = 443 if is_https else 80 356 | if parsed_url.port: 357 | port = parsed_url.port 358 | 359 | if is_unix and unix_socket is None: 360 | unix_socket = urllib.parse.unquote(parsed_url.netloc) 361 | 362 | path = parsed_url.path 363 | if parsed_url.query: 364 | if enc_params: 365 | path = f'{path}?{parsed_url.query}&{enc_params}' 366 | else: 367 | path = f'{path}?{parsed_url.query}' 368 | else: 369 | if enc_params: 370 | path = f'{path}?{enc_params}' 371 | else: 372 | pass # just parsed_url.path in this case 373 | 374 | if isinstance(source_address, str): 375 | source_address = (source_address, 0) 376 | 377 | if is_unix: 378 | conn = UnixHTTPConnection(unix_socket, timeout=timeout) 379 | elif is_https: 380 | if ssl_context is None: 381 | ssl_context = ssl.create_default_context() 382 | if not verify: 383 | ssl_context.check_hostname = False 384 | ssl_context.verify_mode = ssl.CERT_NONE 385 | conn = HTTPSConnection(host, port, source_address=source_address, timeout=timeout, 386 | context=ssl_context) 387 | else: 388 | conn = HTTPConnection(host, port, source_address=source_address, timeout=timeout) 389 | 390 | munged_url = urllib.parse.urlunparse((parsed_url.scheme, parsed_url.netloc, 391 | path, parsed_url.params, 392 | '', parsed_url.fragment)) 393 | return munged_url, conn, path 394 | -------------------------------------------------------------------------------- /source/reset.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | from workflow import Workflow 3 | 4 | # log = None 5 | 6 | def main(wf): 7 | wf.reset() 8 | 9 | if __name__ == u"__main__": 10 | wf = Workflow() 11 | log = wf.logger 12 | wf.run(main) 13 | -------------------------------------------------------------------------------- /source/thumbnails.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 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-10-11 9 | # Source: https://git.deanishe.net/deanishe/alfred-unwatched-videos/src/branch/master 10 | # 11 | 12 | """Generate thumbnails in the background.""" 13 | 14 | from __future__ import print_function, unicode_literals, absolute_import 15 | 16 | import urllib 17 | import logging 18 | import hashlib 19 | import os 20 | import subprocess 21 | import sys 22 | 23 | from workflow import Workflow 24 | from workflow.util import LockFile, atomic_writer 25 | 26 | log = logging.getLogger(__name__) 27 | 28 | 29 | class Thumbs(object): 30 | """Thumbnail generator.""" 31 | 32 | def __init__(self, cachedir): 33 | """Create new ``Thumbs`` object. 34 | 35 | Args: 36 | cachedir (str): Path to directory to save thumbnails in. 37 | """ 38 | self._cachedir = os.path.abspath(cachedir) 39 | self._queue_path = os.path.join(self._cachedir, 'thumbnails.txt') 40 | self._queue = [] 41 | 42 | try: 43 | os.makedirs(self._cachedir) 44 | except (IOError, OSError): 45 | pass 46 | 47 | @property 48 | def cachedir(self): 49 | """Where thumbnails are saved. 50 | 51 | Returns: 52 | str: Directory path. 53 | """ 54 | return self._cachedir 55 | 56 | def thumbnail_path(self, img_path): 57 | """Return appropriate path for thumbnail. 58 | 59 | Args: 60 | img_path (str): Path to image file. 61 | 62 | Returns: 63 | str: Path to thumbnail. 64 | """ 65 | if isinstance(img_path, unicode): 66 | img_path = img_path.encode('utf-8') 67 | elif not isinstance(img_path, str): 68 | img_path = str(img_path) 69 | 70 | h = hashlib.md5(img_path).hexdigest() 71 | dirpath = os.path.join(self.cachedir, h[:2], h[2:4]) 72 | thumb_path = os.path.join(dirpath, u'{}.png'.format(h)) 73 | return thumb_path 74 | 75 | def thumbnail(self, img_path): 76 | """Return resized thumbnail for ``img_path``. 77 | 78 | Args: 79 | img_path (str): Path to original images. 80 | 81 | Returns: 82 | str: Path to thumbnail image. 83 | """ 84 | thumb_path = self.thumbnail_path(img_path) 85 | 86 | if os.path.exists(thumb_path): 87 | return thumb_path 88 | else: 89 | self.queue_thumbnail(img_path) 90 | return None 91 | 92 | def queue_thumbnail(self, img_path): 93 | """Add ``img_path`` to queue for later thumbnail generation. 94 | 95 | Args: 96 | img_path (str): Path to image file. 97 | """ 98 | self._queue.append(img_path) 99 | 100 | def save_queue(self): 101 | """Save queued files.""" 102 | if not self._queue: 103 | return 104 | 105 | text = [] 106 | for p in self._queue: 107 | if isinstance(p, unicode): 108 | p = p.encode('utf-8') 109 | text.append(p) 110 | log.debug('Queued for thumbnail generation : %r', p) 111 | 112 | text = b'\n'.join(text) 113 | with LockFile(self._queue_path): 114 | with atomic_writer(self._queue_path, 'ab') as fp: 115 | fp.write(b'{}\n'.format(text)) 116 | 117 | self._queue = [] 118 | 119 | @property 120 | def has_queue(self): 121 | """Whether any files are queued for thumbnail generation. 122 | 123 | Returns: 124 | bool: ``True`` if there's a queue. 125 | """ 126 | return (os.path.exists(self._queue_path) and 127 | os.path.getsize(self._queue_path) > 0) 128 | 129 | def process_queue(self): 130 | """Generate thumbnails for queued files.""" 131 | if not self.has_queue: 132 | log.debug('Thumbnail queue empty.') 133 | return 134 | 135 | queue = [] 136 | with LockFile(self._queue_path): 137 | with open(self._queue_path) as fp: 138 | for line in fp: 139 | line = line.strip() 140 | if not line: 141 | continue 142 | queue.append(line) 143 | with atomic_writer(self._queue_path, 'wb') as fp: 144 | fp.write('') 145 | 146 | succeeded = True 147 | for i, img_path in enumerate(queue): 148 | log.debug('Generating thumbnail %d/%d ...', i + 1, len(queue)) 149 | if not self.generate_thumbnail(img_path): 150 | succeeded = False 151 | 152 | return succeeded 153 | 154 | def generate_thumbnail(self, img_path): 155 | """Generate and save thumbnail for ``img_path``. 156 | 157 | Args: 158 | img_path (str): Path to image file. 159 | 160 | Returns: 161 | bool: ``True`` if generation succeeded, else ``False``. 162 | """ 163 | thumb_path = self.thumbnail_path(img_path) 164 | dirpath = os.path.dirname(thumb_path) 165 | try: 166 | os.makedirs(dirpath) 167 | except OSError: 168 | pass 169 | 170 | urllib.urlretrieve(img_path, thumb_path) 171 | 172 | log.debug('Wrote thumbnail for `%s` to `%s`.', img_path, thumb_path) 173 | 174 | return True 175 | 176 | 177 | def main(wf): 178 | """Generate any thumbnails pending in the queue. 179 | 180 | Args: 181 | wf (Workflow): Current workflow instance. 182 | """ 183 | t = Thumbs(wf.datafile('thumbs')) 184 | t.process_queue() 185 | 186 | if __name__ == '__main__': 187 | wf = Workflow() 188 | log = wf.logger 189 | sys.exit(wf.run(main)) 190 | -------------------------------------------------------------------------------- /source/thumbnails.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pat-s/alfred-gitea/be3f05c450a77dc02c1916b525cddfd8099f7072/source/thumbnails.pyc -------------------------------------------------------------------------------- /source/update.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | from workflow import Workflow, Workflow3, ICON_WEB, ICON_WARNING, ICON_INFO, web, PasswordNotFound 3 | # from workflow import web, Workflow3, PasswordNotFound 4 | import mureq 5 | 6 | # log = None 7 | 8 | 9 | def get_projects(api_key, url): 10 | """ 11 | Parse all pages of projects 12 | :return: list 13 | """ 14 | return get_project_page(api_key, url, 1, []) 15 | 16 | 17 | def get_project_page(api_key, url, page, list): 18 | log.info("Calling API page {page}".format(page=page)) 19 | params = dict(token=api_key, per_page=100, page=page, membership='true') 20 | r = mureq.get(url, params = params) 21 | 22 | log.debug('URL: %s', url) 23 | 24 | # throw an error if request failed 25 | # Workflow will catch this and show it to the user 26 | r.raise_for_status() 27 | 28 | # Parse the JSON returned by Gitea and extract the projects 29 | # result = list + r.json()['data'] 30 | projects_gitea = list + r.json()['data'] 31 | 32 | log.debug(projects_gitea) 33 | 34 | # count the total amounts of items from the http header 35 | total_count = int(r.headers.get('x-total-count')) 36 | # because api call is made with per_page=100, we get roughly 2 repos per call 37 | pages_count = total_count // 2 38 | log.debug('pages count: %s', pages_count) 39 | 40 | page = page + 1 41 | log.debug('NEXTPAGE: %s', page) 42 | if page < pages_count + 1: 43 | log.debug('nextpage', page) 44 | projects_gitea = get_project_page( 45 | api_key, url, page, projects_gitea) 46 | 47 | return projects_gitea 48 | 49 | 50 | def main(wf): 51 | try: 52 | # Get API key from Keychain 53 | api_key = wf.get_password('gitea_api_key') 54 | api_url = wf.settings.get('api_url') 55 | 56 | # Retrieve projects from cache if available and no more than 600 57 | # seconds old 58 | def wrapper(): 59 | return get_projects(api_key, api_url) 60 | 61 | projects_gitea = wf.cached_data( 62 | 'projects_gitea', wrapper, max_age=3600) 63 | 64 | # Record our progress in the log file 65 | log.info('{} gitea repos cached'.format(len(projects_gitea))) 66 | 67 | except PasswordNotFound: # API key has not yet been set 68 | # Nothing we can do about this, so just log it 69 | log.error('No API key saved') 70 | 71 | 72 | if __name__ == u"__main__": 73 | wf = Workflow() 74 | log = wf.logger 75 | wf.run(main) 76 | -------------------------------------------------------------------------------- /source/workflow/Notify.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pat-s/alfred-gitea/be3f05c450a77dc02c1916b525cddfd8099f7072/source/workflow/Notify.tgz -------------------------------------------------------------------------------- /source/workflow/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 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 | -------------------------------------------------------------------------------- /source/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 | -------------------------------------------------------------------------------- /source/workflow/notify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 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.readPlist(ip_path) 147 | log().debug("changing bundle ID to %r", bundle_id) 148 | data["CFBundleIdentifier"] = bundle_id 149 | plistlib.writePlist(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 | -------------------------------------------------------------------------------- /source/workflow/update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 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 | -------------------------------------------------------------------------------- /source/workflow/util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 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 | -------------------------------------------------------------------------------- /source/workflow/version: -------------------------------------------------------------------------------- 1 | 1.40.0 -------------------------------------------------------------------------------- /source/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 | --------------------------------------------------------------------------------