├── Ariafred.alfredworkflow ├── README.md ├── screenshots ├── ariafred.gif ├── bt.png ├── filter.png ├── run.png └── stat.png └── src ├── Ariafred.app └── Contents │ ├── Info.plist │ ├── MacOS │ └── Ariafred │ ├── PkgInfo │ └── Resources │ ├── Ariafred.icns │ └── en.lproj │ ├── Credits.rtf │ ├── InfoPlist.strings │ └── MainMenu.nib ├── LICENSE ├── active.png ├── aria.py ├── aria_actions.py ├── aria_actions.pyc ├── complete.png ├── deleted.png ├── download.png ├── error.png ├── icon.png ├── info.plist ├── notifier.py ├── paused.png ├── removed.png ├── stopped.png ├── upload.png ├── version ├── waiting.png └── workflow ├── Notify.tgz ├── __init__.py ├── background.py ├── notify.py ├── update.py ├── util.py ├── version ├── web.py ├── workflow.py └── workflow3.py /Ariafred.alfredworkflow: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/Ariafred.alfredworkflow -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ariafred 2 | 3 | ![screenshot](https://github.com/Wildog/Ariafred/raw/master/screenshots/ariafred.gif) 4 | 5 | Manage Aria2 downloads directly in Alfred 3, with background notification. 6 | 7 | ## Usage 8 | 9 | ### Activate Ariafred 10 | 11 | You can set hotkey to activate Ariafred after installing it, my personal recommendation is `Command` + `Shift` + `A`, you can also type keyword `aria` in Alfred to activate Ariafred 12 | 13 | ### Filter by query 14 | 15 | 1. Type keywords to filter by task name 16 | 2. Type `active` / `done` / `paused` / `pending` / `error` to filter by status 17 | 3. You can filter by status and task name simultaneously: 18 | 19 | ![filter](https://github.com/Wildog/Ariafred/raw/master/screenshots/filter.png) 20 | 21 | ### Overall status 22 | 23 | ![stat](https://github.com/Wildog/Ariafred/raw/master/screenshots/stat.png) 24 | 25 | 1. Type keyword `stat` to view overall status 26 | 2. Press `Enter` on Active / Waiting / Stopped to view tasks in corresponding status 27 | 3. Press `Enter` on Download / Upload to go to speed limit settings 28 | 29 | ### Add a task 30 | 31 | Type `add` plus the url then press `Enter`, HTTP/FTP/SFTP/Magnet links are supported. 32 | 33 | It is recommended that you add a default download path in your `aria2.conf`, take it as an example: `dir=/foo/bar`, tasks added by Ariafred will be downloaded to this path. 34 | 35 | ### Add BT task via .torrent files 36 | 37 | ![bt](https://github.com/Wildog/Ariafred/raw/master/screenshots/bt.png) 38 | 39 | Execute [file action](https://www.alfredapp.com/help/features/file-search/#file-actions) 'Add BT download to Aria2' 40 | 41 | ### Reveal download 42 | 43 | 1. Press `Enter` on a task to reveal download in Finder 44 | 2. Press `Ctrl` + `Enter` on a task to reveal download in Alfred 45 | 3. Click on a notification to reveal related download in Finder 46 | 47 | ### Pause/Resume tasks 48 | 49 | 1. Press `Command` + `Enter` on a task 50 | 2. Or type `pause` / `resume` then press `Enter` on a task 51 | 3. Type `pauseall` / `resumeall` will pause/resume all task 52 | 53 | ### Remove tasks 54 | 55 | 1. Press `Option` + `Enter` on a task 56 | 2. Or type `rm` then press `Enter` on a task 57 | 58 | ### Copy URL to clipboard 59 | 60 | 1. Press `Control` + `Enter` on a task 61 | 2. Type `url` then press `Enter` on a task 62 | 63 | ### Clear all stopped tasks 64 | 65 | Type `clear` then press `Enter` 66 | 67 | ### Set speed limit and max concurrent downloads 68 | 69 | 1. Type `limit` plus speed(KiB/s) to set download speed limit 70 | 2. Type `limitup` plus speed(KiB/s) to set upload speed limit 71 | 3. Type `limitnum` plus a number to set max concurrent downloads 72 | 73 | ### Set RPC 74 | 75 | 1. The default RPC address Ariafred connects to is `http://localhost:6800/rpc`, change via typing `rpc` plus your own RPC address then press `Enter`. FYI, Ariafred uses xml-rpc instead of json-rpc that some WebUI uses for Aria2, so make sure your RPC address end with `/rpc` but not `/jsonrpc`. 76 | 2. The default rpc-secret Ariafred uses is empty, if you have configured your own rpc-secret in your `aria2.conf` you should set the secret by typing `secret` plus your own rpc-secret then press `Enter` 77 | 78 | ### Start/Quit Aria2 79 | 80 | ![filter](https://github.com/Wildog/Ariafred/raw/master/screenshots/run.png) 81 | 82 | 1. Ariafred will prompt you to start Aria2 or change RPC address if it fails to connect to Aria2, press `Enter` on starting aria2 to try launching Aria2 83 | 2. Type `quit` to quit Aria2 84 | 85 | ### Get help 86 | 87 | Type `help` to view this page anytime 88 | 89 | ### Update 90 | 91 | Ariafred will automatically check for update and prompt you to update 92 | 93 | ### Caveat 94 | 95 | Background notification will not work under Mac OS X 10.8 or older system 96 | 97 | ## License 98 | 99 | MIT 100 | 101 | ## Credit 102 | 103 | @miniers 104 | @fatestigma 105 | @chyiz 106 | @ecbrodie 107 | -------------------------------------------------------------------------------- /screenshots/ariafred.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/screenshots/ariafred.gif -------------------------------------------------------------------------------- /screenshots/bt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/screenshots/bt.png -------------------------------------------------------------------------------- /screenshots/filter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/screenshots/filter.png -------------------------------------------------------------------------------- /screenshots/run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/screenshots/run.png -------------------------------------------------------------------------------- /screenshots/stat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/screenshots/stat.png -------------------------------------------------------------------------------- /src/Ariafred.app/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildMachineOSBuild 6 | 15E65 7 | CFBundleDevelopmentRegion 8 | en 9 | CFBundleExecutable 10 | Ariafred 11 | CFBundleIconFile 12 | Ariafred 13 | CFBundleIdentifier 14 | dog.wil.ariafrednotification 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | Ariafred 19 | CFBundlePackageType 20 | APPL 21 | CFBundleShortVersionString 22 | 1.6.3 23 | CFBundleSignature 24 | ???? 25 | CFBundleSupportedPlatforms 26 | 27 | MacOSX 28 | 29 | CFBundleVersion 30 | 14 31 | DTCompiler 32 | com.apple.compilers.llvm.clang.1_0 33 | DTPlatformBuild 34 | 7D175 35 | DTPlatformVersion 36 | GM 37 | DTSDKBuild 38 | 15E60 39 | DTSDKName 40 | macosx10.11 41 | DTXcode 42 | 0730 43 | DTXcodeBuild 44 | 7D175 45 | LSMinimumSystemVersion 46 | 10.8 47 | LSUIElement 48 | 49 | NSHumanReadableCopyright 50 | Copyright © 2012-2015 Eloy Durán, Julien Blanchard. All rights reserved. 51 | NSMainNibFile 52 | MainMenu 53 | NSPrincipalClass 54 | NSApplication 55 | 56 | 57 | -------------------------------------------------------------------------------- /src/Ariafred.app/Contents/MacOS/Ariafred: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/Ariafred.app/Contents/MacOS/Ariafred -------------------------------------------------------------------------------- /src/Ariafred.app/Contents/PkgInfo: -------------------------------------------------------------------------------- 1 | APPL???? -------------------------------------------------------------------------------- /src/Ariafred.app/Contents/Resources/Ariafred.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/Ariafred.app/Contents/Resources/Ariafred.icns -------------------------------------------------------------------------------- /src/Ariafred.app/Contents/Resources/en.lproj/Credits.rtf: -------------------------------------------------------------------------------- 1 | {\rtf0\ansi{\fonttbl\f0\fswiss Helvetica;} 2 | {\colortbl;\red255\green255\blue255;} 3 | \paperw9840\paperh8400 4 | \pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\ql\qnatural 5 | 6 | \f0\b\fs24 \cf0 Engineering: 7 | \b0 \ 8 | Some people\ 9 | \ 10 | 11 | \b Human Interface Design: 12 | \b0 \ 13 | Some other people\ 14 | \ 15 | 16 | \b Testing: 17 | \b0 \ 18 | Hopefully not nobody\ 19 | \ 20 | 21 | \b Documentation: 22 | \b0 \ 23 | Whoever\ 24 | \ 25 | 26 | \b With special thanks to: 27 | \b0 \ 28 | Mom\ 29 | } 30 | -------------------------------------------------------------------------------- /src/Ariafred.app/Contents/Resources/en.lproj/InfoPlist.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/Ariafred.app/Contents/Resources/en.lproj/InfoPlist.strings -------------------------------------------------------------------------------- /src/Ariafred.app/Contents/Resources/en.lproj/MainMenu.nib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/Ariafred.app/Contents/Resources/en.lproj/MainMenu.nib -------------------------------------------------------------------------------- /src/LICENSE: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016 Wildog 3 | * Author: Wildog 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | * this software and associated documentation files (the "Software"), to deal in 7 | * the Software without restriction, including without limitation the rights to 8 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | * the Software, and to permit persons to whom the Software is furnished to do so, 10 | * 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, FITNESS 17 | * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | */ 22 | -------------------------------------------------------------------------------- /src/active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/active.png -------------------------------------------------------------------------------- /src/aria.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import socket 4 | import sys 5 | import xmlrpclib 6 | from aria_actions import speed_convert 7 | from workflow import Workflow3 8 | from workflow.background import run_in_background, is_running 9 | 10 | 11 | def get_rpc(): 12 | global server 13 | rpc_path = wf.settings['rpc_path'] 14 | server = xmlrpclib.ServerProxy(rpc_path).aria2 15 | try: 16 | version = server.getVersion(secret) 17 | except (xmlrpclib.Fault, socket.error): 18 | current_secret = secret.split(':')[1] 19 | wf.add_item(u'Aria2 may not be running, try starting it?', u'Press Enter', 20 | arg=u'--run-aria2', valid=True) 21 | wf.add_item(u'Or change RPC path?', u'Currently using ' + rpc_path, 22 | arg=u'--go-rpc-setting', valid=True) 23 | wf.add_item(u'Or change RPC secret?', u'Currently using \'' + current_secret + '\'', 24 | arg=u'--go-secret-setting', valid=True) 25 | return False 26 | else: 27 | return True 28 | 29 | 30 | def size_fmt(num, suffix='B'): 31 | for unit in ['','Ki','Mi','Gi','Ti']: 32 | if abs(num) < 1024.0: 33 | return '{num:.2f} {unit}{suffix}'.format(num=num, unit=unit, suffix=suffix) 34 | num /= 1024.0 35 | return '{num:.1f} {unit}{suffix}'.format(num=num, unit='Pi', suffix=suffix) 36 | 37 | 38 | def time_fmt(seconds): 39 | m, s = divmod(seconds, 60) 40 | h, m = divmod(m, 60) 41 | d, h = divmod(h, 24) 42 | w, d = divmod(d, 7) 43 | y, w = divmod(w, 52) 44 | units = ['y', 'w', 'd', 'h', 'm', 's'] 45 | parts = [str(eval(unit)) + str(unit) for unit in units if eval(unit) > 0] 46 | time = ''.join(parts) 47 | return time 48 | 49 | 50 | def apply_filter(tasks, filters): 51 | if filters: 52 | for filter in filters: 53 | tasks = wf.filter(filter, tasks, 54 | lambda task: get_task_name(task), min_score=20) 55 | return tasks 56 | 57 | 58 | def kill_notifier(): 59 | if os.path.isfile(wf.cachefile('notifier.pid')): 60 | with open(wf.cachefile('notifier.pid'), 'r') as pid_file: 61 | pid = pid_file.readline() 62 | os_command = 'pkill -TERM -P ' + pid 63 | os.system(os_command) 64 | 65 | 66 | def get_task_name(task): 67 | gid = task['gid'] 68 | bt = server.tellStatus(secret, gid, ['bittorrent']) 69 | path = server.getFiles(secret, gid)[0]['path'] 70 | if bt: 71 | file_num = len(server.getFiles(secret, gid)) 72 | if 'info' in bt: 73 | bt_name = bt['bittorrent']['info']['name'] 74 | else: 75 | bt_name = os.path.basename(os.path.dirname(path)) 76 | if not bt_name: 77 | bt_name = 'Task name not obtained yet' 78 | name = u'{bt_name} (BT: {file_num} files)'.format(bt_name=bt_name, file_num=file_num) 79 | else: 80 | name = os.path.basename(path) 81 | if not name: 82 | name = 'Task name not obtained yet' 83 | return name 84 | 85 | 86 | def no_result_notify(status, filters): 87 | info = 'No ' + status + ' download' 88 | if filters: 89 | info += ' with ' 90 | for filter in filters: 91 | info += '\'' + filter + '\' ' 92 | wf.add_item(info) 93 | 94 | def add_modifier_subs(item, active=False, done=False, info=''): 95 | subs = {'cmd': 'Resume download', 96 | 'shift': 'Copy URL', 97 | 'alt': 'Remove download'} 98 | if active: 99 | subs['cmd'] = 'Pause download' 100 | if done: 101 | subs['cmd'] = info 102 | item.add_modifier('cmd',subs['cmd']) 103 | item.add_modifier('shift',subs['shift']) 104 | item.add_modifier('alt',subs['alt']) 105 | 106 | 107 | 108 | def get_tasks(command, status, filters): 109 | if status == 'active': 110 | if not get_active_tasks(command, filters): 111 | no_result_notify(status, filters) 112 | elif status == 'pending': 113 | if not get_pending_tasks(command, filters): 114 | no_result_notify(status, filters) 115 | elif status == 'paused': 116 | if not get_paused_tasks(command, filters): 117 | no_result_notify(status, filters) 118 | elif status == 'done': 119 | if not get_completed_tasks(command, filters): 120 | no_result_notify(status, filters) 121 | elif status == 'error': 122 | if not get_error_tasks(command, filters): 123 | no_result_notify(status, filters) 124 | elif status == 'removed': 125 | if not get_removed_tasks(command, filters): 126 | no_result_notify(status, filters) 127 | elif status == 'waiting': 128 | a = get_pending_tasks(command, filters) 129 | b = get_paused_tasks(command, filters) 130 | if not (a or b): 131 | no_result_notify(status, filters) 132 | elif status == 'incomplete': 133 | a = get_paused_tasks(command, filters) 134 | b = get_error_tasks(command, filters) 135 | c = get_removed_tasks(command, filters) 136 | if not (a or b or c): 137 | no_result_notify(status, filters) 138 | elif status == 'stopped': 139 | a = get_completed_tasks(command, filters) 140 | b = get_error_tasks(command, filters) 141 | c = get_removed_tasks(command, filters) 142 | if not (a or b or c): 143 | no_result_notify(status, filters) 144 | elif status == 'all': 145 | a = get_active_tasks(command, filters) 146 | b = get_pending_tasks(command, filters) 147 | c = get_paused_tasks(command, filters) 148 | d = get_completed_tasks(command, filters) 149 | e = get_error_tasks(command, filters) 150 | f = get_removed_tasks(command, filters) 151 | if not (a or b or c or d or e or f): 152 | no_result_notify('Aria2', filters) 153 | 154 | 155 | def get_active_tasks(command, filters): 156 | active = server.tellActive(secret, ['gid', 'completedLength', 'totalLength', 157 | 'downloadSpeed', 'uploadSpeed', 'connections']) 158 | active = apply_filter(active, filters) 159 | if not active: 160 | return False 161 | for task in active: 162 | name = get_task_name(task) 163 | speed = int(task['downloadSpeed']) 164 | completed = int(task['completedLength']) 165 | total = int(task['totalLength']) 166 | if total == 0: 167 | percentage = 0 168 | else: 169 | percentage = float(completed) / float(total) * 100 170 | if speed > 0: 171 | seconds = (total - completed) / speed 172 | remaining = time_fmt(seconds) 173 | else: 174 | remaining = u'∞' 175 | info = u'{percentage:.2f}%, {completed} / {total}, {speed}, {remaining} left'.format( 176 | percentage=percentage, 177 | completed=size_fmt(completed), 178 | total=size_fmt(total), 179 | speed=size_fmt(speed, suffix='B/s'), 180 | remaining=remaining 181 | ) 182 | arg = '--' + command + ' ' + task['gid'] 183 | item = wf.add_item(name, info, arg=arg, valid=True, icon=icon_active) 184 | add_modifier_subs(item=item,active=True) 185 | return True 186 | 187 | 188 | def get_pending_tasks(command, filters): 189 | waiting = server.tellWaiting(secret, -1, 10, ['gid', 'status', 'completedLength', 190 | 'totalLength']) 191 | waiting = [task for task in waiting if task['status'] == 'waiting'] 192 | waiting = apply_filter(waiting, filters) 193 | if not waiting: 194 | return False 195 | for task in waiting: 196 | name = get_task_name(task) 197 | completed = int(task['completedLength']) 198 | total = int(task['totalLength']) 199 | if total == 0: 200 | percentage = 0 201 | else: 202 | percentage = float(completed) / float(total) * 100 203 | info = '{percentage:.2f}%, {completed} / {total}'.format( 204 | percentage=percentage, 205 | completed=size_fmt(completed), 206 | total=size_fmt(total)) 207 | arg = '--' + command + ' ' + task['gid'] 208 | item = wf.add_item(name, info, arg=arg, valid=True, icon=icon_waiting) 209 | add_modifier_subs(item=item,active=True) 210 | return True 211 | 212 | 213 | def get_paused_tasks(command, filters): 214 | waiting = server.tellWaiting(secret, -1, 10, ['gid', 'status', 'completedLength', 215 | 'totalLength']) 216 | paused = [task for task in waiting if task['status'] == 'paused'] 217 | paused = apply_filter(paused, filters) 218 | if not paused: 219 | return False 220 | for task in paused: 221 | name = get_task_name(task) 222 | completed = int(task['completedLength']) 223 | total = int(task['totalLength']) 224 | if total == 0: 225 | percentage = 0 226 | else: 227 | percentage = float(completed) / float(total) * 100 228 | info = '{percentage:.2f}%, {completed} / {total}'.format( 229 | percentage=percentage, 230 | completed=size_fmt(completed), 231 | total=size_fmt(total)) 232 | arg = '--' + command + ' ' + task['gid'] 233 | item = wf.add_item(name, info, arg=arg, valid=True, icon=icon_paused) 234 | add_modifier_subs(item=item) 235 | return True 236 | 237 | 238 | def get_stopped_tasks(): 239 | stopped = server.tellStopped(secret, -1, 20, ['gid', 'status', 'completedLength', 240 | 'totalLength', 'errorMessage']) 241 | return stopped 242 | 243 | 244 | def get_completed_tasks(command, filters): 245 | stopped = get_stopped_tasks() 246 | completed = [task for task in stopped if task['status'] == 'complete'] 247 | completed = apply_filter(completed, filters) 248 | if not completed: 249 | return False 250 | for task in completed: 251 | name = get_task_name(task) 252 | size = int(task['completedLength']) 253 | info = '100%, File Size: {size}'.format(size=size_fmt(size)) 254 | arg = '--' + command + ' ' + task['gid'] 255 | filepath = server.getFiles(secret, task['gid'])[0]['path'].encode('utf-8') 256 | if not os.path.exists(filepath): 257 | info = '[deleted] ' + info 258 | item = wf.add_item(name, info, arg=arg, valid=True, icon=icon_deleted) 259 | else: 260 | item = wf.add_item(name, info, arg=arg, valid=True, icon=icon_complete) 261 | add_modifier_subs(item=item,done=True, info=info) 262 | return True 263 | 264 | 265 | def get_error_tasks(command, filters): 266 | stopped = get_stopped_tasks() 267 | error = [task for task in stopped if task['status'] == 'error'] 268 | error = apply_filter(error, filters) 269 | if not error: 270 | return False 271 | for task in error: 272 | name = get_task_name(task) 273 | arg = '--' + command + ' ' + task['gid'] 274 | completed = int(task['completedLength']) 275 | total = int(task['totalLength']) 276 | if total == 0: 277 | percentage = 0 278 | else: 279 | percentage = float(completed) / float(total) * 100 280 | info = u'{percentage:.2f}%, {completed} / {total}, {msg}'.format( 281 | percentage=percentage, 282 | completed=size_fmt(completed), 283 | total=size_fmt(total), 284 | msg=task.get('errorMessage', u'Unknown Error.')) 285 | item = wf.add_item(name, info, arg=arg, valid=True, icon=icon_error) 286 | add_modifier_subs(item=item,done=True, info=info) 287 | return True 288 | 289 | 290 | def get_removed_tasks(command, filters): 291 | stopped = get_stopped_tasks() 292 | removed = [task for task in stopped if task['status'] == 'removed'] 293 | removed = apply_filter(removed, filters) 294 | if not removed: 295 | return False 296 | for task in removed: 297 | name = get_task_name(task) 298 | arg = '--' + command + ' ' + task['gid'] 299 | info = 'Removed' 300 | item = wf.add_item(name, u'This task is removed by user.', arg=arg, valid=True, icon=icon_removed) 301 | add_modifier_subs(item=item,done=True, info=info) 302 | return True 303 | 304 | 305 | def get_stats(): 306 | if get_rpc(): 307 | stats = server.getGlobalStat(secret) 308 | options = server.getGlobalOption(secret) 309 | wf.add_item('Active: ' + stats['numActive'], 310 | arg='--go-active', valid=True, icon=icon_active) 311 | wf.add_item('Waiting: ' + stats['numWaiting'], 312 | arg='--go-waiting', valid=True, icon=icon_waiting) 313 | wf.add_item('Stopped: ' + stats['numStopped'], 314 | arg='--go-stopped', valid=True, icon=icon_stopped) 315 | wf.add_item('Download: ' + size_fmt(int(stats['downloadSpeed']), suffix='B/s'), 316 | 'Current download limit: {limit} KiB/s'.format( 317 | limit=options['max-overall-download-limit']), 318 | arg='--go-download-limit-setting', valid=True, icon=icon_download) 319 | wf.add_item('Upload: ' + size_fmt(int(stats['uploadSpeed']), suffix='B/s'), 320 | 'Current upload limit: {limit} KiB/s'.format( 321 | limit=options['max-overall-upload-limit']), 322 | arg='--go-upload-limit-setting', valid=True, icon=icon_upload) 323 | 324 | 325 | def limit_speed(type, param): 326 | if get_rpc(): 327 | limit = int(server.getGlobalOption(secret)['max-overall-' + type +'-limit']) 328 | limit = speed_convert(limit)[1] 329 | param_s = speed_convert(param)[1] 330 | wf.add_item('Limit ' + type +' speed to: {limit}'.format(limit=param_s), 331 | 'Current ' + type + ' limit (0 for no limit): ' + limit, 332 | arg='--limit-' + type + ' ' + param, valid=True) 333 | 334 | 335 | def limit_num(param): 336 | if get_rpc(): 337 | limit = server.getGlobalOption(secret)['max-concurrent-downloads'] 338 | wf.add_item('Limit concurrent downloads to: {limit}'.format(limit=param), 339 | 'Current concurrent downloads limit: ' + limit, 340 | arg='--limit-num ' + param, valid=True) 341 | 342 | 343 | def main(wf): 344 | if wf.first_run: 345 | kill_notifier() 346 | 347 | statuses = ['all', 'active', 'pending', 'paused', 'waiting', 348 | 'done', 'error', 'removed', 'stopped'] 349 | actions = ['reveal', 'rm', 'url', 'pause', 'resume'] 350 | settings = ['rpc', 'secret', 'limit', 'limitup', 'limitnum', 'clear', 'add', 'quit', 351 | 'stat', 'help', 'pauseall', 'resumeall'] 352 | commands = actions + settings 353 | 354 | command = 'reveal' 355 | status = 'all' 356 | param = '' 357 | 358 | if len(wf.args) == 1: 359 | if wf.args[0] in commands: 360 | command = wf.args[0] 361 | elif wf.args[0] in statuses: 362 | status = wf.args[0] 363 | else: 364 | param = wf.args[0:] 365 | elif len(wf.args) > 1: 366 | if wf.args[0] in settings: 367 | command = wf.args[0] 368 | param = wf.args[1] #settings take one param only 369 | elif wf.args[0] in actions: 370 | command = wf.args[0] 371 | param = wf.args[1:] #actions can take multiple param to filter the result 372 | elif wf.args[0] in statuses: 373 | status = wf.args[0] 374 | param = wf.args[1:] #statuses can take multiple param to filter the result 375 | else: 376 | param = wf.args[0:] 377 | 378 | if command not in settings: 379 | if command == 'pause': 380 | status = 'active' 381 | elif command == 'resume': 382 | status = 'incomplete' 383 | if get_rpc(): 384 | get_tasks(command, status, param) 385 | else: 386 | if command == 'rpc': 387 | wf.add_item('Set Aria2\'s RPC Path', 'Set the path to ' + param, 388 | arg=u'--rpc-setting ' + param, valid=True) 389 | elif command == 'secret': 390 | wf.add_item('Set Aria2\'s RPC Secret', 'Set the secret to ' + param, 391 | arg=u'--secret-setting ' + param, valid=True) 392 | elif command == 'add': 393 | wf.add_item('Add new download: ' + param, arg='--add ' + param, valid=True) 394 | elif command == 'clear': 395 | wf.add_item('Clear all stopped download?', arg='--clear', valid=True) 396 | elif command == 'pauseall': 397 | wf.add_item('Pause all active downloads?', arg='--pauseall', valid=True) 398 | elif command == 'resumeall': 399 | wf.add_item('Resume all paused downloads?', arg='--resumeall', valid=True) 400 | elif command == 'help': 401 | wf.add_item('Need some help?', arg='--help', valid=True) 402 | elif command == 'quit': 403 | wf.add_item('Quit Aria2?', arg='--quit', valid=True) 404 | elif command == 'limit': 405 | limit_speed('download', param) 406 | elif command == 'limitup': 407 | limit_speed('upload', param) 408 | elif command == 'limitnum': 409 | limit_num(param) 410 | elif command == 'stat': 411 | get_stats() 412 | 413 | if wf.update_available: 414 | wf.add_item('New version available', 415 | 'Action this item to install the update', 416 | autocomplete='workflow:update') 417 | 418 | wf.send_feedback() 419 | 420 | if not is_running('notifier'): 421 | cmd = ['/usr/bin/python', wf.workflowfile('notifier.py')] 422 | run_in_background('notifier', cmd) 423 | 424 | 425 | if __name__ == '__main__': 426 | 427 | icon_active = 'active.png' 428 | icon_paused = 'paused.png' 429 | icon_waiting = 'waiting.png' 430 | icon_complete = 'complete.png' 431 | icon_deleted = 'deleted.png' 432 | icon_removed = 'removed.png' 433 | icon_error = 'error.png' 434 | icon_download = 'download.png' 435 | icon_upload = 'upload.png' 436 | icon_stopped = 'stopped.png' 437 | 438 | defaults = { 439 | 'rpc_path': 'http://localhost:6800/rpc', 440 | 'secret': '' 441 | } 442 | update_settings = { 443 | 'github_slug': 'Wildog/Ariafred', 444 | 'frequency': 1 445 | } 446 | 447 | wf = Workflow3(default_settings=defaults, update_settings=update_settings) 448 | 449 | server = None 450 | 451 | if 'secret' not in wf.settings: 452 | wf.settings['secret'] = '' 453 | secret = 'token:' + wf.settings['secret'] 454 | wf.rerun=1 455 | sys.exit(wf.run(main)) 456 | -------------------------------------------------------------------------------- /src/aria_actions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import print_function 3 | import os 4 | import sys 5 | import xmlrpclib 6 | from workflow import Workflow3 7 | 8 | 9 | def escape(s, char=' '): 10 | return s.replace(char, '\\' + char) 11 | 12 | 13 | def notify(msg, title='Ariafred', gid=''): 14 | notifier = os.path.join(wf.workflowdir, 'Ariafred.app/Contents/MacOS/Ariafred') 15 | notifier = escape(notifier) 16 | msg = escape(msg, char='[') 17 | os_command = '%s -title "%s" -message "%s"' % (notifier.encode('utf-8'), 18 | title.encode('utf-8'), 19 | msg.encode('utf-8')) 20 | if gid: 21 | dir = server.tellStatus(secret, gid, ['dir'])['dir'] 22 | filepath = server.getFiles(secret, gid)[0]['path'].encode('utf-8') 23 | if os.path.exists(filepath): 24 | click_command = 'open -R "%s"' % filepath 25 | else: 26 | click_command = 'open "%s" ' % dir 27 | os_command = '%s -execute \'%s\'' % (os_command, click_command) 28 | os.system(os_command) 29 | 30 | 31 | def set_query(query): 32 | alfred_2_cmd = 'if application "Alfred 2" is running then tell application "Alfred 2" to search "%s"' % query 33 | alfred_3_cmd = 'if application "Alfred 3" is running then tell application "Alfred 3" to search "%s"' % query 34 | os_command = "osascript -e '%s' & osascript -e '%s'" % (alfred_2_cmd, alfred_3_cmd) 35 | os.system(os_command) 36 | 37 | 38 | def run_aria(): 39 | os_command = 'export PATH=$PATH:/usr/local/bin:/usr/local/aria2/bin && aria2c --enable-rpc --rpc-listen-all=true --rpc-allow-origin-all -c -D' 40 | if os.system(os_command) == 0: 41 | notify('Aria2 has started successfully') 42 | else: 43 | notify('Failed to start Aria2, please run manually') 44 | 45 | 46 | def get_task_name(gid): 47 | bt = server.tellStatus(secret, gid, ['bittorrent']) 48 | path = server.getFiles(secret, gid)[0]['path'] 49 | if bt: 50 | file_num = len(server.getFiles(secret, gid)) 51 | if 'info' in bt: 52 | bt_name = bt['bittorrent']['info']['name'] 53 | else: 54 | bt_name = os.path.basename(os.path.dirname(path)) 55 | if not bt_name: 56 | bt_name = 'Task name not obtained yet' 57 | name = u'{bt_name} (BT: {file_num} files)'.format(bt_name=bt_name, file_num=file_num) 58 | else: 59 | name = os.path.basename(path) 60 | if not name: 61 | name = 'Task name not obtained yet' 62 | return name 63 | 64 | 65 | def reveal(gid, alfred=False): 66 | dir = server.tellStatus(secret, gid, ['dir'])['dir'] 67 | filepath = server.getFiles(secret, gid)[0]['path'].encode('utf-8') 68 | if os.path.exists(filepath): 69 | if alfred: 70 | alfred_2_cmd = 'if application "Alfred 2" is running then tell application "Alfred 2" to search "%s"' % filepath 71 | alfred_3_cmd = 'if application "Alfred 3" is running then tell application "Alfred 3" to search "%s"' % filepath 72 | os_command = "osascript -e '%s' & osascript -e '%s'" % (alfred_2_cmd, alfred_3_cmd) 73 | else: 74 | os_command = 'open -R "%s"' % filepath 75 | else: 76 | os_command = 'open "%s" ' % dir 77 | os.system(os_command) 78 | 79 | 80 | def pause_all(): 81 | server.pauseAll(secret) 82 | notify('All active downloads paused') 83 | 84 | 85 | def resume_all(): 86 | server.unpauseAll(secret) 87 | notify('All paused downloads resumed') 88 | 89 | 90 | def switch_task(gid): 91 | name = get_task_name(gid) 92 | status = server.tellStatus(secret, gid, ['status'])['status'] 93 | if status in ['active', 'waiting']: 94 | server.pause(secret, gid) 95 | notify(title='Download paused:', msg=name, gid=gid) 96 | elif status == 'paused': 97 | server.unpause(secret, gid) 98 | notify(title='Download resumed:', msg=name, gid=gid) 99 | elif status == 'complete': 100 | pass 101 | else: 102 | urls = server.getFiles(secret, gid)[0]['uris'] 103 | if urls: 104 | url = urls[0]['uri'] 105 | server.addUri(secret, [url]) 106 | server.removeDownloadResult(secret, gid) 107 | notify(title='Download resumed:', msg=name, gid=gid) 108 | else: 109 | notify(title='Cannot resume download:', msg=name, gid=gid) 110 | 111 | 112 | def get_url(gid): 113 | urls = server.getFiles(secret, gid)[0]['uris'] 114 | if urls: 115 | url = urls[0]['uri'] 116 | notify(title='URL has been copied to clipboard:', msg=url) 117 | print(url, end='') 118 | else: 119 | notify('No URL found') 120 | 121 | 122 | def add_task(url): 123 | gid = server.addUri(secret, [url]) 124 | notify(title='Download added:', msg=url, gid=gid) 125 | 126 | 127 | def add_bt_task(filepath): 128 | gid = server.addTorrent(secret, xmlrpclib.Binary(open(filepath, mode='rb').read())) 129 | notify(title='BT download added:', msg=os.path.basename(filepath), gid=gid) 130 | 131 | 132 | def remove_task(gid): 133 | name = get_task_name(gid) 134 | status = server.tellStatus(secret, gid, ['status'])['status'] 135 | if status in ['active', 'waiting', 'paused']: 136 | server.remove(secret, gid) 137 | notify(title='Download removed:', msg=name, gid=gid) 138 | server.removeDownloadResult(secret, gid) 139 | 140 | 141 | def clear_stopped(): 142 | server.purgeDownloadResult(secret) 143 | notify('All stopped downloads cleared') 144 | 145 | 146 | def quit_aria(): 147 | server.shutdown(secret) 148 | notify('Aria2 shutting down') 149 | kill_notifier() 150 | 151 | def speed_convert(s): 152 | try: 153 | speed = int(s) 154 | m = speed / (1024 * 1024) 155 | k = speed / 1024 156 | if m != 0: 157 | string = '%d MiB/s' % m 158 | elif k != 0: 159 | string = '%d KiB/s' % k 160 | else: 161 | string = '%d Byte/s' % speed 162 | return (s, string) 163 | except: 164 | import re 165 | m = re.match(r'\s*(\d+)\s*(\w+)\s*', s) 166 | if m: 167 | number = m.group(1) 168 | unit = m.group(2)[0] 169 | if unit == 'K' or unit == 'k': 170 | exp = 1 171 | unit = 'KiB/s' 172 | elif unit == 'M' or unit == 'm': 173 | exp = 2 174 | unit = 'MiB/s' 175 | elif unit == 'G' or unit == 'g': 176 | exp = 3 177 | unit = 'GiB/s' 178 | else: 179 | exp = 0 180 | unit = 'Byte/s' 181 | string = '%s %s' % (number, unit) 182 | speed = int(number) * (1024 ** exp) 183 | return (str(speed), string) 184 | else: 185 | return ('0', '0 Byte') 186 | 187 | def limit_speed(type, speed): 188 | option = 'max-overall-' + type + '-limit' 189 | speed_value,speed_string = speed_convert(speed) 190 | server.changeGlobalOption(secret, {option: speed_value}) 191 | notify('Limit ' + type + ' speed to: ' + speed_string) 192 | 193 | def limit_num(num): 194 | server.changeGlobalOption(secret, {'max-concurrent-downloads': num}) 195 | notify('Limit concurrent downloads to: ' + num) 196 | 197 | 198 | def kill_notifier(): 199 | with open(wf.cachefile('notifier.pid'), 'r') as pid_file: 200 | pid = pid_file.readline() 201 | os_command = 'pkill -TERM -P ' + pid 202 | os.system(os_command) 203 | 204 | def set_rpc(path): 205 | wf.settings['rpc_path'] = path 206 | notify('Set RPC path to: ' + path) 207 | kill_notifier() 208 | 209 | def set_secret(str): 210 | wf.settings['secret'] = str 211 | notify('Set RPC secret to: ' + str) 212 | kill_notifier() 213 | 214 | def get_help(): 215 | os_command = 'open https://github.com/Wildog/Ariafred' 216 | os.system(os_command) 217 | 218 | 219 | def main(wf): 220 | command = wf.args[0] 221 | 222 | if command == '--reveal': 223 | reveal(wf.args[1]) 224 | elif command == '--alfred': 225 | reveal(wf.args[1], True) 226 | elif command == '--rm': 227 | remove_task(wf.args[1]) 228 | elif command == '--add': 229 | add_task(wf.args[1]) 230 | elif command == '--bt': 231 | add_bt_task(wf.args[1]) 232 | elif (command == '--pause' 233 | or command == '--resume' 234 | or command == '--switch'): 235 | switch_task(wf.args[1]) 236 | elif command == '--pauseall': 237 | pause_all() 238 | elif command == '--resumeall': 239 | resume_all() 240 | elif command == '--clear': 241 | clear_stopped() 242 | elif command == '--url': 243 | get_url(wf.args[1]) 244 | elif command == '--rpc-setting': 245 | set_rpc(wf.args[1]) 246 | elif command == '--secret-setting': 247 | set_secret(wf.args[1]) 248 | elif command == '--run-aria2': 249 | run_aria() 250 | elif command == '--quit': 251 | quit_aria() 252 | elif command == '--help': 253 | get_help() 254 | elif command == '--limit-download': 255 | limit_speed('download', wf.args[1]) 256 | elif command == '--limit-upload': 257 | limit_speed('upload', wf.args[1]) 258 | elif command == '--limit-num': 259 | limit_num(wf.args[1]) 260 | elif command == '--go-rpc-setting': 261 | set_query('aria rpc ') 262 | elif command == '--go-secret-setting': 263 | set_query('aria secret ') 264 | elif command == '--go-active': 265 | set_query('aria active ') 266 | elif command == '--go-stopped': 267 | set_query('aria stopped ') 268 | elif command == '--go-waiting': 269 | set_query('aria waiting ') 270 | elif command == '--go-download-limit-setting': 271 | set_query('aria limit ') 272 | elif command == '--go-upload-limit-setting': 273 | set_query('aria limitup ') 274 | 275 | 276 | if __name__ == '__main__': 277 | 278 | wf = Workflow3() 279 | rpc_path = wf.settings['rpc_path'] 280 | server = xmlrpclib.ServerProxy(rpc_path).aria2 281 | secret = 'token:' + wf.settings['secret'] 282 | sys.exit(wf.run(main)) 283 | -------------------------------------------------------------------------------- /src/aria_actions.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/aria_actions.pyc -------------------------------------------------------------------------------- /src/complete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/complete.png -------------------------------------------------------------------------------- /src/deleted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/deleted.png -------------------------------------------------------------------------------- /src/download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/download.png -------------------------------------------------------------------------------- /src/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/error.png -------------------------------------------------------------------------------- /src/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/icon.png -------------------------------------------------------------------------------- /src/info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | dog.wil.ariafred 7 | connections 8 | 9 | 5BBBEF8D-B80D-471F-961F-886F344F1B9D 10 | 11 | 12 | destinationuid 13 | 85E562C1-0EB6-4234-B34E-BE1843D9E441 14 | modifiers 15 | 0 16 | modifiersubtext 17 | 18 | 19 | 20 | 82126C11-9FA3-4DFC-8917-FCD788552F38 21 | 22 | 23 | destinationuid 24 | 032DABC0-4F0C-4875-A556-DF07D92D1563 25 | modifiers 26 | 0 27 | modifiersubtext 28 | 29 | 30 | 31 | 85E562C1-0EB6-4234-B34E-BE1843D9E441 32 | 33 | 34 | destinationuid 35 | 8F555CDC-0040-4F77-9ACF-DEDA171D11D7 36 | modifiers 37 | 0 38 | modifiersubtext 39 | 40 | 41 | 42 | destinationuid 43 | 60B41887-2E94-4CAF-8BFD-E3108279053A 44 | modifiers 45 | 1048576 46 | modifiersubtext 47 | 48 | 49 | 50 | destinationuid 51 | 90400F13-F9A6-46D0-95D6-F45BD7F8C733 52 | modifiers 53 | 524288 54 | modifiersubtext 55 | 56 | 57 | 58 | destinationuid 59 | 82126C11-9FA3-4DFC-8917-FCD788552F38 60 | modifiers 61 | 131072 62 | modifiersubtext 63 | 64 | 65 | 66 | destinationuid 67 | F918CA67-023E-430D-9903-03DDE35F0E4E 68 | modifiers 69 | 262144 70 | modifiersubtext 71 | Show in Alfred 72 | 73 | 74 | 8F555CDC-0040-4F77-9ACF-DEDA171D11D7 75 | 76 | 77 | destinationuid 78 | 032DABC0-4F0C-4875-A556-DF07D92D1563 79 | modifiers 80 | 0 81 | modifiersubtext 82 | 83 | 84 | 85 | 90400F13-F9A6-46D0-95D6-F45BD7F8C733 86 | 87 | F49892BB-5FD7-431B-98A6-C84DA5923E02 88 | 89 | 90 | destinationuid 91 | 063A4D9F-7D6A-43B5-9320-C8FC1FE80612 92 | modifiers 93 | 0 94 | modifiersubtext 95 | 96 | 97 | 98 | 99 | createdby 100 | Wildog 101 | description 102 | Manage Aria2 downloads with Alfred 103 | disabled 104 | 105 | name 106 | Ariafred 107 | objects 108 | 109 | 110 | config 111 | 112 | argumenttype 113 | 1 114 | escaping 115 | 102 116 | keyword 117 | aria 118 | queuedelaycustom 119 | 3 120 | queuedelayimmediatelyinitially 121 | 122 | queuedelaymode 123 | 1 124 | queuemode 125 | 1 126 | runningsubtext 127 | Connecting Aria2... 128 | script 129 | /usr/bin/python aria.py {query} 130 | title 131 | Manage Aria2 downloads 132 | type 133 | 0 134 | withspace 135 | 136 | 137 | type 138 | alfred.workflow.input.scriptfilter 139 | uid 140 | 85E562C1-0EB6-4234-B34E-BE1843D9E441 141 | version 142 | 0 143 | 144 | 145 | config 146 | 147 | action 148 | 0 149 | argument 150 | 0 151 | hotkey 152 | 0 153 | hotmod 154 | 1179648 155 | hotstring 156 | A 157 | leftcursor 158 | 159 | modsmode 160 | 0 161 | relatedAppsMode 162 | 0 163 | 164 | type 165 | alfred.workflow.trigger.hotkey 166 | uid 167 | 5BBBEF8D-B80D-471F-961F-886F344F1B9D 168 | version 169 | 1 170 | 171 | 172 | config 173 | 174 | concurrently 175 | 176 | escaping 177 | 102 178 | script 179 | /usr/bin/python aria_actions.py {query} 180 | type 181 | 0 182 | 183 | type 184 | alfred.workflow.action.script 185 | uid 186 | 8F555CDC-0040-4F77-9ACF-DEDA171D11D7 187 | version 188 | 0 189 | 190 | 191 | config 192 | 193 | concurrently 194 | 195 | escaping 196 | 102 197 | script 198 | query="{query}" 199 | gid=${query#--*[ ]} 200 | command="--switch " 201 | /usr/bin/python aria_actions.py $command$gid 202 | type 203 | 0 204 | 205 | type 206 | alfred.workflow.action.script 207 | uid 208 | 60B41887-2E94-4CAF-8BFD-E3108279053A 209 | version 210 | 0 211 | 212 | 213 | config 214 | 215 | autopaste 216 | 217 | clipboardtext 218 | {query} 219 | 220 | type 221 | alfred.workflow.output.clipboard 222 | uid 223 | 032DABC0-4F0C-4875-A556-DF07D92D1563 224 | version 225 | 0 226 | 227 | 228 | config 229 | 230 | concurrently 231 | 232 | escaping 233 | 102 234 | script 235 | query="{query}" 236 | gid=${query#--*[ ]} 237 | command="--rm " 238 | /usr/bin/python aria_actions.py $command$gid 239 | type 240 | 0 241 | 242 | type 243 | alfred.workflow.action.script 244 | uid 245 | 90400F13-F9A6-46D0-95D6-F45BD7F8C733 246 | version 247 | 0 248 | 249 | 250 | config 251 | 252 | concurrently 253 | 254 | escaping 255 | 102 256 | script 257 | query="{query}" 258 | gid=${query#--*[ ]} 259 | command="--url " 260 | /usr/bin/python aria_actions.py $command$gid 261 | type 262 | 0 263 | 264 | type 265 | alfred.workflow.action.script 266 | uid 267 | 82126C11-9FA3-4DFC-8917-FCD788552F38 268 | version 269 | 0 270 | 271 | 272 | config 273 | 274 | concurrently 275 | 276 | escaping 277 | 102 278 | script 279 | query="{query}" 280 | gid=${query#--*[ ]} 281 | command="--alfred " 282 | /usr/bin/python aria_actions.py $command$gid 283 | type 284 | 0 285 | 286 | type 287 | alfred.workflow.action.script 288 | uid 289 | F918CA67-023E-430D-9903-03DDE35F0E4E 290 | version 291 | 0 292 | 293 | 294 | config 295 | 296 | acceptsmulti 297 | 298 | filetypes 299 | 300 | dyn.ah62d4rv4ge81k55wsmw067a 301 | 302 | name 303 | Add BT download to Aria2 304 | 305 | type 306 | alfred.workflow.trigger.action 307 | uid 308 | F49892BB-5FD7-431B-98A6-C84DA5923E02 309 | version 310 | 0 311 | 312 | 313 | config 314 | 315 | concurrently 316 | 317 | escaping 318 | 102 319 | script 320 | files=("{query}") 321 | for file in ${files[@]}; do 322 | filepath=$file 323 | /usr/bin/python aria_actions.py --bt $filepath 324 | done 325 | type 326 | 0 327 | 328 | type 329 | alfred.workflow.action.script 330 | uid 331 | 063A4D9F-7D6A-43B5-9320-C8FC1FE80612 332 | version 333 | 0 334 | 335 | 336 | readme 337 | 338 | uidata 339 | 340 | 032DABC0-4F0C-4875-A556-DF07D92D1563 341 | 342 | ypos 343 | 240 344 | 345 | 063A4D9F-7D6A-43B5-9320-C8FC1FE80612 346 | 347 | ypos 348 | 710 349 | 350 | 5BBBEF8D-B80D-471F-961F-886F344F1B9D 351 | 352 | ypos 353 | 70 354 | 355 | 60B41887-2E94-4CAF-8BFD-E3108279053A 356 | 357 | ypos 358 | 190 359 | 360 | 82126C11-9FA3-4DFC-8917-FCD788552F38 361 | 362 | ypos 363 | 450 364 | 365 | 85E562C1-0EB6-4234-B34E-BE1843D9E441 366 | 367 | ypos 368 | 70 369 | 370 | 8F555CDC-0040-4F77-9ACF-DEDA171D11D7 371 | 372 | ypos 373 | 70 374 | 375 | 90400F13-F9A6-46D0-95D6-F45BD7F8C733 376 | 377 | ypos 378 | 320 379 | 380 | F49892BB-5FD7-431B-98A6-C84DA5923E02 381 | 382 | ypos 383 | 710 384 | 385 | F918CA67-023E-430D-9903-03DDE35F0E4E 386 | 387 | ypos 388 | 580 389 | 390 | 391 | webaddress 392 | http://wil.dog 393 | 394 | 395 | -------------------------------------------------------------------------------- /src/notifier.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import socket 4 | import sys 5 | import threading 6 | import xmlrpclib 7 | from workflow import Workflow3 8 | 9 | 10 | def escape(s, char=' '): 11 | return s.replace(char, '\\' + char) 12 | 13 | 14 | def notify(msg, title='Ariafred', gid=''): 15 | notifier = os.path.join(wf.workflowdir, 'Ariafred.app/Contents/MacOS/Ariafred') 16 | notifier = escape(notifier) 17 | msg = escape(msg, char='[') 18 | os_command = '%s -title "%s" -message "%s"' % (notifier.encode('utf-8'), 19 | title.encode('utf-8'), 20 | msg.encode('utf-8')) 21 | if gid: 22 | dir = server.tellStatus(secret, gid, ['dir'])['dir'] 23 | filepath = server.getFiles(secret, gid)[0]['path'].encode('utf-8') 24 | if os.path.exists(filepath): 25 | click_command = 'open -R "%s"' % filepath 26 | else: 27 | click_command = 'open "%s" ' % dir 28 | os_command = '%s -execute \'%s\'' % (os_command, click_command) 29 | os.system(os_command) 30 | 31 | 32 | def main(wf): 33 | update_watch_list() 34 | get_notified() 35 | 36 | 37 | def update_watch_list(): 38 | threading.Timer(2.0, update_watch_list).start() 39 | try: 40 | active = server.tellActive(secret, ['gid']) 41 | except (xmlrpclib.Fault, socket.error): 42 | pass 43 | else: 44 | for task in active: 45 | gid = task['gid'] 46 | with lock: 47 | if gid not in watch_list: 48 | watch_list.append(gid) 49 | 50 | 51 | def get_notified(): 52 | threading.Timer(1.0, get_notified).start() 53 | for gid in watch_list: 54 | try: 55 | task = server.tellStatus(secret, gid, ['status', 'errorMessage']) 56 | status = task['status'] 57 | except (xmlrpclib.Fault, socket.error): 58 | pass 59 | else: 60 | if status == 'active': 61 | return 62 | elif status == 'complete': 63 | notify(title='Download completed: ', msg=get_task_name(gid), gid=gid) 64 | elif status == 'error': 65 | notify(title='Error occurred while downloading "' + get_task_name(gid) + '":', 66 | msg=task['errorMessage'], gid=gid) 67 | with lock: 68 | watch_list.remove(gid) 69 | 70 | 71 | def get_task_name(gid): 72 | bt = server.tellStatus(secret, gid, ['bittorrent']) 73 | path = server.getFiles(secret, gid)[0]['path'] 74 | if bt: 75 | file_num = len(server.getFiles(secret, gid)) 76 | if 'info' in bt: 77 | bt_name = bt['bittorrent']['info']['name'] 78 | else: 79 | bt_name = os.path.basename(os.path.dirname(path)) 80 | if not bt_name: 81 | bt_name = 'Task name not obtained yet' 82 | name = u'{bt_name} (BT: {file_num} files)'.format(bt_name=bt_name, file_num=file_num) 83 | else: 84 | name = os.path.basename(path) 85 | if not name: 86 | name = 'Task name not obtained yet' 87 | return name 88 | 89 | 90 | if __name__ == '__main__': 91 | 92 | watch_list = [] 93 | lock = threading.Lock() 94 | 95 | wf = Workflow3() 96 | 97 | rpc_path = wf.settings['rpc_path'] 98 | secret = 'token:' + wf.settings['secret'] 99 | server = xmlrpclib.ServerProxy(rpc_path).aria2 100 | 101 | sys.exit(wf.run(main)) 102 | -------------------------------------------------------------------------------- /src/paused.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/paused.png -------------------------------------------------------------------------------- /src/removed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/removed.png -------------------------------------------------------------------------------- /src/stopped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/stopped.png -------------------------------------------------------------------------------- /src/upload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/upload.png -------------------------------------------------------------------------------- /src/version: -------------------------------------------------------------------------------- 1 | 1.5.4 2 | -------------------------------------------------------------------------------- /src/waiting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/waiting.png -------------------------------------------------------------------------------- /src/workflow/Notify.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildog/Ariafred/8b9d611fb8661945e286e10aaeae308c1d3517eb/src/workflow/Notify.tgz -------------------------------------------------------------------------------- /src/workflow/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 Dean Jackson 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2014-02-15 9 | # 10 | 11 | """A helper library for `Alfred `_ workflows.""" 12 | 13 | import os 14 | 15 | # Workflow objects 16 | from .workflow import Workflow, manager 17 | from .workflow3 import Variables, Workflow3 18 | 19 | # Exceptions 20 | from .workflow import PasswordNotFound, KeychainError 21 | 22 | # Icons 23 | from .workflow import ( 24 | ICON_ACCOUNT, 25 | ICON_BURN, 26 | ICON_CLOCK, 27 | ICON_COLOR, 28 | ICON_COLOUR, 29 | ICON_EJECT, 30 | ICON_ERROR, 31 | ICON_FAVORITE, 32 | ICON_FAVOURITE, 33 | ICON_GROUP, 34 | ICON_HELP, 35 | ICON_HOME, 36 | ICON_INFO, 37 | ICON_NETWORK, 38 | ICON_NOTE, 39 | ICON_SETTINGS, 40 | ICON_SWIRL, 41 | ICON_SWITCH, 42 | ICON_SYNC, 43 | ICON_TRASH, 44 | ICON_USER, 45 | ICON_WARNING, 46 | ICON_WEB, 47 | ) 48 | 49 | # Filter matching rules 50 | from .workflow import ( 51 | MATCH_ALL, 52 | MATCH_ALLCHARS, 53 | MATCH_ATOM, 54 | MATCH_CAPITALS, 55 | MATCH_INITIALS, 56 | MATCH_INITIALS_CONTAIN, 57 | MATCH_INITIALS_STARTSWITH, 58 | MATCH_STARTSWITH, 59 | MATCH_SUBSTRING, 60 | ) 61 | 62 | 63 | __title__ = 'Alfred-Workflow' 64 | __version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read() 65 | __author__ = 'Dean Jackson' 66 | __licence__ = 'MIT' 67 | __copyright__ = 'Copyright 2014-2017 Dean Jackson' 68 | 69 | __all__ = [ 70 | 'Variables', 71 | 'Workflow', 72 | 'Workflow3', 73 | 'manager', 74 | 'PasswordNotFound', 75 | 'KeychainError', 76 | 'ICON_ACCOUNT', 77 | 'ICON_BURN', 78 | 'ICON_CLOCK', 79 | 'ICON_COLOR', 80 | 'ICON_COLOUR', 81 | 'ICON_EJECT', 82 | 'ICON_ERROR', 83 | 'ICON_FAVORITE', 84 | 'ICON_FAVOURITE', 85 | 'ICON_GROUP', 86 | 'ICON_HELP', 87 | 'ICON_HOME', 88 | 'ICON_INFO', 89 | 'ICON_NETWORK', 90 | 'ICON_NOTE', 91 | 'ICON_SETTINGS', 92 | 'ICON_SWIRL', 93 | 'ICON_SWITCH', 94 | 'ICON_SYNC', 95 | 'ICON_TRASH', 96 | 'ICON_USER', 97 | 'ICON_WARNING', 98 | 'ICON_WEB', 99 | 'MATCH_ALL', 100 | 'MATCH_ALLCHARS', 101 | 'MATCH_ATOM', 102 | 'MATCH_CAPITALS', 103 | 'MATCH_INITIALS', 104 | 'MATCH_INITIALS_CONTAIN', 105 | 'MATCH_INITIALS_STARTSWITH', 106 | 'MATCH_STARTSWITH', 107 | 'MATCH_SUBSTRING', 108 | ] 109 | -------------------------------------------------------------------------------- /src/workflow/background.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 deanishe@deanishe.net 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2014-04-06 9 | # 10 | 11 | """ 12 | This module provides an API to run commands in background processes. 13 | Combine with the :ref:`caching API ` to work from cached data 14 | while you fetch fresh data in the background. 15 | 16 | See :ref:`the User Manual ` for more information 17 | and examples. 18 | """ 19 | 20 | from __future__ import print_function, unicode_literals 21 | 22 | import signal 23 | import sys 24 | import os 25 | import subprocess 26 | import pickle 27 | 28 | from workflow import Workflow 29 | 30 | __all__ = ['is_running', 'run_in_background'] 31 | 32 | _wf = None 33 | 34 | 35 | def wf(): 36 | global _wf 37 | if _wf is None: 38 | _wf = Workflow() 39 | return _wf 40 | 41 | 42 | def _log(): 43 | return wf().logger 44 | 45 | 46 | def _arg_cache(name): 47 | """Return path to pickle cache file for arguments. 48 | 49 | :param name: name of task 50 | :type name: ``unicode`` 51 | :returns: Path to cache file 52 | :rtype: ``unicode`` filepath 53 | 54 | """ 55 | return wf().cachefile(name + '.argcache') 56 | 57 | 58 | def _pid_file(name): 59 | """Return path to PID file for ``name``. 60 | 61 | :param name: name of task 62 | :type name: ``unicode`` 63 | :returns: Path to PID file for task 64 | :rtype: ``unicode`` filepath 65 | 66 | """ 67 | return wf().cachefile(name + '.pid') 68 | 69 | 70 | def _process_exists(pid): 71 | """Check if a process with PID ``pid`` exists. 72 | 73 | :param pid: PID to check 74 | :type pid: ``int`` 75 | :returns: ``True`` if process exists, else ``False`` 76 | :rtype: ``Boolean`` 77 | 78 | """ 79 | try: 80 | os.kill(pid, 0) 81 | except OSError: # not running 82 | return False 83 | return True 84 | 85 | 86 | def _job_pid(name): 87 | """Get PID of job or `None` if job does not exist. 88 | 89 | Args: 90 | name (str): Name of job. 91 | 92 | Returns: 93 | int: PID of job process (or `None` if job doesn't exist). 94 | """ 95 | pidfile = _pid_file(name) 96 | if not os.path.exists(pidfile): 97 | return 98 | 99 | with open(pidfile, 'rb') as fp: 100 | pid = int(fp.read()) 101 | 102 | if _process_exists(pid): 103 | return pid 104 | 105 | try: 106 | os.unlink(pidfile) 107 | except Exception: # pragma: no cover 108 | pass 109 | 110 | 111 | def is_running(name): 112 | """Test whether task ``name`` is currently running. 113 | 114 | :param name: name of task 115 | :type name: unicode 116 | :returns: ``True`` if task with name ``name`` is running, else ``False`` 117 | :rtype: bool 118 | 119 | """ 120 | if _job_pid(name) is not None: 121 | return True 122 | 123 | return False 124 | 125 | 126 | def _background(pidfile, stdin='/dev/null', stdout='/dev/null', 127 | stderr='/dev/null'): # pragma: no cover 128 | """Fork the current process into a background daemon. 129 | 130 | :param pidfile: file to write PID of daemon process to. 131 | :type pidfile: filepath 132 | :param stdin: where to read input 133 | :type stdin: filepath 134 | :param stdout: where to write stdout output 135 | :type stdout: filepath 136 | :param stderr: where to write stderr output 137 | :type stderr: filepath 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(str(pid)) 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', 0) 169 | so = open(stdout, 'a+', 0) 170 | se = open(stderr, 'a+', 0) 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 = ['/usr/bin/python', __file__, 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 | -------------------------------------------------------------------------------- /src/workflow/notify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2015 deanishe@deanishe.net 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2015-11-26 9 | # 10 | 11 | # TODO: Exclude this module from test and code coverage in py2.6 12 | 13 | """ 14 | Post notifications via the macOS Notification Center. This feature 15 | is only available on Mountain Lion (10.8) and later. It will 16 | silently fail on older systems. 17 | 18 | The main API is a single function, :func:`~workflow.notify.notify`. 19 | 20 | It works by copying a simple application to your workflow's data 21 | directory. It replaces the application's icon with your workflow's 22 | icon and then calls the application to post notifications. 23 | """ 24 | 25 | from __future__ import print_function, unicode_literals 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 | 36 | import workflow 37 | 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 | assert os.path.exists(n), \ 120 | 'Notify.app could not be installed in %s' % 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 NSWorkspace, NSImage 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 convert_image(inpath, outpath, size): 213 | """Convert an image file using ``sips``. 214 | 215 | Args: 216 | inpath (str): Path of source file. 217 | outpath (str): Path to destination file. 218 | size (int): Width and height of destination image in pixels. 219 | 220 | Raises: 221 | RuntimeError: Raised if ``sips`` exits with non-zero status. 222 | """ 223 | cmd = [ 224 | b'sips', 225 | b'-z', str(size), str(size), 226 | inpath, 227 | b'--out', outpath] 228 | # log().debug(cmd) 229 | with open(os.devnull, 'w') as pipe: 230 | retcode = subprocess.call(cmd, stdout=pipe, stderr=subprocess.STDOUT) 231 | 232 | if retcode != 0: 233 | raise RuntimeError('sips exited with %d' % retcode) 234 | 235 | 236 | def png_to_icns(png_path, icns_path): 237 | """Convert PNG file to ICNS using ``iconutil``. 238 | 239 | Create an iconset from the source PNG file. Generate PNG files 240 | in each size required by macOS, then call ``iconutil`` to turn 241 | them into a single ICNS file. 242 | 243 | Args: 244 | png_path (str): Path to source PNG file. 245 | icns_path (str): Path to destination ICNS file. 246 | 247 | Raises: 248 | RuntimeError: Raised if ``iconutil`` or ``sips`` fail. 249 | """ 250 | tempdir = tempfile.mkdtemp(prefix='aw-', dir=wf().datadir) 251 | 252 | try: 253 | iconset = os.path.join(tempdir, 'Icon.iconset') 254 | 255 | assert not os.path.exists(iconset), \ 256 | 'iconset already exists: ' + iconset 257 | os.makedirs(iconset) 258 | 259 | # Copy source icon to icon set and generate all the other 260 | # sizes needed 261 | configs = [] 262 | for i in (16, 32, 128, 256, 512): 263 | configs.append(('icon_{0}x{0}.png'.format(i), i)) 264 | configs.append((('icon_{0}x{0}@2x.png'.format(i), i * 2))) 265 | 266 | shutil.copy(png_path, os.path.join(iconset, 'icon_256x256.png')) 267 | shutil.copy(png_path, os.path.join(iconset, 'icon_128x128@2x.png')) 268 | 269 | for name, size in configs: 270 | outpath = os.path.join(iconset, name) 271 | if os.path.exists(outpath): 272 | continue 273 | convert_image(png_path, outpath, size) 274 | 275 | cmd = [ 276 | b'iconutil', 277 | b'-c', b'icns', 278 | b'-o', icns_path, 279 | iconset] 280 | 281 | retcode = subprocess.call(cmd) 282 | if retcode != 0: 283 | raise RuntimeError('iconset exited with %d' % retcode) 284 | 285 | assert os.path.exists(icns_path), \ 286 | 'generated ICNS file not found: ' + repr(icns_path) 287 | finally: 288 | try: 289 | shutil.rmtree(tempdir) 290 | except OSError: # pragma: no cover 291 | pass 292 | 293 | 294 | if __name__ == '__main__': # pragma: nocover 295 | # Simple command-line script to test module with 296 | # This won't work on 2.6, as `argparse` isn't available 297 | # by default. 298 | import argparse 299 | 300 | from unicodedata import normalize 301 | 302 | def ustr(s): 303 | """Coerce `s` to normalised Unicode.""" 304 | return normalize('NFD', s.decode('utf-8')) 305 | 306 | p = argparse.ArgumentParser() 307 | p.add_argument('-p', '--png', help="PNG image to convert to ICNS.") 308 | p.add_argument('-l', '--list-sounds', help="Show available sounds.", 309 | action='store_true') 310 | p.add_argument('-t', '--title', 311 | help="Notification title.", type=ustr, 312 | default='') 313 | p.add_argument('-s', '--sound', type=ustr, 314 | help="Optional notification sound.", default='') 315 | p.add_argument('text', type=ustr, 316 | help="Notification body text.", default='', nargs='?') 317 | o = p.parse_args() 318 | 319 | # List available sounds 320 | if o.list_sounds: 321 | for sound in SOUNDS: 322 | print(sound) 323 | sys.exit(0) 324 | 325 | # Convert PNG to ICNS 326 | if o.png: 327 | icns = os.path.join( 328 | os.path.dirname(o.png), 329 | os.path.splitext(os.path.basename(o.png))[0] + '.icns') 330 | 331 | print('converting {0!r} to {1!r} ...'.format(o.png, icns), 332 | file=sys.stderr) 333 | 334 | assert not os.path.exists(icns), \ 335 | 'destination file already exists: ' + icns 336 | 337 | png_to_icns(o.png, icns) 338 | sys.exit(0) 339 | 340 | # Post notification 341 | if o.title == o.text == '': 342 | print('ERROR: empty notification.', file=sys.stderr) 343 | sys.exit(1) 344 | else: 345 | notify(o.title, o.text, o.sound) 346 | -------------------------------------------------------------------------------- /src/workflow/update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 Fabio Niephaus , 5 | # Dean Jackson 6 | # 7 | # MIT Licence. See http://opensource.org/licenses/MIT 8 | # 9 | # Created on 2014-08-16 10 | # 11 | 12 | """Self-updating from GitHub. 13 | 14 | .. versionadded:: 1.9 15 | 16 | .. note:: 17 | 18 | This module is not intended to be used directly. Automatic updates 19 | are controlled by the ``update_settings`` :class:`dict` passed to 20 | :class:`~workflow.workflow.Workflow` objects. 21 | 22 | """ 23 | 24 | from __future__ import print_function, unicode_literals 25 | 26 | import os 27 | import tempfile 28 | import re 29 | import subprocess 30 | 31 | import workflow 32 | import web 33 | 34 | # __all__ = [] 35 | 36 | 37 | RELEASES_BASE = 'https://api.github.com/repos/{0}/releases' 38 | 39 | 40 | _wf = None 41 | 42 | 43 | def wf(): 44 | """Lazy `Workflow` object.""" 45 | global _wf 46 | if _wf is None: 47 | _wf = workflow.Workflow() 48 | return _wf 49 | 50 | 51 | class Version(object): 52 | """Mostly semantic versioning. 53 | 54 | The main difference to proper :ref:`semantic versioning ` 55 | is that this implementation doesn't require a minor or patch version. 56 | 57 | Version strings may also be prefixed with "v", e.g.: 58 | 59 | >>> v = Version('v1.1.1') 60 | >>> v.tuple 61 | (1, 1, 1, '') 62 | 63 | >>> v = Version('2.0') 64 | >>> v.tuple 65 | (2, 0, 0, '') 66 | 67 | >>> Version('3.1-beta').tuple 68 | (3, 1, 0, 'beta') 69 | 70 | >>> Version('1.0.1') > Version('0.0.1') 71 | True 72 | """ 73 | 74 | #: Match version and pre-release/build information in version strings 75 | match_version = re.compile(r'([0-9\.]+)(.+)?').match 76 | 77 | def __init__(self, vstr): 78 | """Create new `Version` object. 79 | 80 | Args: 81 | vstr (basestring): Semantic version string. 82 | """ 83 | self.vstr = vstr 84 | self.major = 0 85 | self.minor = 0 86 | self.patch = 0 87 | self.suffix = '' 88 | self.build = '' 89 | self._parse(vstr) 90 | 91 | def _parse(self, vstr): 92 | if vstr.startswith('v'): 93 | m = self.match_version(vstr[1:]) 94 | else: 95 | m = self.match_version(vstr) 96 | if not m: 97 | raise ValueError('invalid version number: {0}'.format(vstr)) 98 | 99 | version, suffix = m.groups() 100 | parts = self._parse_dotted_string(version) 101 | self.major = parts.pop(0) 102 | if len(parts): 103 | self.minor = parts.pop(0) 104 | if len(parts): 105 | self.patch = parts.pop(0) 106 | if not len(parts) == 0: 107 | raise ValueError('invalid version (too long) : {0}'.format(vstr)) 108 | 109 | if suffix: 110 | # Build info 111 | idx = suffix.find('+') 112 | if idx > -1: 113 | self.build = suffix[idx+1:] 114 | suffix = suffix[:idx] 115 | if suffix: 116 | if not suffix.startswith('-'): 117 | raise ValueError( 118 | 'suffix must start with - : {0}'.format(suffix)) 119 | self.suffix = suffix[1:] 120 | 121 | # wf().logger.debug('version str `{}` -> {}'.format(vstr, repr(self))) 122 | 123 | def _parse_dotted_string(self, s): 124 | """Parse string ``s`` into list of ints and strings.""" 125 | parsed = [] 126 | parts = s.split('.') 127 | for p in parts: 128 | if p.isdigit(): 129 | p = int(p) 130 | parsed.append(p) 131 | return parsed 132 | 133 | @property 134 | def tuple(self): 135 | """Version number as a tuple of major, minor, patch, pre-release.""" 136 | return (self.major, self.minor, self.patch, self.suffix) 137 | 138 | def __lt__(self, other): 139 | """Implement comparison.""" 140 | if not isinstance(other, Version): 141 | raise ValueError('not a Version instance: {0!r}'.format(other)) 142 | t = self.tuple[:3] 143 | o = other.tuple[:3] 144 | if t < o: 145 | return True 146 | if t == o: # We need to compare suffixes 147 | if self.suffix and not other.suffix: 148 | return True 149 | if other.suffix and not self.suffix: 150 | return False 151 | return (self._parse_dotted_string(self.suffix) < 152 | self._parse_dotted_string(other.suffix)) 153 | # t > o 154 | return False 155 | 156 | def __eq__(self, other): 157 | """Implement comparison.""" 158 | if not isinstance(other, Version): 159 | raise ValueError('not a Version instance: {0!r}'.format(other)) 160 | return self.tuple == other.tuple 161 | 162 | def __ne__(self, other): 163 | """Implement comparison.""" 164 | return not self.__eq__(other) 165 | 166 | def __gt__(self, other): 167 | """Implement comparison.""" 168 | if not isinstance(other, Version): 169 | raise ValueError('not a Version instance: {0!r}'.format(other)) 170 | return other.__lt__(self) 171 | 172 | def __le__(self, other): 173 | """Implement comparison.""" 174 | if not isinstance(other, Version): 175 | raise ValueError('not a Version instance: {0!r}'.format(other)) 176 | return not other.__lt__(self) 177 | 178 | def __ge__(self, other): 179 | """Implement comparison.""" 180 | return not self.__lt__(other) 181 | 182 | def __str__(self): 183 | """Return semantic version string.""" 184 | vstr = '{0}.{1}.{2}'.format(self.major, self.minor, self.patch) 185 | if self.suffix: 186 | vstr = '{0}-{1}'.format(vstr, self.suffix) 187 | if self.build: 188 | vstr = '{0}+{1}'.format(vstr, self.build) 189 | return vstr 190 | 191 | def __repr__(self): 192 | """Return 'code' representation of `Version`.""" 193 | return "Version('{0}')".format(str(self)) 194 | 195 | 196 | def download_workflow(url): 197 | """Download workflow at ``url`` to a local temporary file. 198 | 199 | :param url: URL to .alfredworkflow file in GitHub repo 200 | :returns: path to downloaded file 201 | 202 | """ 203 | filename = url.split('/')[-1] 204 | 205 | if (not filename.endswith('.alfredworkflow') and 206 | not filename.endswith('.alfred3workflow')): 207 | raise ValueError('attachment not a workflow: {0}'.format(filename)) 208 | 209 | local_path = os.path.join(tempfile.gettempdir(), filename) 210 | 211 | wf().logger.debug( 212 | 'downloading updated workflow from `%s` to `%s` ...', url, local_path) 213 | 214 | response = web.get(url) 215 | 216 | with open(local_path, 'wb') as output: 217 | output.write(response.content) 218 | 219 | return local_path 220 | 221 | 222 | def build_api_url(slug): 223 | """Generate releases URL from GitHub slug. 224 | 225 | :param slug: Repo name in form ``username/repo`` 226 | :returns: URL to the API endpoint for the repo's releases 227 | 228 | """ 229 | if len(slug.split('/')) != 2: 230 | raise ValueError('invalid GitHub slug: {0}'.format(slug)) 231 | 232 | return RELEASES_BASE.format(slug) 233 | 234 | 235 | def _validate_release(release): 236 | """Return release for running version of Alfred.""" 237 | alf3 = wf().alfred_version.major == 3 238 | 239 | downloads = {'.alfredworkflow': [], '.alfred3workflow': []} 240 | dl_count = 0 241 | version = release['tag_name'] 242 | 243 | for asset in release.get('assets', []): 244 | url = asset.get('browser_download_url') 245 | if not url: # pragma: nocover 246 | continue 247 | 248 | ext = os.path.splitext(url)[1].lower() 249 | if ext not in downloads: 250 | continue 251 | 252 | # Ignore Alfred 3-only files if Alfred 2 is running 253 | if ext == '.alfred3workflow' and not alf3: 254 | continue 255 | 256 | downloads[ext].append(url) 257 | dl_count += 1 258 | 259 | # download_urls.append(url) 260 | 261 | if dl_count == 0: 262 | wf().logger.warning( 263 | 'invalid release (no workflow file): %s', version) 264 | return None 265 | 266 | for k in downloads: 267 | if len(downloads[k]) > 1: 268 | wf().logger.warning( 269 | 'invalid release (multiple %s files): %s', k, version) 270 | return None 271 | 272 | # Prefer .alfred3workflow file if there is one and Alfred 3 is 273 | # running. 274 | if alf3 and len(downloads['.alfred3workflow']): 275 | download_url = downloads['.alfred3workflow'][0] 276 | 277 | else: 278 | download_url = downloads['.alfredworkflow'][0] 279 | 280 | wf().logger.debug('release %s: %s', version, download_url) 281 | 282 | return { 283 | 'version': version, 284 | 'download_url': download_url, 285 | 'prerelease': release['prerelease'] 286 | } 287 | 288 | 289 | def get_valid_releases(github_slug, prereleases=False): 290 | """Return list of all valid releases. 291 | 292 | :param github_slug: ``username/repo`` for workflow's GitHub repo 293 | :param prereleases: Whether to include pre-releases. 294 | :returns: list of dicts. Each :class:`dict` has the form 295 | ``{'version': '1.1', 'download_url': 'http://github.com/...', 296 | 'prerelease': False }`` 297 | 298 | 299 | A valid release is one that contains one ``.alfredworkflow`` file. 300 | 301 | If the GitHub version (i.e. tag) is of the form ``v1.1``, the leading 302 | ``v`` will be stripped. 303 | 304 | """ 305 | api_url = build_api_url(github_slug) 306 | releases = [] 307 | 308 | wf().logger.debug('retrieving releases list: %s', api_url) 309 | 310 | def retrieve_releases(): 311 | wf().logger.info( 312 | 'retrieving releases: %s', github_slug) 313 | return web.get(api_url).json() 314 | 315 | slug = github_slug.replace('/', '-') 316 | for release in wf().cached_data('gh-releases-' + slug, retrieve_releases): 317 | 318 | release = _validate_release(release) 319 | if release is None: 320 | wf().logger.debug('invalid release: %r', release) 321 | continue 322 | 323 | elif release['prerelease'] and not prereleases: 324 | wf().logger.debug('ignoring prerelease: %s', release['version']) 325 | continue 326 | 327 | wf().logger.debug('release: %r', release) 328 | 329 | releases.append(release) 330 | 331 | return releases 332 | 333 | 334 | def check_update(github_slug, current_version, prereleases=False): 335 | """Check whether a newer release is available on GitHub. 336 | 337 | :param github_slug: ``username/repo`` for workflow's GitHub repo 338 | :param current_version: the currently installed version of the 339 | workflow. :ref:`Semantic versioning ` is required. 340 | :param prereleases: Whether to include pre-releases. 341 | :type current_version: ``unicode`` 342 | :returns: ``True`` if an update is available, else ``False`` 343 | 344 | If an update is available, its version number and download URL will 345 | be cached. 346 | 347 | """ 348 | releases = get_valid_releases(github_slug, prereleases) 349 | 350 | if not len(releases): 351 | raise ValueError('no valid releases for %s', github_slug) 352 | 353 | wf().logger.info('%d releases for %s', len(releases), github_slug) 354 | 355 | # GitHub returns releases newest-first 356 | latest_release = releases[0] 357 | 358 | # (latest_version, download_url) = get_latest_release(releases) 359 | vr = Version(latest_release['version']) 360 | vl = Version(current_version) 361 | wf().logger.debug('latest=%r, installed=%r', vr, vl) 362 | if vr > vl: 363 | 364 | wf().cache_data('__workflow_update_status', { 365 | 'version': latest_release['version'], 366 | 'download_url': latest_release['download_url'], 367 | 'available': True 368 | }) 369 | 370 | return True 371 | 372 | wf().cache_data('__workflow_update_status', {'available': False}) 373 | return False 374 | 375 | 376 | def install_update(): 377 | """If a newer release is available, download and install it. 378 | 379 | :returns: ``True`` if an update is installed, else ``False`` 380 | 381 | """ 382 | update_data = wf().cached_data('__workflow_update_status', max_age=0) 383 | 384 | if not update_data or not update_data.get('available'): 385 | wf().logger.info('no update available') 386 | return False 387 | 388 | local_file = download_workflow(update_data['download_url']) 389 | 390 | wf().logger.info('installing updated workflow ...') 391 | subprocess.call(['open', local_file]) 392 | 393 | update_data['available'] = False 394 | wf().cache_data('__workflow_update_status', update_data) 395 | return True 396 | 397 | 398 | if __name__ == '__main__': # pragma: nocover 399 | import sys 400 | 401 | def show_help(status=0): 402 | """Print help message.""" 403 | print('Usage : update.py (check|install) ' 404 | '[--prereleases] ') 405 | sys.exit(status) 406 | 407 | argv = sys.argv[:] 408 | if '-h' in argv or '--help' in argv: 409 | show_help() 410 | 411 | prereleases = '--prereleases' in argv 412 | 413 | if prereleases: 414 | argv.remove('--prereleases') 415 | 416 | if len(argv) != 4: 417 | show_help(1) 418 | 419 | action, github_slug, version = argv[1:] 420 | 421 | if action == 'check': 422 | check_update(github_slug, version, prereleases) 423 | elif action == 'install': 424 | install_update() 425 | else: 426 | show_help(1) 427 | -------------------------------------------------------------------------------- /src/workflow/util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2017 Dean Jackson 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2017-12-17 9 | # 10 | 11 | """A selection of helper functions useful for building workflows.""" 12 | 13 | from __future__ import print_function, absolute_import 14 | 15 | import atexit 16 | from collections import namedtuple 17 | from contextlib import contextmanager 18 | import errno 19 | import fcntl 20 | import functools 21 | import os 22 | import signal 23 | import subprocess 24 | import sys 25 | from threading import Event 26 | import time 27 | 28 | # AppleScript to call an External Trigger in Alfred 29 | AS_TRIGGER = """ 30 | tell application "Alfred 3" 31 | run trigger "{name}" in workflow "{bundleid}" {arg} 32 | end tell 33 | """ 34 | 35 | 36 | class AcquisitionError(Exception): 37 | """Raised if a lock cannot be acquired.""" 38 | 39 | 40 | AppInfo = namedtuple('AppInfo', ['name', 'path', 'bundleid']) 41 | """Information about an installed application. 42 | 43 | Returned by :func:`appinfo`. All attributes are Unicode. 44 | 45 | .. py:attribute:: name 46 | 47 | Name of the application, e.g. ``u'Safari'``. 48 | 49 | .. py:attribute:: path 50 | 51 | Path to the application bundle, e.g. ``u'/Applications/Safari.app'``. 52 | 53 | .. py:attribute:: bundleid 54 | 55 | Application's bundle ID, e.g. ``u'com.apple.Safari'``. 56 | """ 57 | 58 | 59 | def unicodify(s, encoding='utf-8', norm=None): 60 | """Ensure string is Unicode. 61 | 62 | .. versionadded:: 1.31 63 | 64 | Decode encoded strings using ``encoding`` and normalise Unicode 65 | to form ``norm`` if specified. 66 | 67 | Args: 68 | s (str): String to decode. May also be Unicode. 69 | encoding (str, optional): Encoding to use on bytestrings. 70 | norm (None, optional): Normalisation form to apply to Unicode string. 71 | 72 | Returns: 73 | unicode: Decoded, optionally normalised, Unicode string. 74 | 75 | """ 76 | if not isinstance(s, unicode): 77 | s = unicode(s, encoding) 78 | 79 | if norm: 80 | from unicodedata import normalize 81 | s = normalize(norm, s) 82 | 83 | return s 84 | 85 | 86 | def utf8ify(s): 87 | """Ensure string is a bytestring. 88 | 89 | .. versionadded:: 1.31 90 | 91 | Returns `str` objects unchanced, encodes `unicode` objects to 92 | UTF-8, and calls :func:`str` on anything else. 93 | 94 | Args: 95 | s (object): A Python object 96 | 97 | Returns: 98 | str: UTF-8 string or string representation of s. 99 | """ 100 | if isinstance(s, str): 101 | return s 102 | 103 | if isinstance(s, unicode): 104 | return s.encode('utf-8') 105 | 106 | return str(s) 107 | 108 | 109 | def applescriptify(s): 110 | """Escape string for insertion into an AppleScript string. 111 | 112 | .. versionadded:: 1.31 113 | 114 | Replaces ``"`` with `"& quote &"`. Use this function if you want 115 | 116 | to insert a string into an AppleScript script: 117 | >>> script = 'tell application "Alfred 3" to search "{}"' 118 | >>> query = 'g "python" test' 119 | >>> script.format(applescriptify(query)) 120 | 'tell application "Alfred 3" to search "g " & quote & "python" & quote & "test"' 121 | 122 | Args: 123 | s (unicode): Unicode string to escape. 124 | 125 | Returns: 126 | unicode: Escaped string 127 | """ 128 | return s.replace(u'"', u'" & quote & "') 129 | 130 | 131 | def run_command(cmd, **kwargs): 132 | """Run a command and return the output. 133 | 134 | .. versionadded:: 1.31 135 | 136 | A thin wrapper around :func:`subprocess.check_output` that ensures 137 | all arguments are encoded to UTF-8 first. 138 | 139 | Args: 140 | cmd (list): Command arguments to pass to ``check_output``. 141 | **kwargs: Keyword arguments to pass to ``check_output``. 142 | 143 | Returns: 144 | str: Output returned by ``check_output``. 145 | """ 146 | cmd = [utf8ify(s) for s in cmd] 147 | return subprocess.check_output(cmd, **kwargs) 148 | 149 | 150 | def run_applescript(script, *args, **kwargs): 151 | """Execute an AppleScript script and return its output. 152 | 153 | .. versionadded:: 1.31 154 | 155 | Run AppleScript either by filepath or code. If ``script`` is a valid 156 | filepath, that script will be run, otherwise ``script`` is treated 157 | as code. 158 | 159 | Args: 160 | script (str, optional): Filepath of script or code to run. 161 | *args: Optional command-line arguments to pass to the script. 162 | **kwargs: Pass ``lang`` to run a language other than AppleScript. 163 | 164 | Returns: 165 | str: Output of run command. 166 | 167 | """ 168 | cmd = ['/usr/bin/osascript', '-l', kwargs.get('lang', 'AppleScript')] 169 | 170 | if os.path.exists(script): 171 | cmd += [script] 172 | else: 173 | cmd += ['-e', script] 174 | 175 | cmd.extend(args) 176 | 177 | return run_command(cmd) 178 | 179 | 180 | def run_jxa(script, *args): 181 | """Execute a JXA script and return its output. 182 | 183 | .. versionadded:: 1.31 184 | 185 | Wrapper around :func:`run_applescript` that passes ``lang=JavaScript``. 186 | 187 | Args: 188 | script (str): Filepath of script or code to run. 189 | *args: Optional command-line arguments to pass to script. 190 | 191 | Returns: 192 | str: Output of script. 193 | """ 194 | return run_applescript(script, *args, lang='JavaScript') 195 | 196 | 197 | def run_trigger(name, bundleid=None, arg=None): 198 | """Call an Alfred External Trigger. 199 | 200 | .. versionadded:: 1.31 201 | 202 | If ``bundleid`` is not specified, reads the bundle ID of the current 203 | workflow from Alfred's environment variables. 204 | 205 | Args: 206 | name (str): Name of External Trigger to call. 207 | bundleid (str, optional): Bundle ID of workflow trigger belongs to. 208 | arg (str, optional): Argument to pass to trigger. 209 | """ 210 | if not bundleid: 211 | bundleid = os.getenv('alfred_workflow_bundleid') 212 | 213 | if arg: 214 | arg = 'with argument "{}"'.format(applescriptify(arg)) 215 | else: 216 | arg = '' 217 | 218 | script = AS_TRIGGER.format(name=name, bundleid=bundleid, 219 | arg=arg) 220 | 221 | run_applescript(script) 222 | 223 | 224 | def appinfo(name): 225 | """Get information about an installed application. 226 | 227 | .. versionadded:: 1.31 228 | 229 | Args: 230 | name (str): Name of application to look up. 231 | 232 | Returns: 233 | AppInfo: :class:`AppInfo` tuple or ``None`` if app isn't found. 234 | """ 235 | cmd = ['mdfind', '-onlyin', '/', 236 | '(kMDItemContentTypeTree == com.apple.application &&' 237 | '(kMDItemDisplayName == "{0}" || kMDItemFSName == "{0}.app"))' 238 | .format(name)] 239 | 240 | path = run_command(cmd).strip() 241 | if not path: 242 | return None 243 | 244 | cmd = ['mdls', '-raw', '-name', 'kMDItemCFBundleIdentifier', path] 245 | bid = run_command(cmd).strip() 246 | if not bid: # pragma: no cover 247 | return None 248 | 249 | return AppInfo(unicodify(name), unicodify(path), unicodify(bid)) 250 | 251 | 252 | @contextmanager 253 | def atomic_writer(fpath, mode): 254 | """Atomic file writer. 255 | 256 | .. versionadded:: 1.12 257 | 258 | Context manager that ensures the file is only written if the write 259 | succeeds. The data is first written to a temporary file. 260 | 261 | :param fpath: path of file to write to. 262 | :type fpath: ``unicode`` 263 | :param mode: sames as for :func:`open` 264 | :type mode: string 265 | 266 | """ 267 | suffix = '.{}.tmp'.format(os.getpid()) 268 | temppath = fpath + suffix 269 | with open(temppath, mode) as fp: 270 | try: 271 | yield fp 272 | os.rename(temppath, fpath) 273 | finally: 274 | try: 275 | os.remove(temppath) 276 | except (OSError, IOError): 277 | pass 278 | 279 | 280 | class LockFile(object): 281 | """Context manager to protect filepaths with lockfiles. 282 | 283 | .. versionadded:: 1.13 284 | 285 | Creates a lockfile alongside ``protected_path``. Other ``LockFile`` 286 | instances will refuse to lock the same path. 287 | 288 | >>> path = '/path/to/file' 289 | >>> with LockFile(path): 290 | >>> with open(path, 'wb') as fp: 291 | >>> fp.write(data) 292 | 293 | Args: 294 | protected_path (unicode): File to protect with a lockfile 295 | timeout (float, optional): Raises an :class:`AcquisitionError` 296 | if lock cannot be acquired within this number of seconds. 297 | If ``timeout`` is 0 (the default), wait forever. 298 | delay (float, optional): How often to check (in seconds) if 299 | lock has been released. 300 | 301 | Attributes: 302 | delay (float): How often to check (in seconds) whether the lock 303 | can be acquired. 304 | lockfile (unicode): Path of the lockfile. 305 | timeout (float): How long to wait to acquire the lock. 306 | 307 | """ 308 | 309 | def __init__(self, protected_path, timeout=0.0, delay=0.05): 310 | """Create new :class:`LockFile` object.""" 311 | self.lockfile = protected_path + '.lock' 312 | self._lockfile = None 313 | self.timeout = timeout 314 | self.delay = delay 315 | self._lock = Event() 316 | atexit.register(self.release) 317 | 318 | @property 319 | def locked(self): 320 | """``True`` if file is locked by this instance.""" 321 | return self._lock.is_set() 322 | 323 | def acquire(self, blocking=True): 324 | """Acquire the lock if possible. 325 | 326 | If the lock is in use and ``blocking`` is ``False``, return 327 | ``False``. 328 | 329 | Otherwise, check every :attr:`delay` seconds until it acquires 330 | lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`. 331 | 332 | """ 333 | if self.locked and not blocking: 334 | return False 335 | 336 | start = time.time() 337 | while True: 338 | 339 | # Raise error if we've been waiting too long to acquire the lock 340 | if self.timeout and (time.time() - start) >= self.timeout: 341 | raise AcquisitionError('lock acquisition timed out') 342 | 343 | # If already locked, wait then try again 344 | if self.locked: 345 | time.sleep(self.delay) 346 | continue 347 | 348 | # Create in append mode so we don't lose any contents 349 | if self._lockfile is None: 350 | self._lockfile = open(self.lockfile, 'a') 351 | 352 | # Try to acquire the lock 353 | try: 354 | fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB) 355 | self._lock.set() 356 | break 357 | except IOError as err: # pragma: no cover 358 | if err.errno not in (errno.EACCES, errno.EAGAIN): 359 | raise 360 | 361 | # Don't try again 362 | if not blocking: # pragma: no cover 363 | return False 364 | 365 | # Wait, then try again 366 | time.sleep(self.delay) 367 | 368 | return True 369 | 370 | def release(self): 371 | """Release the lock by deleting `self.lockfile`.""" 372 | if not self._lock.is_set(): 373 | return False 374 | 375 | try: 376 | fcntl.lockf(self._lockfile, fcntl.LOCK_UN) 377 | except IOError: # pragma: no cover 378 | pass 379 | finally: 380 | self._lock.clear() 381 | self._lockfile = None 382 | try: 383 | os.unlink(self.lockfile) 384 | except (IOError, OSError): # pragma: no cover 385 | pass 386 | 387 | return True 388 | 389 | def __enter__(self): 390 | """Acquire lock.""" 391 | self.acquire() 392 | return self 393 | 394 | def __exit__(self, typ, value, traceback): 395 | """Release lock.""" 396 | self.release() 397 | 398 | def __del__(self): 399 | """Clear up `self.lockfile`.""" 400 | self.release() # pragma: no cover 401 | 402 | 403 | class uninterruptible(object): 404 | """Decorator that postpones SIGTERM until wrapped function returns. 405 | 406 | .. versionadded:: 1.12 407 | 408 | .. important:: This decorator is NOT thread-safe. 409 | 410 | As of version 2.7, Alfred allows Script Filters to be killed. If 411 | your workflow is killed in the middle of critical code (e.g. 412 | writing data to disk), this may corrupt your workflow's data. 413 | 414 | Use this decorator to wrap critical functions that *must* complete. 415 | If the script is killed while a wrapped function is executing, 416 | the SIGTERM will be caught and handled after your function has 417 | finished executing. 418 | 419 | Alfred-Workflow uses this internally to ensure its settings, data 420 | and cache writes complete. 421 | 422 | """ 423 | 424 | def __init__(self, func, class_name=''): 425 | """Decorate `func`.""" 426 | self.func = func 427 | functools.update_wrapper(self, func) 428 | self._caught_signal = None 429 | 430 | def signal_handler(self, signum, frame): 431 | """Called when process receives SIGTERM.""" 432 | self._caught_signal = (signum, frame) 433 | 434 | def __call__(self, *args, **kwargs): 435 | """Trap ``SIGTERM`` and call wrapped function.""" 436 | self._caught_signal = None 437 | # Register handler for SIGTERM, then call `self.func` 438 | self.old_signal_handler = signal.getsignal(signal.SIGTERM) 439 | signal.signal(signal.SIGTERM, self.signal_handler) 440 | 441 | self.func(*args, **kwargs) 442 | 443 | # Restore old signal handler 444 | signal.signal(signal.SIGTERM, self.old_signal_handler) 445 | 446 | # Handle any signal caught during execution 447 | if self._caught_signal is not None: 448 | signum, frame = self._caught_signal 449 | if callable(self.old_signal_handler): 450 | self.old_signal_handler(signum, frame) 451 | elif self.old_signal_handler == signal.SIG_DFL: 452 | sys.exit(0) 453 | 454 | def __get__(self, obj=None, klass=None): 455 | """Decorator API.""" 456 | return self.__class__(self.func.__get__(obj, klass), 457 | klass.__name__) 458 | -------------------------------------------------------------------------------- /src/workflow/version: -------------------------------------------------------------------------------- 1 | 1.32 -------------------------------------------------------------------------------- /src/workflow/web.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2014 Dean Jackson 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2014-02-15 8 | # 9 | 10 | """Lightweight HTTP library with a requests-like interface.""" 11 | 12 | import codecs 13 | import json 14 | import mimetypes 15 | import os 16 | import random 17 | import re 18 | import socket 19 | import string 20 | import unicodedata 21 | import urllib 22 | import urllib2 23 | import urlparse 24 | import zlib 25 | 26 | 27 | USER_AGENT = u'Alfred-Workflow/1.19 (+http://www.deanishe.net/alfred-workflow)' 28 | 29 | # Valid characters for multipart form data boundaries 30 | BOUNDARY_CHARS = string.digits + string.ascii_letters 31 | 32 | # HTTP response codes 33 | RESPONSES = { 34 | 100: 'Continue', 35 | 101: 'Switching Protocols', 36 | 200: 'OK', 37 | 201: 'Created', 38 | 202: 'Accepted', 39 | 203: 'Non-Authoritative Information', 40 | 204: 'No Content', 41 | 205: 'Reset Content', 42 | 206: 'Partial Content', 43 | 300: 'Multiple Choices', 44 | 301: 'Moved Permanently', 45 | 302: 'Found', 46 | 303: 'See Other', 47 | 304: 'Not Modified', 48 | 305: 'Use Proxy', 49 | 307: 'Temporary Redirect', 50 | 400: 'Bad Request', 51 | 401: 'Unauthorized', 52 | 402: 'Payment Required', 53 | 403: 'Forbidden', 54 | 404: 'Not Found', 55 | 405: 'Method Not Allowed', 56 | 406: 'Not Acceptable', 57 | 407: 'Proxy Authentication Required', 58 | 408: 'Request Timeout', 59 | 409: 'Conflict', 60 | 410: 'Gone', 61 | 411: 'Length Required', 62 | 412: 'Precondition Failed', 63 | 413: 'Request Entity Too Large', 64 | 414: 'Request-URI Too Long', 65 | 415: 'Unsupported Media Type', 66 | 416: 'Requested Range Not Satisfiable', 67 | 417: 'Expectation Failed', 68 | 500: 'Internal Server Error', 69 | 501: 'Not Implemented', 70 | 502: 'Bad Gateway', 71 | 503: 'Service Unavailable', 72 | 504: 'Gateway Timeout', 73 | 505: 'HTTP Version Not Supported' 74 | } 75 | 76 | 77 | def str_dict(dic): 78 | """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`. 79 | 80 | :param dic: Mapping of Unicode strings 81 | :type dic: dict 82 | :returns: Dictionary containing only UTF-8 strings 83 | :rtype: dict 84 | 85 | """ 86 | if isinstance(dic, CaseInsensitiveDictionary): 87 | dic2 = CaseInsensitiveDictionary() 88 | else: 89 | dic2 = {} 90 | for k, v in dic.items(): 91 | if isinstance(k, unicode): 92 | k = k.encode('utf-8') 93 | if isinstance(v, unicode): 94 | v = v.encode('utf-8') 95 | dic2[k] = v 96 | return dic2 97 | 98 | 99 | class NoRedirectHandler(urllib2.HTTPRedirectHandler): 100 | """Prevent redirections.""" 101 | 102 | def redirect_request(self, *args): 103 | return None 104 | 105 | 106 | # Adapted from https://gist.github.com/babakness/3901174 107 | class CaseInsensitiveDictionary(dict): 108 | """Dictionary with caseless key search. 109 | 110 | Enables case insensitive searching while preserving case sensitivity 111 | when keys are listed, ie, via keys() or items() methods. 112 | 113 | Works by storing a lowercase version of the key as the new key and 114 | stores the original key-value pair as the key's value 115 | (values become dictionaries). 116 | 117 | """ 118 | 119 | def __init__(self, initval=None): 120 | """Create new case-insensitive dictionary.""" 121 | if isinstance(initval, dict): 122 | for key, value in initval.iteritems(): 123 | self.__setitem__(key, value) 124 | 125 | elif isinstance(initval, list): 126 | for (key, value) in initval: 127 | self.__setitem__(key, value) 128 | 129 | def __contains__(self, key): 130 | return dict.__contains__(self, key.lower()) 131 | 132 | def __getitem__(self, key): 133 | return dict.__getitem__(self, key.lower())['val'] 134 | 135 | def __setitem__(self, key, value): 136 | return dict.__setitem__(self, key.lower(), {'key': key, 'val': value}) 137 | 138 | def get(self, key, default=None): 139 | try: 140 | v = dict.__getitem__(self, key.lower()) 141 | except KeyError: 142 | return default 143 | else: 144 | return v['val'] 145 | 146 | def update(self, other): 147 | for k, v in other.items(): 148 | self[k] = v 149 | 150 | def items(self): 151 | return [(v['key'], v['val']) for v in dict.itervalues(self)] 152 | 153 | def keys(self): 154 | return [v['key'] for v in dict.itervalues(self)] 155 | 156 | def values(self): 157 | return [v['val'] for v in dict.itervalues(self)] 158 | 159 | def iteritems(self): 160 | for v in dict.itervalues(self): 161 | yield v['key'], v['val'] 162 | 163 | def iterkeys(self): 164 | for v in dict.itervalues(self): 165 | yield v['key'] 166 | 167 | def itervalues(self): 168 | for v in dict.itervalues(self): 169 | yield v['val'] 170 | 171 | 172 | class Response(object): 173 | """ 174 | Returned by :func:`request` / :func:`get` / :func:`post` functions. 175 | 176 | Simplified version of the ``Response`` object in the ``requests`` library. 177 | 178 | >>> r = request('http://www.google.com') 179 | >>> r.status_code 180 | 200 181 | >>> r.encoding 182 | ISO-8859-1 183 | >>> r.content # bytes 184 | ... 185 | >>> r.text # unicode, decoded according to charset in HTTP header/meta tag 186 | u' ...' 187 | >>> r.json() # content parsed as JSON 188 | 189 | """ 190 | 191 | def __init__(self, request, stream=False): 192 | """Call `request` with :mod:`urllib2` and process results. 193 | 194 | :param request: :class:`urllib2.Request` instance 195 | :param stream: Whether to stream response or retrieve it all at once 196 | :type stream: bool 197 | 198 | """ 199 | self.request = request 200 | self._stream = stream 201 | self.url = None 202 | self.raw = None 203 | self._encoding = None 204 | self.error = None 205 | self.status_code = None 206 | self.reason = None 207 | self.headers = CaseInsensitiveDictionary() 208 | self._content = None 209 | self._content_loaded = False 210 | self._gzipped = False 211 | 212 | # Execute query 213 | try: 214 | self.raw = urllib2.urlopen(request) 215 | except urllib2.HTTPError as err: 216 | self.error = err 217 | try: 218 | self.url = err.geturl() 219 | # sometimes (e.g. when authentication fails) 220 | # urllib can't get a URL from an HTTPError 221 | # This behaviour changes across Python versions, 222 | # so no test cover (it isn't important). 223 | except AttributeError: # pragma: no cover 224 | pass 225 | self.status_code = err.code 226 | else: 227 | self.status_code = self.raw.getcode() 228 | self.url = self.raw.geturl() 229 | self.reason = RESPONSES.get(self.status_code) 230 | 231 | # Parse additional info if request succeeded 232 | if not self.error: 233 | headers = self.raw.info() 234 | self.transfer_encoding = headers.getencoding() 235 | self.mimetype = headers.gettype() 236 | for key in headers.keys(): 237 | self.headers[key.lower()] = headers.get(key) 238 | 239 | # Is content gzipped? 240 | # Transfer-Encoding appears to not be used in the wild 241 | # (contrary to the HTTP standard), but no harm in testing 242 | # for it 243 | if ('gzip' in headers.get('content-encoding', '') or 244 | 'gzip' in headers.get('transfer-encoding', '')): 245 | self._gzipped = True 246 | 247 | @property 248 | def stream(self): 249 | """Whether response is streamed. 250 | 251 | Returns: 252 | bool: `True` if response is streamed. 253 | """ 254 | return self._stream 255 | 256 | @stream.setter 257 | def stream(self, value): 258 | if self._content_loaded: 259 | raise RuntimeError("`content` has already been read from " 260 | "this Response.") 261 | 262 | self._stream = value 263 | 264 | def json(self): 265 | """Decode response contents as JSON. 266 | 267 | :returns: object decoded from JSON 268 | :rtype: list, dict or unicode 269 | 270 | """ 271 | return json.loads(self.content, self.encoding or 'utf-8') 272 | 273 | @property 274 | def encoding(self): 275 | """Text encoding of document or ``None``. 276 | 277 | :returns: Text encoding if found. 278 | :rtype: str or ``None`` 279 | 280 | """ 281 | if not self._encoding: 282 | self._encoding = self._get_encoding() 283 | 284 | return self._encoding 285 | 286 | @property 287 | def content(self): 288 | """Raw content of response (i.e. bytes). 289 | 290 | :returns: Body of HTTP response 291 | :rtype: str 292 | 293 | """ 294 | if not self._content: 295 | 296 | # Decompress gzipped content 297 | if self._gzipped: 298 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) 299 | self._content = decoder.decompress(self.raw.read()) 300 | 301 | else: 302 | self._content = self.raw.read() 303 | 304 | self._content_loaded = True 305 | 306 | return self._content 307 | 308 | @property 309 | def text(self): 310 | """Unicode-decoded content of response body. 311 | 312 | If no encoding can be determined from HTTP headers or the content 313 | itself, the encoded response body will be returned instead. 314 | 315 | :returns: Body of HTTP response 316 | :rtype: unicode or str 317 | 318 | """ 319 | if self.encoding: 320 | return unicodedata.normalize('NFC', unicode(self.content, 321 | self.encoding)) 322 | return self.content 323 | 324 | def iter_content(self, chunk_size=4096, decode_unicode=False): 325 | """Iterate over response data. 326 | 327 | .. versionadded:: 1.6 328 | 329 | :param chunk_size: Number of bytes to read into memory 330 | :type chunk_size: int 331 | :param decode_unicode: Decode to Unicode using detected encoding 332 | :type decode_unicode: bool 333 | :returns: iterator 334 | 335 | """ 336 | if not self.stream: 337 | raise RuntimeError("You cannot call `iter_content` on a " 338 | "Response unless you passed `stream=True`" 339 | " to `get()`/`post()`/`request()`.") 340 | 341 | if self._content_loaded: 342 | raise RuntimeError( 343 | "`content` has already been read from this Response.") 344 | 345 | def decode_stream(iterator, r): 346 | 347 | decoder = codecs.getincrementaldecoder(r.encoding)(errors='replace') 348 | 349 | for chunk in iterator: 350 | data = decoder.decode(chunk) 351 | if data: 352 | yield data 353 | 354 | data = decoder.decode(b'', final=True) 355 | if data: # pragma: no cover 356 | yield data 357 | 358 | def generate(): 359 | 360 | if self._gzipped: 361 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) 362 | 363 | while True: 364 | chunk = self.raw.read(chunk_size) 365 | if not chunk: 366 | break 367 | 368 | if self._gzipped: 369 | chunk = decoder.decompress(chunk) 370 | 371 | yield chunk 372 | 373 | chunks = generate() 374 | 375 | if decode_unicode and self.encoding: 376 | chunks = decode_stream(chunks, self) 377 | 378 | return chunks 379 | 380 | def save_to_path(self, filepath): 381 | """Save retrieved data to file at ``filepath``. 382 | 383 | .. versionadded: 1.9.6 384 | 385 | :param filepath: Path to save retrieved data. 386 | 387 | """ 388 | filepath = os.path.abspath(filepath) 389 | dirname = os.path.dirname(filepath) 390 | if not os.path.exists(dirname): 391 | os.makedirs(dirname) 392 | 393 | self.stream = True 394 | 395 | with open(filepath, 'wb') as fileobj: 396 | for data in self.iter_content(): 397 | fileobj.write(data) 398 | 399 | def raise_for_status(self): 400 | """Raise stored error if one occurred. 401 | 402 | error will be instance of :class:`urllib2.HTTPError` 403 | """ 404 | if self.error is not None: 405 | raise self.error 406 | return 407 | 408 | def _get_encoding(self): 409 | """Get encoding from HTTP headers or content. 410 | 411 | :returns: encoding or `None` 412 | :rtype: unicode or ``None`` 413 | 414 | """ 415 | headers = self.raw.info() 416 | encoding = None 417 | 418 | if headers.getparam('charset'): 419 | encoding = headers.getparam('charset') 420 | 421 | # HTTP Content-Type header 422 | for param in headers.getplist(): 423 | if param.startswith('charset='): 424 | encoding = param[8:] 425 | break 426 | 427 | if not self.stream: # Try sniffing response content 428 | # Encoding declared in document should override HTTP headers 429 | if self.mimetype == 'text/html': # sniff HTML headers 430 | m = re.search("""""", 431 | self.content) 432 | if m: 433 | encoding = m.group(1) 434 | 435 | elif ((self.mimetype.startswith('application/') or 436 | self.mimetype.startswith('text/')) and 437 | 'xml' in self.mimetype): 438 | m = re.search("""]*\?>""", 439 | self.content) 440 | if m: 441 | encoding = m.group(1) 442 | 443 | # Format defaults 444 | if self.mimetype == 'application/json' and not encoding: 445 | # The default encoding for JSON 446 | encoding = 'utf-8' 447 | 448 | elif self.mimetype == 'application/xml' and not encoding: 449 | # The default for 'application/xml' 450 | encoding = 'utf-8' 451 | 452 | if encoding: 453 | encoding = encoding.lower() 454 | 455 | return encoding 456 | 457 | 458 | def request(method, url, params=None, data=None, headers=None, cookies=None, 459 | files=None, auth=None, timeout=60, allow_redirects=False, 460 | stream=False): 461 | """Initiate an HTTP(S) request. Returns :class:`Response` object. 462 | 463 | :param method: 'GET' or 'POST' 464 | :type method: unicode 465 | :param url: URL to open 466 | :type url: unicode 467 | :param params: mapping of URL parameters 468 | :type params: dict 469 | :param data: mapping of form data ``{'field_name': 'value'}`` or 470 | :class:`str` 471 | :type data: dict or str 472 | :param headers: HTTP headers 473 | :type headers: dict 474 | :param cookies: cookies to send to server 475 | :type cookies: dict 476 | :param files: files to upload (see below). 477 | :type files: dict 478 | :param auth: username, password 479 | :type auth: tuple 480 | :param timeout: connection timeout limit in seconds 481 | :type timeout: int 482 | :param allow_redirects: follow redirections 483 | :type allow_redirects: bool 484 | :param stream: Stream content instead of fetching it all at once. 485 | :type stream: bool 486 | :returns: Response object 487 | :rtype: :class:`Response` 488 | 489 | 490 | The ``files`` argument is a dictionary:: 491 | 492 | {'fieldname' : { 'filename': 'blah.txt', 493 | 'content': '', 494 | 'mimetype': 'text/plain'} 495 | } 496 | 497 | * ``fieldname`` is the name of the field in the HTML form. 498 | * ``mimetype`` is optional. If not provided, :mod:`mimetypes` will 499 | be used to guess the mimetype, or ``application/octet-stream`` 500 | will be used. 501 | 502 | """ 503 | # TODO: cookies 504 | socket.setdefaulttimeout(timeout) 505 | 506 | # Default handlers 507 | openers = [] 508 | 509 | if not allow_redirects: 510 | openers.append(NoRedirectHandler()) 511 | 512 | if auth is not None: # Add authorisation handler 513 | username, password = auth 514 | password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm() 515 | password_manager.add_password(None, url, username, password) 516 | auth_manager = urllib2.HTTPBasicAuthHandler(password_manager) 517 | openers.append(auth_manager) 518 | 519 | # Install our custom chain of openers 520 | opener = urllib2.build_opener(*openers) 521 | urllib2.install_opener(opener) 522 | 523 | if not headers: 524 | headers = CaseInsensitiveDictionary() 525 | else: 526 | headers = CaseInsensitiveDictionary(headers) 527 | 528 | if 'user-agent' not in headers: 529 | headers['user-agent'] = USER_AGENT 530 | 531 | # Accept gzip-encoded content 532 | encodings = [s.strip() for s in 533 | headers.get('accept-encoding', '').split(',')] 534 | if 'gzip' not in encodings: 535 | encodings.append('gzip') 536 | 537 | headers['accept-encoding'] = ', '.join(encodings) 538 | 539 | # Force POST by providing an empty data string 540 | if method == 'POST' and not data: 541 | data = '' 542 | 543 | if files: 544 | if not data: 545 | data = {} 546 | new_headers, data = encode_multipart_formdata(data, files) 547 | headers.update(new_headers) 548 | elif data and isinstance(data, dict): 549 | data = urllib.urlencode(str_dict(data)) 550 | 551 | # Make sure everything is encoded text 552 | headers = str_dict(headers) 553 | 554 | if isinstance(url, unicode): 555 | url = url.encode('utf-8') 556 | 557 | if params: # GET args (POST args are handled in encode_multipart_formdata) 558 | 559 | scheme, netloc, path, query, fragment = urlparse.urlsplit(url) 560 | 561 | if query: # Combine query string and `params` 562 | url_params = urlparse.parse_qs(query) 563 | # `params` take precedence over URL query string 564 | url_params.update(params) 565 | params = url_params 566 | 567 | query = urllib.urlencode(str_dict(params), doseq=True) 568 | url = urlparse.urlunsplit((scheme, netloc, path, query, fragment)) 569 | 570 | req = urllib2.Request(url, data, headers) 571 | return Response(req, stream) 572 | 573 | 574 | def get(url, params=None, headers=None, cookies=None, auth=None, 575 | timeout=60, allow_redirects=True, stream=False): 576 | """Initiate a GET request. Arguments as for :func:`request`. 577 | 578 | :returns: :class:`Response` instance 579 | 580 | """ 581 | return request('GET', url, params, headers=headers, cookies=cookies, 582 | auth=auth, timeout=timeout, allow_redirects=allow_redirects, 583 | stream=stream) 584 | 585 | 586 | def post(url, params=None, data=None, headers=None, cookies=None, files=None, 587 | auth=None, timeout=60, allow_redirects=False, stream=False): 588 | """Initiate a POST request. Arguments as for :func:`request`. 589 | 590 | :returns: :class:`Response` instance 591 | 592 | """ 593 | return request('POST', url, params, data, headers, cookies, files, auth, 594 | timeout, allow_redirects, stream) 595 | 596 | 597 | def encode_multipart_formdata(fields, files): 598 | """Encode form data (``fields``) and ``files`` for POST request. 599 | 600 | :param fields: mapping of ``{name : value}`` pairs for normal form fields. 601 | :type fields: dict 602 | :param files: dictionary of fieldnames/files elements for file data. 603 | See below for details. 604 | :type files: dict of :class:`dict` 605 | :returns: ``(headers, body)`` ``headers`` is a 606 | :class:`dict` of HTTP headers 607 | :rtype: 2-tuple ``(dict, str)`` 608 | 609 | The ``files`` argument is a dictionary:: 610 | 611 | {'fieldname' : { 'filename': 'blah.txt', 612 | 'content': '', 613 | 'mimetype': 'text/plain'} 614 | } 615 | 616 | - ``fieldname`` is the name of the field in the HTML form. 617 | - ``mimetype`` is optional. If not provided, :mod:`mimetypes` will 618 | be used to guess the mimetype, or ``application/octet-stream`` 619 | will be used. 620 | 621 | """ 622 | def get_content_type(filename): 623 | """Return or guess mimetype of ``filename``. 624 | 625 | :param filename: filename of file 626 | :type filename: unicode/str 627 | :returns: mime-type, e.g. ``text/html`` 628 | :rtype: str 629 | 630 | """ 631 | 632 | return mimetypes.guess_type(filename)[0] or 'application/octet-stream' 633 | 634 | boundary = '-----' + ''.join(random.choice(BOUNDARY_CHARS) 635 | for i in range(30)) 636 | CRLF = '\r\n' 637 | output = [] 638 | 639 | # Normal form fields 640 | for (name, value) in fields.items(): 641 | if isinstance(name, unicode): 642 | name = name.encode('utf-8') 643 | if isinstance(value, unicode): 644 | value = value.encode('utf-8') 645 | output.append('--' + boundary) 646 | output.append('Content-Disposition: form-data; name="%s"' % name) 647 | output.append('') 648 | output.append(value) 649 | 650 | # Files to upload 651 | for name, d in files.items(): 652 | filename = d[u'filename'] 653 | content = d[u'content'] 654 | if u'mimetype' in d: 655 | mimetype = d[u'mimetype'] 656 | else: 657 | mimetype = get_content_type(filename) 658 | if isinstance(name, unicode): 659 | name = name.encode('utf-8') 660 | if isinstance(filename, unicode): 661 | filename = filename.encode('utf-8') 662 | if isinstance(mimetype, unicode): 663 | mimetype = mimetype.encode('utf-8') 664 | output.append('--' + boundary) 665 | output.append('Content-Disposition: form-data; ' 666 | 'name="%s"; filename="%s"' % (name, filename)) 667 | output.append('Content-Type: %s' % mimetype) 668 | output.append('') 669 | output.append(content) 670 | 671 | output.append('--' + boundary + '--') 672 | output.append('') 673 | body = CRLF.join(output) 674 | headers = { 675 | 'Content-Type': 'multipart/form-data; boundary=%s' % boundary, 676 | 'Content-Length': str(len(body)), 677 | } 678 | return (headers, body) 679 | -------------------------------------------------------------------------------- /src/workflow/workflow3.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2016 Dean Jackson 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2016-06-25 8 | # 9 | 10 | """An Alfred 3-only version of :class:`~workflow.Workflow`. 11 | 12 | :class:`~workflow.Workflow3` supports Alfred 3's new features, such as 13 | setting :ref:`workflow-variables` and 14 | :class:`the more advanced modifiers ` supported by Alfred 3. 15 | 16 | In order for the feedback mechanism to work correctly, it's important 17 | to create :class:`Item3` and :class:`Modifier` objects via the 18 | :meth:`Workflow3.add_item()` and :meth:`Item3.add_modifier()` methods 19 | respectively. If you instantiate :class:`Item3` or :class:`Modifier` 20 | objects directly, the current :class:`Workflow3` object won't be aware 21 | of them, and they won't be sent to Alfred when you call 22 | :meth:`Workflow3.send_feedback()`. 23 | 24 | """ 25 | 26 | from __future__ import print_function, unicode_literals, absolute_import 27 | 28 | import json 29 | import os 30 | import sys 31 | 32 | from .workflow import ICON_WARNING, Workflow 33 | 34 | 35 | class Variables(dict): 36 | """Workflow variables for Run Script actions. 37 | 38 | .. versionadded: 1.26 39 | 40 | This class allows you to set workflow variables from 41 | Run Script actions. 42 | 43 | It is a subclass of :class:`dict`. 44 | 45 | >>> v = Variables(username='deanishe', password='hunter2') 46 | >>> v.arg = u'output value' 47 | >>> print(v) 48 | 49 | See :ref:`variables-run-script` in the User Guide for more 50 | information. 51 | 52 | Args: 53 | arg (unicode, optional): Main output/``{query}``. 54 | **variables: Workflow variables to set. 55 | 56 | 57 | Attributes: 58 | arg (unicode): Output value (``{query}``). 59 | config (dict): Configuration for downstream workflow element. 60 | 61 | """ 62 | 63 | def __init__(self, arg=None, **variables): 64 | """Create a new `Variables` object.""" 65 | self.arg = arg 66 | self.config = {} 67 | super(Variables, self).__init__(**variables) 68 | 69 | @property 70 | def obj(self): 71 | """Return ``alfredworkflow`` `dict`.""" 72 | o = {} 73 | if self: 74 | d2 = {} 75 | for k, v in self.items(): 76 | d2[k] = v 77 | o['variables'] = d2 78 | 79 | if self.config: 80 | o['config'] = self.config 81 | 82 | if self.arg is not None: 83 | o['arg'] = self.arg 84 | 85 | return {'alfredworkflow': o} 86 | 87 | def __unicode__(self): 88 | """Convert to ``alfredworkflow`` JSON object. 89 | 90 | Returns: 91 | unicode: ``alfredworkflow`` JSON object 92 | 93 | """ 94 | if not self and not self.config: 95 | if self.arg: 96 | return self.arg 97 | else: 98 | return u'' 99 | 100 | return json.dumps(self.obj) 101 | 102 | def __str__(self): 103 | """Convert to ``alfredworkflow`` JSON object. 104 | 105 | Returns: 106 | str: UTF-8 encoded ``alfredworkflow`` JSON object 107 | 108 | """ 109 | return unicode(self).encode('utf-8') 110 | 111 | 112 | class Modifier(object): 113 | """Modify :class:`Item3` arg/icon/variables when modifier key is pressed. 114 | 115 | Don't use this class directly (as it won't be associated with any 116 | :class:`Item3`), but rather use :meth:`Item3.add_modifier()` 117 | to add modifiers to results. 118 | 119 | >>> it = wf.add_item('Title', 'Subtitle', valid=True) 120 | >>> it.setvar('name', 'default') 121 | >>> m = it.add_modifier('cmd') 122 | >>> m.setvar('name', 'alternate') 123 | 124 | See :ref:`workflow-variables` in the User Guide for more information 125 | and :ref:`example usage `. 126 | 127 | Args: 128 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. 129 | subtitle (unicode, optional): Override default subtitle. 130 | arg (unicode, optional): Argument to pass for this modifier. 131 | valid (bool, optional): Override item's validity. 132 | icon (unicode, optional): Filepath/UTI of icon to use 133 | icontype (unicode, optional): Type of icon. See 134 | :meth:`Workflow.add_item() ` 135 | for valid values. 136 | 137 | Attributes: 138 | arg (unicode): Arg to pass to following action. 139 | config (dict): Configuration for a downstream element, such as 140 | a File Filter. 141 | icon (unicode): Filepath/UTI of icon. 142 | icontype (unicode): Type of icon. See 143 | :meth:`Workflow.add_item() ` 144 | for valid values. 145 | key (unicode): Modifier key (see above). 146 | subtitle (unicode): Override item subtitle. 147 | valid (bool): Override item validity. 148 | variables (dict): Workflow variables set by this modifier. 149 | 150 | """ 151 | 152 | def __init__(self, key, subtitle=None, arg=None, valid=None, icon=None, 153 | icontype=None): 154 | """Create a new :class:`Modifier`. 155 | 156 | Don't use this class directly (as it won't be associated with any 157 | :class:`Item3`), but rather use :meth:`Item3.add_modifier()` 158 | to add modifiers to results. 159 | 160 | Args: 161 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. 162 | subtitle (unicode, optional): Override default subtitle. 163 | arg (unicode, optional): Argument to pass for this modifier. 164 | valid (bool, optional): Override item's validity. 165 | icon (unicode, optional): Filepath/UTI of icon to use 166 | icontype (unicode, optional): Type of icon. See 167 | :meth:`Workflow.add_item() ` 168 | for valid values. 169 | 170 | """ 171 | self.key = key 172 | self.subtitle = subtitle 173 | self.arg = arg 174 | self.valid = valid 175 | self.icon = icon 176 | self.icontype = icontype 177 | 178 | self.config = {} 179 | self.variables = {} 180 | 181 | def setvar(self, name, value): 182 | """Set a workflow variable for this Item. 183 | 184 | Args: 185 | name (unicode): Name of variable. 186 | value (unicode): Value of variable. 187 | 188 | """ 189 | self.variables[name] = value 190 | 191 | def getvar(self, name, default=None): 192 | """Return value of workflow variable for ``name`` or ``default``. 193 | 194 | Args: 195 | name (unicode): Variable name. 196 | default (None, optional): Value to return if variable is unset. 197 | 198 | Returns: 199 | unicode or ``default``: Value of variable if set or ``default``. 200 | 201 | """ 202 | return self.variables.get(name, default) 203 | 204 | @property 205 | def obj(self): 206 | """Modifier formatted for JSON serialization for Alfred 3. 207 | 208 | Returns: 209 | dict: Modifier for serializing to JSON. 210 | 211 | """ 212 | o = {} 213 | 214 | if self.subtitle is not None: 215 | o['subtitle'] = self.subtitle 216 | 217 | if self.arg is not None: 218 | o['arg'] = self.arg 219 | 220 | if self.valid is not None: 221 | o['valid'] = self.valid 222 | 223 | if self.variables: 224 | o['variables'] = self.variables 225 | 226 | if self.config: 227 | o['config'] = self.config 228 | 229 | icon = self._icon() 230 | if icon: 231 | o['icon'] = icon 232 | 233 | return o 234 | 235 | def _icon(self): 236 | """Return `icon` object for item. 237 | 238 | Returns: 239 | dict: Mapping for item `icon` (may be empty). 240 | 241 | """ 242 | icon = {} 243 | if self.icon is not None: 244 | icon['path'] = self.icon 245 | 246 | if self.icontype is not None: 247 | icon['type'] = self.icontype 248 | 249 | return icon 250 | 251 | 252 | class Item3(object): 253 | """Represents a feedback item for Alfred 3. 254 | 255 | Generates Alfred-compliant JSON for a single item. 256 | 257 | Don't use this class directly (as it then won't be associated with 258 | any :class:`Workflow3 ` object), but rather use 259 | :meth:`Workflow3.add_item() `. 260 | See :meth:`~workflow.Workflow3.add_item` for details of arguments. 261 | 262 | """ 263 | 264 | def __init__(self, title, subtitle='', arg=None, autocomplete=None, 265 | match=None, valid=False, uid=None, icon=None, icontype=None, 266 | type=None, largetext=None, copytext=None, quicklookurl=None): 267 | """Create a new :class:`Item3` object. 268 | 269 | Use same arguments as for 270 | :class:`Workflow.Item `. 271 | 272 | Argument ``subtitle_modifiers`` is not supported. 273 | 274 | """ 275 | self.title = title 276 | self.subtitle = subtitle 277 | self.arg = arg 278 | self.autocomplete = autocomplete 279 | self.match = match 280 | self.valid = valid 281 | self.uid = uid 282 | self.icon = icon 283 | self.icontype = icontype 284 | self.type = type 285 | self.quicklookurl = quicklookurl 286 | self.largetext = largetext 287 | self.copytext = copytext 288 | 289 | self.modifiers = {} 290 | 291 | self.config = {} 292 | self.variables = {} 293 | 294 | def setvar(self, name, value): 295 | """Set a workflow variable for this Item. 296 | 297 | Args: 298 | name (unicode): Name of variable. 299 | value (unicode): Value of variable. 300 | 301 | """ 302 | self.variables[name] = value 303 | 304 | def getvar(self, name, default=None): 305 | """Return value of workflow variable for ``name`` or ``default``. 306 | 307 | Args: 308 | name (unicode): Variable name. 309 | default (None, optional): Value to return if variable is unset. 310 | 311 | Returns: 312 | unicode or ``default``: Value of variable if set or ``default``. 313 | 314 | """ 315 | return self.variables.get(name, default) 316 | 317 | def add_modifier(self, key, subtitle=None, arg=None, valid=None, icon=None, 318 | icontype=None): 319 | """Add alternative values for a modifier key. 320 | 321 | Args: 322 | key (unicode): Modifier key, e.g. ``"cmd"`` or ``"alt"`` 323 | subtitle (unicode, optional): Override item subtitle. 324 | arg (unicode, optional): Input for following action. 325 | valid (bool, optional): Override item validity. 326 | icon (unicode, optional): Filepath/UTI of icon. 327 | icontype (unicode, optional): Type of icon. See 328 | :meth:`Workflow.add_item() ` 329 | for valid values. 330 | 331 | Returns: 332 | Modifier: Configured :class:`Modifier`. 333 | 334 | """ 335 | mod = Modifier(key, subtitle, arg, valid, icon, icontype) 336 | 337 | # Add Item variables to Modifier 338 | mod.variables.update(self.variables) 339 | 340 | self.modifiers[key] = mod 341 | 342 | return mod 343 | 344 | @property 345 | def obj(self): 346 | """Item formatted for JSON serialization. 347 | 348 | Returns: 349 | dict: Data suitable for Alfred 3 feedback. 350 | 351 | """ 352 | # Required values 353 | o = { 354 | 'title': self.title, 355 | 'subtitle': self.subtitle, 356 | 'valid': self.valid, 357 | } 358 | 359 | # Optional values 360 | if self.arg is not None: 361 | o['arg'] = self.arg 362 | 363 | if self.autocomplete is not None: 364 | o['autocomplete'] = self.autocomplete 365 | 366 | if self.match is not None: 367 | o['match'] = self.match 368 | 369 | if self.uid is not None: 370 | o['uid'] = self.uid 371 | 372 | if self.type is not None: 373 | o['type'] = self.type 374 | 375 | if self.quicklookurl is not None: 376 | o['quicklookurl'] = self.quicklookurl 377 | 378 | if self.variables: 379 | o['variables'] = self.variables 380 | 381 | if self.config: 382 | o['config'] = self.config 383 | 384 | # Largetype and copytext 385 | text = self._text() 386 | if text: 387 | o['text'] = text 388 | 389 | icon = self._icon() 390 | if icon: 391 | o['icon'] = icon 392 | 393 | # Modifiers 394 | mods = self._modifiers() 395 | if mods: 396 | o['mods'] = mods 397 | 398 | return o 399 | 400 | def _icon(self): 401 | """Return `icon` object for item. 402 | 403 | Returns: 404 | dict: Mapping for item `icon` (may be empty). 405 | 406 | """ 407 | icon = {} 408 | if self.icon is not None: 409 | icon['path'] = self.icon 410 | 411 | if self.icontype is not None: 412 | icon['type'] = self.icontype 413 | 414 | return icon 415 | 416 | def _text(self): 417 | """Return `largetext` and `copytext` object for item. 418 | 419 | Returns: 420 | dict: `text` mapping (may be empty) 421 | 422 | """ 423 | text = {} 424 | if self.largetext is not None: 425 | text['largetype'] = self.largetext 426 | 427 | if self.copytext is not None: 428 | text['copy'] = self.copytext 429 | 430 | return text 431 | 432 | def _modifiers(self): 433 | """Build `mods` dictionary for JSON feedback. 434 | 435 | Returns: 436 | dict: Modifier mapping or `None`. 437 | 438 | """ 439 | if self.modifiers: 440 | mods = {} 441 | for k, mod in self.modifiers.items(): 442 | mods[k] = mod.obj 443 | 444 | return mods 445 | 446 | return None 447 | 448 | 449 | class Workflow3(Workflow): 450 | """Workflow class that generates Alfred 3 feedback. 451 | 452 | It is a subclass of :class:`~workflow.Workflow` and most of its 453 | methods are documented there. 454 | 455 | Attributes: 456 | item_class (class): Class used to generate feedback items. 457 | variables (dict): Top level workflow variables. 458 | 459 | """ 460 | 461 | item_class = Item3 462 | 463 | def __init__(self, **kwargs): 464 | """Create a new :class:`Workflow3` object. 465 | 466 | See :class:`~workflow.Workflow` for documentation. 467 | 468 | """ 469 | Workflow.__init__(self, **kwargs) 470 | self.variables = {} 471 | self._rerun = 0 472 | # Get session ID from environment if present 473 | self._session_id = os.getenv('_WF_SESSION_ID') or None 474 | if self._session_id: 475 | self.setvar('_WF_SESSION_ID', self._session_id) 476 | 477 | @property 478 | def _default_cachedir(self): 479 | """Alfred 3's default cache directory.""" 480 | return os.path.join( 481 | os.path.expanduser( 482 | '~/Library/Caches/com.runningwithcrayons.Alfred-3/' 483 | 'Workflow Data/'), 484 | self.bundleid) 485 | 486 | @property 487 | def _default_datadir(self): 488 | """Alfred 3's default data directory.""" 489 | return os.path.join(os.path.expanduser( 490 | '~/Library/Application Support/Alfred 3/Workflow Data/'), 491 | self.bundleid) 492 | 493 | @property 494 | def rerun(self): 495 | """How often (in seconds) Alfred should re-run the Script Filter.""" 496 | return self._rerun 497 | 498 | @rerun.setter 499 | def rerun(self, seconds): 500 | """Interval at which Alfred should re-run the Script Filter. 501 | 502 | Args: 503 | seconds (int): Interval between runs. 504 | """ 505 | self._rerun = seconds 506 | 507 | @property 508 | def session_id(self): 509 | """A unique session ID every time the user uses the workflow. 510 | 511 | .. versionadded:: 1.25 512 | 513 | The session ID persists while the user is using this workflow. 514 | It expires when the user runs a different workflow or closes 515 | Alfred. 516 | 517 | """ 518 | if not self._session_id: 519 | from uuid import uuid4 520 | self._session_id = uuid4().hex 521 | self.setvar('_WF_SESSION_ID', self._session_id) 522 | 523 | return self._session_id 524 | 525 | def setvar(self, name, value): 526 | """Set a "global" workflow variable. 527 | 528 | These variables are always passed to downstream workflow objects. 529 | 530 | If you have set :attr:`rerun`, these variables are also passed 531 | back to the script when Alfred runs it again. 532 | 533 | Args: 534 | name (unicode): Name of variable. 535 | value (unicode): Value of variable. 536 | 537 | """ 538 | self.variables[name] = value 539 | 540 | def getvar(self, name, default=None): 541 | """Return value of workflow variable for ``name`` or ``default``. 542 | 543 | Args: 544 | name (unicode): Variable name. 545 | default (None, optional): Value to return if variable is unset. 546 | 547 | Returns: 548 | unicode or ``default``: Value of variable if set or ``default``. 549 | 550 | """ 551 | return self.variables.get(name, default) 552 | 553 | def add_item(self, title, subtitle='', arg=None, autocomplete=None, 554 | valid=False, uid=None, icon=None, icontype=None, type=None, 555 | largetext=None, copytext=None, quicklookurl=None, match=None): 556 | """Add an item to be output to Alfred. 557 | 558 | Args: 559 | match (unicode, optional): If you have "Alfred filters results" 560 | turned on for your Script Filter, Alfred (version 3.5 and 561 | above) will filter against this field, not ``title``. 562 | 563 | See :meth:`Workflow.add_item() ` for 564 | the main documentation and other parameters. 565 | 566 | The key difference is that this method does not support the 567 | ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()` 568 | method instead on the returned item instead. 569 | 570 | Returns: 571 | Item3: Alfred feedback item. 572 | 573 | """ 574 | item = self.item_class(title, subtitle, arg, autocomplete, 575 | match, valid, uid, icon, icontype, type, 576 | largetext, copytext, quicklookurl) 577 | 578 | # Add variables to child item 579 | item.variables.update(self.variables) 580 | 581 | self._items.append(item) 582 | return item 583 | 584 | @property 585 | def _session_prefix(self): 586 | """Filename prefix for current session.""" 587 | return '_wfsess-{0}-'.format(self.session_id) 588 | 589 | def _mk_session_name(self, name): 590 | """New cache name/key based on session ID.""" 591 | return self._session_prefix + name 592 | 593 | def cache_data(self, name, data, session=False): 594 | """Cache API with session-scoped expiry. 595 | 596 | .. versionadded:: 1.25 597 | 598 | Args: 599 | name (str): Cache key 600 | data (object): Data to cache 601 | session (bool, optional): Whether to scope the cache 602 | to the current session. 603 | 604 | ``name`` and ``data`` are the same as for the 605 | :meth:`~workflow.Workflow.cache_data` method on 606 | :class:`~workflow.Workflow`. 607 | 608 | If ``session`` is ``True``, then ``name`` is prefixed 609 | with :attr:`session_id`. 610 | 611 | """ 612 | if session: 613 | name = self._mk_session_name(name) 614 | 615 | return super(Workflow3, self).cache_data(name, data) 616 | 617 | def cached_data(self, name, data_func=None, max_age=60, session=False): 618 | """Cache API with session-scoped expiry. 619 | 620 | .. versionadded:: 1.25 621 | 622 | Args: 623 | name (str): Cache key 624 | data_func (callable): Callable that returns fresh data. It 625 | is called if the cache has expired or doesn't exist. 626 | max_age (int): Maximum allowable age of cache in seconds. 627 | session (bool, optional): Whether to scope the cache 628 | to the current session. 629 | 630 | ``name``, ``data_func`` and ``max_age`` are the same as for the 631 | :meth:`~workflow.Workflow.cached_data` method on 632 | :class:`~workflow.Workflow`. 633 | 634 | If ``session`` is ``True``, then ``name`` is prefixed 635 | with :attr:`session_id`. 636 | 637 | """ 638 | if session: 639 | name = self._mk_session_name(name) 640 | 641 | return super(Workflow3, self).cached_data(name, data_func, max_age) 642 | 643 | def clear_session_cache(self, current=False): 644 | """Remove session data from the cache. 645 | 646 | .. versionadded:: 1.25 647 | .. versionchanged:: 1.27 648 | 649 | By default, data belonging to the current session won't be 650 | deleted. Set ``current=True`` to also clear current session. 651 | 652 | Args: 653 | current (bool, optional): If ``True``, also remove data for 654 | current session. 655 | 656 | """ 657 | def _is_session_file(filename): 658 | if current: 659 | return filename.startswith('_wfsess-') 660 | return filename.startswith('_wfsess-') \ 661 | and not filename.startswith(self._session_prefix) 662 | 663 | self.clear_cache(_is_session_file) 664 | 665 | @property 666 | def obj(self): 667 | """Feedback formatted for JSON serialization. 668 | 669 | Returns: 670 | dict: Data suitable for Alfred 3 feedback. 671 | 672 | """ 673 | items = [] 674 | for item in self._items: 675 | items.append(item.obj) 676 | 677 | o = {'items': items} 678 | if self.variables: 679 | o['variables'] = self.variables 680 | if self.rerun: 681 | o['rerun'] = self.rerun 682 | return o 683 | 684 | def warn_empty(self, title, subtitle=u'', icon=None): 685 | """Add a warning to feedback if there are no items. 686 | 687 | .. versionadded:: 1.31 688 | 689 | Add a "warning" item to Alfred feedback if no other items 690 | have been added. This is a handy shortcut to prevent Alfred 691 | from showing its fallback searches, which is does if no 692 | items are returned. 693 | 694 | Args: 695 | title (unicode): Title of feedback item. 696 | subtitle (unicode, optional): Subtitle of feedback item. 697 | icon (str, optional): Icon for feedback item. If not 698 | specified, ``ICON_WARNING`` is used. 699 | 700 | Returns: 701 | Item3: Newly-created item. 702 | """ 703 | if len(self._items): 704 | return 705 | 706 | icon = icon or ICON_WARNING 707 | return self.add_item(title, subtitle, icon=icon) 708 | 709 | def send_feedback(self): 710 | """Print stored items to console/Alfred as JSON.""" 711 | json.dump(self.obj, sys.stdout) 712 | sys.stdout.flush() 713 | --------------------------------------------------------------------------------