├── LICENSE ├── README.md ├── ScreenShots ├── alfred_query.png ├── alfred_search.gif └── alfred_send.png ├── WeChat Plugin.alfredworkflow └── src ├── 7DECB235-5455-435D-90AF-1A51C26046DD.png ├── convert.py ├── icon.png ├── info.plist ├── openSession.py ├── search.py ├── sendMsg.py ├── userChatLog.py ├── version ├── web.py └── workflow ├── .alfredversionchecked ├── Notify.tgz ├── __init__.py ├── __init__.pyc ├── background.py ├── notify.py ├── update.py ├── util.py ├── version ├── workflow.py └── workflow3.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 TK 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ## wechat workflow for Alfred 3.0 3 | 4 | ![platform](https://img.shields.io/badge/platform-macos-lightgrey.svg) ![language](https://img.shields.io/badge/language-python-blue.svg) 5 | ![release](https://img.shields.io/badge/release-v2.0-brightgreen.svg) 6 | [![GitHub license](https://img.shields.io/github/license/TKkk-iOSer/wechat-workflow.svg)](https://github.com/TKkk-iOSer/wechat-workflow/blob/master/LICENSE) 7 | 8 | 一款让你不用打开微信就能聊天的`alfred workflow` 3.0 9 | 10 | --- 11 | 12 | ### 安装 13 | 14 | * 下载 WeChat Plugin.alfredworkflow 15 | * 安装requests:pip install requests 16 | * 打开微信-菜单栏-微信小助手-小助手-开启alfred功能 17 | 18 | ### 功能 19 | 20 | * 快速搜索微信好友、群聊 21 | * 快捷发送消息 22 | * 快捷打开聊天窗口 23 | * 支持搜索好友,匹配昵称、备注、微信号、国家、省份、市。 24 | * 支持搜索群聊,匹配群聊昵称、群成员昵称、群成员备注名、群成员微信号 25 | * 支持复制微信号、聊天内容(**2.0**) 26 | * 聊天记录界面支持快捷打开微信聊天窗口(`Command + ↩︎`)(**2.0**) 27 | * 支持预览长文本消息(`Command + L`)(**2.0**) 28 | * 支持查看好友高清头像(`Command + Y`或者`shift`)(**2.0**) 29 | * 支持预览视频、表情、图片、网址等消息(`Command + Y`或者`shift`)(**2.0**) 30 | * 支持播放音频消息(直接选中音频消息回车)(**2.0**) 31 | * 聊天记录显示发送时间(**2.0**) 32 | * 默认打开最近聊天记录 & 获取聊天内容(**2.0**) 33 | * 支持python3(**3.0**) 34 | 35 | --- 36 | 37 | ### Demo 演示 38 | 39 | ![alfred](./ScreenShots/alfred_search.gif) 40 | 41 | ~~懒得录制 gif 图了,自己看[功能](#功能) 或者 [使用](#使用) 或者 `Alfred` 中的提示~~ 42 | 43 | --- 44 | 45 | ### 使用 46 | * 下载该 [wechat-alfred-workflow](https://github.com/TKkk-iOSer/wechat-alfred-workflow/releases) & [WeChatPlugin-macOS](https://github.com/TKkk-iOSer/WeChatPlugin-MacOS) 47 | 48 | * 启动`Alfred`,输入`wx`启动该 workflow。 49 | 50 | * 启动`Alfred`,输入 `wx` + `空格` 键,快捷打开最近聊天会话。 51 | 52 | ![keyword](./ScreenShots/alfred_query.png) 53 | 54 | * 搜索到好友,点击 `Enter` 键,并输入内容,则发送消息给好友(此时可看到下方30条最新聊天记录)。 55 | 56 | ![keyword](./ScreenShots/alfred_send.png) 57 | 58 | * 搜索到好友(或者发送消息界面),点击 `Command + Enter` 键,快捷打开聊天窗口。 59 | * 选中好友(聊天记录),点击 `Command + C` 键,复制好友微信号(聊天内容)。 60 | * 选中聊天消息,`Command + L`预览长文本消息。 61 | * 选中好友,`Command + Y`(`shift`)查看好友高清头像。 62 | * 选中视频、表情、图片、网址等消息`Command + Y`(`shift`)预览非文本内容。 63 | * 选中音频消息,在未输入发送内容时回车,可播放音频。 64 | 65 | --- 66 | 67 | ### 依赖 68 | 69 | * [Alfred-Workflow](http://www.deanishe.net/alfred-workflow/index.html) 70 | 71 | --- 72 | 73 | #### 听说你想请我喝下午茶?😏 74 | 75 |     76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /ScreenShots/alfred_query.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TKkk-iOSer/wechat-alfred-workflow/4d44698fe7f507d8c029c5b3d8987689e580db72/ScreenShots/alfred_query.png -------------------------------------------------------------------------------- /ScreenShots/alfred_search.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TKkk-iOSer/wechat-alfred-workflow/4d44698fe7f507d8c029c5b3d8987689e580db72/ScreenShots/alfred_search.gif -------------------------------------------------------------------------------- /ScreenShots/alfred_send.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TKkk-iOSer/wechat-alfred-workflow/4d44698fe7f507d8c029c5b3d8987689e580db72/ScreenShots/alfred_send.png -------------------------------------------------------------------------------- /WeChat Plugin.alfredworkflow: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TKkk-iOSer/wechat-alfred-workflow/4d44698fe7f507d8c029c5b3d8987689e580db72/WeChat Plugin.alfredworkflow -------------------------------------------------------------------------------- /src/7DECB235-5455-435D-90AF-1A51C26046DD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TKkk-iOSer/wechat-alfred-workflow/4d44698fe7f507d8c029c5b3d8987689e580db72/src/7DECB235-5455-435D-90AF-1A51C26046DD.png -------------------------------------------------------------------------------- /src/convert.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | import sys,os 3 | from workflow import Workflow3 4 | 5 | # reload(sys) 6 | # sys.setdefaultencoding('utf-8') 7 | 8 | def main(wf): 9 | userId = os.getenv('userId') 10 | data = wf.stored_data('wechat_search_user_list') 11 | for item in data: 12 | if item['userId'] == userId: 13 | title = 'To:'+item['title'] 14 | subTitle = item['subTitle'] 15 | icon = item['icon'] 16 | wf.add_item(title=title, subtitle=subTitle, icon=icon, valid=True, arg=sys.argv[1]) 17 | wf.send_feedback() 18 | if __name__ == '__main__': 19 | wf = Workflow3() 20 | sys.exit(wf.run(main)) 21 | -------------------------------------------------------------------------------- /src/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TKkk-iOSer/wechat-alfred-workflow/4d44698fe7f507d8c029c5b3d8987689e580db72/src/icon.png -------------------------------------------------------------------------------- /src/info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | tkkk.wechatPlugin 7 | category 8 | Tools 9 | connections 10 | 11 | 0D88BE16-D0D5-4489-B54E-9C3A9C7FB187 12 | 13 | 14 | destinationuid 15 | 951BA0D4-BF57-48C9-B977-D9A7C8A398A1 16 | modifiers 17 | 1048576 18 | modifiersubtext 19 | ↩: 打开聊天窗口; C: 复制消息内容; L: 查看消息详情; Y: 预览非文本信息 20 | vitoclose 21 | 22 | 23 | 24 | destinationuid 25 | EBC34E72-65EE-4D6A-8E7C-BA6D06F4A3A7 26 | modifiers 27 | 0 28 | modifiersubtext 29 | 30 | vitoclose 31 | 32 | 33 | 34 | 7DECB235-5455-435D-90AF-1A51C26046DD 35 | 36 | 37 | destinationuid 38 | C7F97CC1-A708-4992-9A14-DBCE8FED26BE 39 | modifiers 40 | 1048576 41 | modifiersubtext 42 | ↩: 打开聊天窗口; C: 复制微信号 43 | vitoclose 44 | 45 | 46 | 47 | destinationuid 48 | 807CF904-8DD6-45B7-B6FE-B0D7C190CC83 49 | modifiers 50 | 0 51 | modifiersubtext 52 | 53 | vitoclose 54 | 55 | 56 | 57 | 807CF904-8DD6-45B7-B6FE-B0D7C190CC83 58 | 59 | 60 | destinationuid 61 | 0D88BE16-D0D5-4489-B54E-9C3A9C7FB187 62 | modifiers 63 | 0 64 | modifiersubtext 65 | 66 | vitoclose 67 | 68 | 69 | 70 | 951BA0D4-BF57-48C9-B977-D9A7C8A398A1 71 | 72 | 73 | destinationuid 74 | DF9DFCC2-CC36-4945-9B04-E02F397E7E7D 75 | modifiers 76 | 0 77 | modifiersubtext 78 | 79 | vitoclose 80 | 81 | 82 | 83 | C06A928C-83DB-4998-A6C9-04DDE70705A3 84 | 85 | 86 | destinationuid 87 | 7DECB235-5455-435D-90AF-1A51C26046DD 88 | modifiers 89 | 0 90 | modifiersubtext 91 | 92 | vitoclose 93 | 94 | 95 | 96 | C7F97CC1-A708-4992-9A14-DBCE8FED26BE 97 | 98 | 99 | destinationuid 100 | 951BA0D4-BF57-48C9-B977-D9A7C8A398A1 101 | modifiers 102 | 0 103 | modifiersubtext 104 | 105 | vitoclose 106 | 107 | 108 | 109 | EBC34E72-65EE-4D6A-8E7C-BA6D06F4A3A7 110 | 111 | 112 | createdby 113 | TKkk 114 | description 115 | 快速搜索好友(群聊)、发送消息 116 | disabled 117 | 118 | name 119 | WeChat Plugin 120 | objects 121 | 122 | 123 | config 124 | 125 | concurrently 126 | 127 | escaping 128 | 127 129 | script 130 | python3 openSession.py {query} 131 | scriptargtype 132 | 0 133 | scriptfile 134 | 135 | type 136 | 0 137 | 138 | type 139 | alfred.workflow.action.script 140 | uid 141 | 951BA0D4-BF57-48C9-B977-D9A7C8A398A1 142 | version 143 | 2 144 | 145 | 146 | config 147 | 148 | paths 149 | 150 | /Applications/WeChat.app 151 | 152 | toggle 153 | 154 | 155 | type 156 | alfred.workflow.action.launchfiles 157 | uid 158 | DF9DFCC2-CC36-4945-9B04-E02F397E7E7D 159 | version 160 | 1 161 | 162 | 163 | config 164 | 165 | json 166 | { 167 | "alfredworkflow" : { 168 | "arg" : "", 169 | "config" : { 170 | }, 171 | "variables" : { 172 | "userId" : "{query}", 173 | } 174 | } 175 | } 176 | 177 | type 178 | alfred.workflow.utility.json 179 | uid 180 | C7F97CC1-A708-4992-9A14-DBCE8FED26BE 181 | version 182 | 1 183 | 184 | 185 | config 186 | 187 | alfredfiltersresults 188 | 189 | alfredfiltersresultsmatchmode 190 | 0 191 | argumenttreatemptyqueryasnil 192 | 193 | argumenttrimmode 194 | 1 195 | argumenttype 196 | 1 197 | escaping 198 | 102 199 | keyword 200 | wx 201 | queuedelaycustom 202 | 3 203 | queuedelayimmediatelyinitially 204 | 205 | queuedelaymode 206 | 0 207 | queuemode 208 | 1 209 | runningsubtext 210 | 搜索中… 211 | script 212 | python3 search.py "{query}" 213 | scriptargtype 214 | 0 215 | scriptfile 216 | 217 | subtext 218 | ↩: 快捷发送消息;⌘ + ↩:快捷打开窗口; ⌘ + C: 复制微信号 219 | title 220 | 微信小助手 221 | type 222 | 0 223 | withspace 224 | 225 | 226 | type 227 | alfred.workflow.input.scriptfilter 228 | uid 229 | 7DECB235-5455-435D-90AF-1A51C26046DD 230 | version 231 | 3 232 | 233 | 234 | config 235 | 236 | action 237 | 0 238 | argument 239 | 0 240 | focusedappvariable 241 | 242 | focusedappvariablename 243 | 244 | hotkey 245 | -1 246 | hotmod 247 | 524288 248 | hotstring 249 | double tap 250 | leftcursor 251 | 252 | modsmode 253 | 0 254 | relatedAppsMode 255 | 0 256 | 257 | type 258 | alfred.workflow.trigger.hotkey 259 | uid 260 | C06A928C-83DB-4998-A6C9-04DDE70705A3 261 | version 262 | 2 263 | 264 | 265 | config 266 | 267 | alfredfiltersresults 268 | 269 | alfredfiltersresultsmatchmode 270 | 0 271 | argumenttreatemptyqueryasnil 272 | 273 | argumenttrimmode 274 | 0 275 | argumenttype 276 | 1 277 | escaping 278 | 102 279 | queuedelaycustom 280 | 3 281 | queuedelayimmediatelyinitially 282 | 283 | queuedelaymode 284 | 0 285 | queuemode 286 | 1 287 | runningsubtext 288 | 正在搜索中… 289 | script 290 | python3 userChatLog.py "{query}" 291 | scriptargtype 292 | 0 293 | scriptfile 294 | 295 | subtext 296 | 297 | title 298 | 299 | type 300 | 0 301 | withspace 302 | 303 | 304 | type 305 | alfred.workflow.input.scriptfilter 306 | uid 307 | 0D88BE16-D0D5-4489-B54E-9C3A9C7FB187 308 | version 309 | 3 310 | 311 | 312 | config 313 | 314 | concurrently 315 | 316 | escaping 317 | 102 318 | script 319 | python3 sendMsg.py "{query}" 320 | scriptargtype 321 | 0 322 | scriptfile 323 | 324 | type 325 | 0 326 | 327 | type 328 | alfred.workflow.action.script 329 | uid 330 | EBC34E72-65EE-4D6A-8E7C-BA6D06F4A3A7 331 | version 332 | 2 333 | 334 | 335 | config 336 | 337 | json 338 | { 339 | "alfredworkflow" : { 340 | "arg" : "", 341 | "config" : { 342 | }, 343 | "variables" : { 344 | "userId" : "{query}", 345 | } 346 | } 347 | } 348 | 349 | type 350 | alfred.workflow.utility.json 351 | uid 352 | 807CF904-8DD6-45B7-B6FE-B0D7C190CC83 353 | version 354 | 1 355 | 356 | 357 | readme 358 | 配合微信插件 359 | 支持python3 360 | uidata 361 | 362 | 0D88BE16-D0D5-4489-B54E-9C3A9C7FB187 363 | 364 | xpos 365 | 510 366 | ypos 367 | 190 368 | 369 | 7DECB235-5455-435D-90AF-1A51C26046DD 370 | 371 | xpos 372 | 220 373 | ypos 374 | 100 375 | 376 | 807CF904-8DD6-45B7-B6FE-B0D7C190CC83 377 | 378 | xpos 379 | 430 380 | ypos 381 | 220 382 | 383 | 951BA0D4-BF57-48C9-B977-D9A7C8A398A1 384 | 385 | xpos 386 | 730 387 | ypos 388 | 10 389 | 390 | C06A928C-83DB-4998-A6C9-04DDE70705A3 391 | 392 | xpos 393 | 40 394 | ypos 395 | 100 396 | 397 | C7F97CC1-A708-4992-9A14-DBCE8FED26BE 398 | 399 | xpos 400 | 430 401 | ypos 402 | 40 403 | 404 | DF9DFCC2-CC36-4945-9B04-E02F397E7E7D 405 | 406 | xpos 407 | 930 408 | ypos 409 | 10 410 | 411 | EBC34E72-65EE-4D6A-8E7C-BA6D06F4A3A7 412 | 413 | xpos 414 | 860 415 | ypos 416 | 190 417 | 418 | 419 | variables 420 | 421 | baseUrl 422 | http://127.0.0.1:57270/wechat-plugin/ 423 | 424 | variablesdontexport 425 | 426 | version 427 | 3.0 428 | webaddress 429 | https://github.com/TKkk-iOSer 430 | 431 | 432 | -------------------------------------------------------------------------------- /src/openSession.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | import json,sys,os 3 | from workflow import Workflow 4 | import web 5 | 6 | def main(wf): 7 | srvId = 0 8 | if len(sys.argv) > 1: 9 | srvId = sys.argv[1] 10 | pass 11 | baseUrl = os.getenv('baseUrl') 12 | userId = os.getenv('userId') 13 | url = baseUrl + 'open-session' 14 | data = {'userId': userId, 'srvId': srvId} 15 | r = web.post(url=url,data=data) 16 | r.raise_for_status() 17 | wf.send_feedback() 18 | 19 | if __name__ == '__main__': 20 | wf = Workflow() 21 | sys.exit(wf.run(main)) 22 | -------------------------------------------------------------------------------- /src/search.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | import json,sys,os 3 | from workflow import Workflow 4 | import web 5 | 6 | def main(wf): 7 | query = sys.argv[1] 8 | baseUrl = os.getenv('baseUrl') 9 | url = baseUrl + 'user?keyword=' + query 10 | try: 11 | userList = web.get(url) 12 | if len(userList) > 0: 13 | for item in userList: 14 | title = item['title'] 15 | subtitle = item['subTitle'] 16 | icon = item['icon'] 17 | userId = item['userId'] 18 | copyText = item['copyText'] 19 | qlurl = item['url'] 20 | if 'unReadCount' in item: 21 | unReadCount = item['unReadCount'] 22 | if unReadCount > 0: 23 | title = title + ' (未读: ' + str(unReadCount) + ')' 24 | wf.add_item(title=title, subtitle=subtitle, icon=icon, largetext=title, copytext=copyText, quicklookurl=qlurl, arg=userId, valid=True) 25 | else: 26 | wf.add_item(title='找不到联系人…',subtitle='请重新输入') 27 | except IOError: 28 | wf.add_item(title='请先启动微信 & 登录…',subtitle='并确保安装微信小助手') 29 | wf.send_feedback() 30 | 31 | if __name__ == '__main__': 32 | wf = Workflow() 33 | sys.exit(wf.run(main)) 34 | -------------------------------------------------------------------------------- /src/sendMsg.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | import sys,os 3 | from workflow import Workflow 4 | import web 5 | 6 | def main(wf): 7 | srvId = sys.argv[1] 8 | userId = os.getenv('userId') 9 | baseUrl = os.getenv('baseUrl') 10 | msgContent = wf.stored_data('wechat_send_content') 11 | 12 | url = baseUrl + 'send-message' 13 | data = {'userId':userId, 'content': msgContent, 'srvId': srvId} 14 | r = web.post(url=url,data=data) 15 | wf.send_feedback() 16 | 17 | if __name__ == '__main__': 18 | wf = Workflow() 19 | sys.exit(wf.run(main)) 20 | -------------------------------------------------------------------------------- /src/userChatLog.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | import json,sys,os 3 | from workflow import Workflow 4 | import web 5 | 6 | 7 | def main(wf): 8 | query = sys.argv[1].encode() 9 | userId = os.getenv('userId') 10 | baseUrl = os.getenv('baseUrl') 11 | url = baseUrl + 'chatlog?userId=' + userId + '&count=60' 12 | try: 13 | userList = web.get(url=url) 14 | if len(userList) > 0: 15 | wf.store_data('wechat_send_content',query) 16 | for item in userList: 17 | title = item['title'] 18 | subtitle = item['subTitle'] 19 | icon = item['icon'] 20 | userId = item['userId'] 21 | copyText = item['copyText'] 22 | qlurl = item['url'] 23 | srvId = str(item['srvId']) 24 | l = len(title) 25 | lineNun = 70 26 | # if l < lineNun: 27 | largetext = title 28 | # else: 29 | # b = [] 30 | # for n in range(l): 31 | # if n % lineNun == 0: 32 | # b.append(title[n:n+lineNun]) 33 | # largetext='\n'.join(b) 34 | wf.add_item(title=title, subtitle=subtitle, icon=icon, valid=True, largetext=largetext, quicklookurl=qlurl, copytext=copyText, arg=srvId) 35 | else: 36 | wf.add_item(title='找不到联系人…',subtitle='请重新输入') 37 | except IOError: 38 | wf.add_item(title='请先启动微信 & 登录…',subtitle='并确保安装微信小助手') 39 | 40 | wf.send_feedback() 41 | if __name__ == '__main__': 42 | wf = Workflow() 43 | sys.exit(wf.run(main)) 44 | -------------------------------------------------------------------------------- /src/version: -------------------------------------------------------------------------------- 1 | 3.0 -------------------------------------------------------------------------------- /src/web.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | #!/usr/bin/env python3 3 | # @File : web.py 4 | # @Time : 2023/03/06 12:34:48 5 | # @Author : TKkk 6 | # @Email : tkk.ioser@gmail.com 7 | 8 | import os, json 9 | import requests 10 | 11 | def get(url): 12 | data = requests.get(url).text 13 | return json.loads(data) 14 | 15 | def post(url, data): 16 | data = requests.post(url, data) 17 | return data 18 | 19 | def main(): 20 | None 21 | 22 | if __name__ == "__main__": 23 | main() -------------------------------------------------------------------------------- /src/workflow/.alfredversionchecked: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TKkk-iOSer/wechat-alfred-workflow/4d44698fe7f507d8c029c5b3d8987689e580db72/src/workflow/.alfredversionchecked -------------------------------------------------------------------------------- /src/workflow/Notify.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TKkk-iOSer/wechat-alfred-workflow/4d44698fe7f507d8c029c5b3d8987689e580db72/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 | # Filter matching rules 16 | # Icons 17 | # Exceptions 18 | # Workflow objects 19 | from .workflow import ( 20 | ICON_ACCOUNT, 21 | ICON_BURN, 22 | ICON_CLOCK, 23 | ICON_COLOR, 24 | ICON_COLOUR, 25 | ICON_EJECT, 26 | ICON_ERROR, 27 | ICON_FAVORITE, 28 | ICON_FAVOURITE, 29 | ICON_GROUP, 30 | ICON_HELP, 31 | ICON_HOME, 32 | ICON_INFO, 33 | ICON_NETWORK, 34 | ICON_NOTE, 35 | ICON_SETTINGS, 36 | ICON_SWIRL, 37 | ICON_SWITCH, 38 | ICON_SYNC, 39 | ICON_TRASH, 40 | ICON_USER, 41 | ICON_WARNING, 42 | ICON_WEB, 43 | MATCH_ALL, 44 | MATCH_ALLCHARS, 45 | MATCH_ATOM, 46 | MATCH_CAPITALS, 47 | MATCH_INITIALS, 48 | MATCH_INITIALS_CONTAIN, 49 | MATCH_INITIALS_STARTSWITH, 50 | MATCH_STARTSWITH, 51 | MATCH_SUBSTRING, 52 | KeychainError, 53 | PasswordNotFound, 54 | Workflow, 55 | manager, 56 | ) 57 | from .workflow3 import Variables, Workflow3 58 | 59 | __title__ = "Alfred-Workflow" 60 | __version__ = open(os.path.join(os.path.dirname(__file__), "version")).read() 61 | __author__ = "Dean Jackson" 62 | __licence__ = "MIT" 63 | __copyright__ = "Copyright 2014-2019 Dean Jackson" 64 | 65 | __all__ = [ 66 | "Variables", 67 | "Workflow", 68 | "Workflow3", 69 | "manager", 70 | "PasswordNotFound", 71 | "KeychainError", 72 | "ICON_ACCOUNT", 73 | "ICON_BURN", 74 | "ICON_CLOCK", 75 | "ICON_COLOR", 76 | "ICON_COLOUR", 77 | "ICON_EJECT", 78 | "ICON_ERROR", 79 | "ICON_FAVORITE", 80 | "ICON_FAVOURITE", 81 | "ICON_GROUP", 82 | "ICON_HELP", 83 | "ICON_HOME", 84 | "ICON_INFO", 85 | "ICON_NETWORK", 86 | "ICON_NOTE", 87 | "ICON_SETTINGS", 88 | "ICON_SWIRL", 89 | "ICON_SWITCH", 90 | "ICON_SYNC", 91 | "ICON_TRASH", 92 | "ICON_USER", 93 | "ICON_WARNING", 94 | "ICON_WEB", 95 | "MATCH_ALL", 96 | "MATCH_ALLCHARS", 97 | "MATCH_ATOM", 98 | "MATCH_CAPITALS", 99 | "MATCH_INITIALS", 100 | "MATCH_INITIALS_CONTAIN", 101 | "MATCH_INITIALS_STARTSWITH", 102 | "MATCH_STARTSWITH", 103 | "MATCH_SUBSTRING", 104 | ] 105 | -------------------------------------------------------------------------------- /src/workflow/__init__.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TKkk-iOSer/wechat-alfred-workflow/4d44698fe7f507d8c029c5b3d8987689e580db72/src/workflow/__init__.pyc -------------------------------------------------------------------------------- /src/workflow/background.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2014 deanishe@deanishe.net 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2014-04-06 8 | # 9 | 10 | """This module provides an API to run commands in background processes. 11 | 12 | Combine with the :ref:`caching API ` to work from cached data 13 | while you fetch fresh data in the background. 14 | 15 | See :ref:`the User Manual ` for more information 16 | and examples. 17 | """ 18 | 19 | 20 | import os 21 | import pickle 22 | import signal 23 | import subprocess 24 | import sys 25 | 26 | from workflow import Workflow 27 | 28 | __all__ = ["is_running", "run_in_background"] 29 | 30 | _wf = None 31 | 32 | 33 | def wf(): 34 | global _wf 35 | if _wf is None: 36 | _wf = Workflow() 37 | return _wf 38 | 39 | 40 | def _log(): 41 | return wf().logger 42 | 43 | 44 | def _arg_cache(name): 45 | """Return path to pickle cache file for arguments. 46 | 47 | :param name: name of task 48 | :type name: ``unicode`` 49 | :returns: Path to cache file 50 | :rtype: ``unicode`` filepath 51 | 52 | """ 53 | return wf().cachefile(name + ".argcache") 54 | 55 | 56 | def _pid_file(name): 57 | """Return path to PID file for ``name``. 58 | 59 | :param name: name of task 60 | :type name: ``unicode`` 61 | :returns: Path to PID file for task 62 | :rtype: ``unicode`` filepath 63 | 64 | """ 65 | return wf().cachefile(name + ".pid") 66 | 67 | 68 | def _process_exists(pid): 69 | """Check if a process with PID ``pid`` exists. 70 | 71 | :param pid: PID to check 72 | :type pid: ``int`` 73 | :returns: ``True`` if process exists, else ``False`` 74 | :rtype: ``Boolean`` 75 | 76 | """ 77 | try: 78 | os.kill(pid, 0) 79 | except OSError: # not running 80 | return False 81 | return True 82 | 83 | 84 | def _job_pid(name): 85 | """Get PID of job or `None` if job does not exist. 86 | 87 | Args: 88 | name (str): Name of job. 89 | 90 | Returns: 91 | int: PID of job process (or `None` if job doesn't exist). 92 | """ 93 | pidfile = _pid_file(name) 94 | if not os.path.exists(pidfile): 95 | return 96 | 97 | with open(pidfile, "rb") as fp: 98 | read = fp.read() 99 | # print(str(read)) 100 | pid = int.from_bytes(read, sys.byteorder) 101 | # print(pid) 102 | 103 | if _process_exists(pid): 104 | return pid 105 | 106 | os.unlink(pidfile) 107 | 108 | 109 | def is_running(name): 110 | """Test whether task ``name`` is currently running. 111 | 112 | :param name: name of task 113 | :type name: unicode 114 | :returns: ``True`` if task with name ``name`` is running, else ``False`` 115 | :rtype: bool 116 | 117 | """ 118 | if _job_pid(name) is not None: 119 | return True 120 | 121 | return False 122 | 123 | 124 | def _background( 125 | pidfile, stdin="/dev/null", stdout="/dev/null", stderr="/dev/null" 126 | ): # pragma: no cover 127 | """Fork the current process into a background daemon. 128 | 129 | :param pidfile: file to write PID of daemon process to. 130 | :type pidfile: filepath 131 | :param stdin: where to read input 132 | :type stdin: filepath 133 | :param stdout: where to write stdout output 134 | :type stdout: filepath 135 | :param stderr: where to write stderr output 136 | :type stderr: filepath 137 | 138 | """ 139 | 140 | def _fork_and_exit_parent(errmsg, wait=False, write=False): 141 | try: 142 | pid = os.fork() 143 | if pid > 0: 144 | if write: # write PID of child process to `pidfile` 145 | tmp = pidfile + ".tmp" 146 | with open(tmp, "wb") as fp: 147 | fp.write(pid.to_bytes(4, sys.byteorder)) 148 | os.rename(tmp, pidfile) 149 | if wait: # wait for child process to exit 150 | os.waitpid(pid, 0) 151 | os._exit(0) 152 | except OSError as err: 153 | _log().critical("%s: (%d) %s", errmsg, err.errno, err.strerror) 154 | raise err 155 | 156 | # Do first fork and wait for second fork to finish. 157 | _fork_and_exit_parent("fork #1 failed", wait=True) 158 | 159 | # Decouple from parent environment. 160 | os.chdir(wf().workflowdir) 161 | os.setsid() 162 | 163 | # Do second fork and write PID to pidfile. 164 | _fork_and_exit_parent("fork #2 failed", write=True) 165 | 166 | # Now I am a daemon! 167 | # Redirect standard file descriptors. 168 | si = open(stdin, "r", 1) 169 | so = open(stdout, "a+", 1) 170 | se = open(stderr, "a+", 1) 171 | if hasattr(sys.stdin, "fileno"): 172 | os.dup2(si.fileno(), sys.stdin.fileno()) 173 | if hasattr(sys.stdout, "fileno"): 174 | os.dup2(so.fileno(), sys.stdout.fileno()) 175 | if hasattr(sys.stderr, "fileno"): 176 | os.dup2(se.fileno(), sys.stderr.fileno()) 177 | 178 | 179 | def kill(name, sig=signal.SIGTERM): 180 | """Send a signal to job ``name`` via :func:`os.kill`. 181 | 182 | .. versionadded:: 1.29 183 | 184 | Args: 185 | name (str): Name of the job 186 | sig (int, optional): Signal to send (default: SIGTERM) 187 | 188 | Returns: 189 | bool: `False` if job isn't running, `True` if signal was sent. 190 | """ 191 | pid = _job_pid(name) 192 | if pid is None: 193 | return False 194 | 195 | os.kill(pid, sig) 196 | return True 197 | 198 | 199 | def run_in_background(name, args, **kwargs): 200 | r"""Cache arguments then call this script again via :func:`subprocess.call`. 201 | 202 | :param name: name of job 203 | :type name: unicode 204 | :param args: arguments passed as first argument to :func:`subprocess.call` 205 | :param \**kwargs: keyword arguments to :func:`subprocess.call` 206 | :returns: exit code of sub-process 207 | :rtype: int 208 | 209 | When you call this function, it caches its arguments and then calls 210 | ``background.py`` in a subprocess. The Python subprocess will load the 211 | cached arguments, fork into the background, and then run the command you 212 | specified. 213 | 214 | This function will return as soon as the ``background.py`` subprocess has 215 | forked, returning the exit code of *that* process (i.e. not of the command 216 | you're trying to run). 217 | 218 | If that process fails, an error will be written to the log file. 219 | 220 | If a process is already running under the same name, this function will 221 | return immediately and will not run the specified command. 222 | 223 | """ 224 | if is_running(name): 225 | _log().info("[%s] job already running", name) 226 | return 227 | 228 | argcache = _arg_cache(name) 229 | 230 | # Cache arguments 231 | with open(argcache, "wb") as fp: 232 | pickle.dump({"args": args, "kwargs": kwargs}, fp) 233 | _log().debug("[%s] command cached: %s", name, argcache) 234 | 235 | # Call this script 236 | cmd = [sys.executable, "-m", "workflow.background", name] 237 | _log().debug("[%s] passing job to background runner: %r", name, cmd) 238 | retcode = subprocess.call(cmd) 239 | 240 | if retcode: # pragma: no cover 241 | _log().error("[%s] background runner failed with %d", name, retcode) 242 | else: 243 | _log().debug("[%s] background job started", name) 244 | 245 | return retcode 246 | 247 | 248 | def main(wf): # pragma: no cover 249 | """Run command in a background process. 250 | 251 | Load cached arguments, fork into background, then call 252 | :meth:`subprocess.call` with cached arguments. 253 | 254 | """ 255 | log = wf.logger 256 | name = wf.args[0] 257 | argcache = _arg_cache(name) 258 | if not os.path.exists(argcache): 259 | msg = "[{0}] command cache not found: {1}".format(name, argcache) 260 | log.critical(msg) 261 | raise IOError(msg) 262 | 263 | # Fork to background and run command 264 | pidfile = _pid_file(name) 265 | _background(pidfile) 266 | 267 | # Load cached arguments 268 | with open(argcache, "rb") as fp: 269 | data = pickle.load(fp) 270 | 271 | # Cached arguments 272 | args = data["args"] 273 | kwargs = data["kwargs"] 274 | 275 | # Delete argument cache file 276 | os.unlink(argcache) 277 | 278 | try: 279 | # Run the command 280 | log.debug("[%s] running command: %r", name, args) 281 | 282 | retcode = subprocess.call(args, **kwargs) 283 | 284 | if retcode: 285 | log.error("[%s] command failed with status %d", name, retcode) 286 | finally: 287 | os.unlink(pidfile) 288 | 289 | log.debug("[%s] job complete", name) 290 | 291 | 292 | if __name__ == "__main__": # pragma: no cover 293 | wf().run(main) 294 | -------------------------------------------------------------------------------- /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. 15 | 16 | This feature is only available on Mountain Lion (10.8) and later. 17 | It will silently fail on older systems. 18 | 19 | The main API is a single function, :func:`~workflow.notify.notify`. 20 | 21 | It works by copying a simple application to your workflow's data 22 | directory. It replaces the application's icon with your workflow's 23 | icon and then calls the application to post notifications. 24 | """ 25 | 26 | 27 | import os 28 | import plistlib 29 | import shutil 30 | import subprocess 31 | import sys 32 | import tarfile 33 | import tempfile 34 | import uuid 35 | from typing import List 36 | 37 | from . import workflow 38 | 39 | _wf = None 40 | _log = None 41 | 42 | 43 | #: Available system sounds from System Preferences > Sound > Sound Effects 44 | SOUNDS = ( 45 | "Basso", 46 | "Blow", 47 | "Bottle", 48 | "Frog", 49 | "Funk", 50 | "Glass", 51 | "Hero", 52 | "Morse", 53 | "Ping", 54 | "Pop", 55 | "Purr", 56 | "Sosumi", 57 | "Submarine", 58 | "Tink", 59 | ) 60 | 61 | 62 | def wf(): 63 | """Return Workflow object for this module. 64 | 65 | Returns: 66 | workflow.Workflow: Workflow object for current workflow. 67 | """ 68 | global _wf 69 | if _wf is None: 70 | _wf = workflow.Workflow() 71 | return _wf 72 | 73 | 74 | def log(): 75 | """Return logger for this module. 76 | 77 | Returns: 78 | logging.Logger: Logger for this module. 79 | """ 80 | global _log 81 | if _log is None: 82 | _log = wf().logger 83 | return _log 84 | 85 | 86 | def notifier_program(): 87 | """Return path to notifier applet executable. 88 | 89 | Returns: 90 | unicode: Path to Notify.app ``applet`` executable. 91 | """ 92 | return wf().datafile("Notify.app/Contents/MacOS/applet") 93 | 94 | 95 | def notifier_icon_path(): 96 | """Return path to icon file in installed Notify.app. 97 | 98 | Returns: 99 | unicode: Path to ``applet.icns`` within the app bundle. 100 | """ 101 | return wf().datafile("Notify.app/Contents/Resources/applet.icns") 102 | 103 | 104 | def install_notifier(): 105 | """Extract ``Notify.app`` from the workflow to data directory. 106 | 107 | Changes the bundle ID of the installed app and gives it the 108 | workflow's icon. 109 | """ 110 | archive = os.path.join(os.path.dirname(__file__), "Notify.tgz") 111 | destdir = wf().datadir 112 | app_path = os.path.join(destdir, "Notify.app") 113 | n = notifier_program() 114 | log().debug("installing Notify.app to %r ...", destdir) 115 | # z = zipfile.ZipFile(archive, 'r') 116 | # z.extractall(destdir) 117 | tgz = tarfile.open(archive, "r:gz") 118 | tgz.extractall(destdir) 119 | if not os.path.exists(n): # pragma: nocover 120 | raise RuntimeError("Notify.app could not be installed in " + destdir) 121 | 122 | # Replace applet icon 123 | icon = notifier_icon_path() 124 | workflow_icon = wf().workflowfile("icon.png") 125 | if os.path.exists(icon): 126 | os.unlink(icon) 127 | 128 | png_to_icns(workflow_icon, icon) 129 | 130 | # Set file icon 131 | # PyObjC isn't available for 2.6, so this is 2.7 only. Actually, 132 | # none of this code will "work" on pre-10.8 systems. Let it run 133 | # until I figure out a better way of excluding this module 134 | # from coverage in py2.6. 135 | if sys.version_info >= (2, 7): # pragma: no cover 136 | from AppKit import NSImage, NSWorkspace 137 | 138 | ws = NSWorkspace.sharedWorkspace() 139 | img = NSImage.alloc().init() 140 | img.initWithContentsOfFile_(icon) 141 | ws.setIcon_forFile_options_(img, app_path, 0) 142 | 143 | # Change bundle ID of installed app 144 | ip_path = os.path.join(app_path, "Contents/Info.plist") 145 | bundle_id = "{0}.{1}".format(wf().bundleid, uuid.uuid4().hex) 146 | data = plistlib.load(ip_path) 147 | log().debug("changing bundle ID to %r", bundle_id) 148 | data["CFBundleIdentifier"] = bundle_id 149 | plistlib.dump(data, ip_path) 150 | 151 | 152 | def validate_sound(sound): 153 | """Coerce ``sound`` to valid sound name. 154 | 155 | Returns ``None`` for invalid sounds. Sound names can be found 156 | in ``System Preferences > Sound > Sound Effects``. 157 | 158 | Args: 159 | sound (str): Name of system sound. 160 | 161 | Returns: 162 | str: Proper name of sound or ``None``. 163 | """ 164 | if not sound: 165 | return None 166 | 167 | # Case-insensitive comparison of `sound` 168 | if sound.lower() in [s.lower() for s in SOUNDS]: 169 | # Title-case is correct for all system sounds as of macOS 10.11 170 | return sound.title() 171 | return None 172 | 173 | 174 | def notify(title="", text="", sound=None): 175 | """Post notification via Notify.app helper. 176 | 177 | Args: 178 | title (str, optional): Notification title. 179 | text (str, optional): Notification body text. 180 | sound (str, optional): Name of sound to play. 181 | 182 | Raises: 183 | ValueError: Raised if both ``title`` and ``text`` are empty. 184 | 185 | Returns: 186 | bool: ``True`` if notification was posted, else ``False``. 187 | """ 188 | if title == text == "": 189 | raise ValueError("Empty notification") 190 | 191 | sound = validate_sound(sound) or "" 192 | 193 | n = notifier_program() 194 | 195 | if not os.path.exists(n): 196 | install_notifier() 197 | 198 | env = os.environ.copy() 199 | enc = "utf-8" 200 | env["NOTIFY_TITLE"] = title.encode(enc) 201 | env["NOTIFY_MESSAGE"] = text.encode(enc) 202 | env["NOTIFY_SOUND"] = sound.encode(enc) 203 | cmd = [n] 204 | retcode = subprocess.call(cmd, env=env) 205 | if retcode == 0: 206 | return True 207 | 208 | log().error("Notify.app exited with status {0}.".format(retcode)) 209 | return False 210 | 211 | 212 | def usr_bin_env(*args: str) -> List[str]: 213 | return ["/usr/bin/env", f'PATH={os.environ["PATH"]}'] + list(args) 214 | 215 | 216 | def convert_image(inpath, outpath, size): 217 | """Convert an image file using ``sips``. 218 | 219 | Args: 220 | inpath (str): Path of source file. 221 | outpath (str): Path to destination file. 222 | size (int): Width and height of destination image in pixels. 223 | 224 | Raises: 225 | RuntimeError: Raised if ``sips`` exits with non-zero status. 226 | """ 227 | cmd = ["sips", "-z", str(size), str(size), inpath, "--out", outpath] 228 | # log().debug(cmd) 229 | with open(os.devnull, "w") as pipe: 230 | retcode = subprocess.call( 231 | cmd, shell=True, stdout=pipe, stderr=subprocess.STDOUT 232 | ) 233 | 234 | if retcode != 0: 235 | raise RuntimeError("sips exited with %d" % retcode) 236 | 237 | 238 | def png_to_icns(png_path, icns_path): 239 | """Convert PNG file to ICNS using ``iconutil``. 240 | 241 | Create an iconset from the source PNG file. Generate PNG files 242 | in each size required by macOS, then call ``iconutil`` to turn 243 | them into a single ICNS file. 244 | 245 | Args: 246 | png_path (str): Path to source PNG file. 247 | icns_path (str): Path to destination ICNS file. 248 | 249 | Raises: 250 | RuntimeError: Raised if ``iconutil`` or ``sips`` fail. 251 | """ 252 | tempdir = tempfile.mkdtemp(prefix="aw-", dir=wf().datadir) 253 | 254 | try: 255 | iconset = os.path.join(tempdir, "Icon.iconset") 256 | 257 | if os.path.exists(iconset): # pragma: nocover 258 | raise RuntimeError("iconset already exists: " + iconset) 259 | 260 | os.makedirs(iconset) 261 | 262 | # Copy source icon to icon set and generate all the other 263 | # sizes needed 264 | configs = [] 265 | for i in (16, 32, 128, 256, 512): 266 | configs.append(("icon_{0}x{0}.png".format(i), i)) 267 | configs.append((("icon_{0}x{0}@2x.png".format(i), i * 2))) 268 | 269 | shutil.copy(png_path, os.path.join(iconset, "icon_256x256.png")) 270 | shutil.copy(png_path, os.path.join(iconset, "icon_128x128@2x.png")) 271 | 272 | for name, size in configs: 273 | outpath = os.path.join(iconset, name) 274 | if os.path.exists(outpath): 275 | continue 276 | convert_image(png_path, outpath, size) 277 | 278 | cmd = ["iconutil", "-c", "icns", "-o", icns_path, iconset] 279 | 280 | retcode = subprocess.call(cmd) 281 | if retcode != 0: 282 | raise RuntimeError("iconset exited with %d" % retcode) 283 | 284 | if not os.path.exists(icns_path): # pragma: nocover 285 | raise ValueError("generated ICNS file not found: " + repr(icns_path)) 286 | finally: 287 | try: 288 | shutil.rmtree(tempdir) 289 | except OSError: # pragma: no cover 290 | pass 291 | 292 | 293 | if __name__ == "__main__": # pragma: nocover 294 | # Simple command-line script to test module with 295 | # This won't work on 2.6, as `argparse` isn't available 296 | # by default. 297 | import argparse 298 | from unicodedata import normalize 299 | 300 | def ustr(s): 301 | """Coerce `s` to normalised Unicode.""" 302 | return normalize("NFD", s.decode("utf-8")) 303 | 304 | p = argparse.ArgumentParser() 305 | p.add_argument("-p", "--png", help="PNG image to convert to ICNS.") 306 | p.add_argument( 307 | "-l", "--list-sounds", help="Show available sounds.", action="store_true" 308 | ) 309 | p.add_argument("-t", "--title", help="Notification title.", type=ustr, default="") 310 | p.add_argument( 311 | "-s", "--sound", type=ustr, help="Optional notification sound.", default="" 312 | ) 313 | p.add_argument( 314 | "text", type=ustr, help="Notification body text.", default="", nargs="?" 315 | ) 316 | o = p.parse_args() 317 | 318 | # List available sounds 319 | if o.list_sounds: 320 | for sound in SOUNDS: 321 | print(sound) 322 | sys.exit(0) 323 | 324 | # Convert PNG to ICNS 325 | if o.png: 326 | icns = os.path.join( 327 | os.path.dirname(o.png), 328 | os.path.splitext(os.path.basename(o.png))[0] + ".icns", 329 | ) 330 | 331 | print("converting {0!r} to {1!r} ...".format(o.png, icns), file=sys.stderr) 332 | 333 | if os.path.exists(icns): 334 | raise ValueError("destination file already exists: " + icns) 335 | 336 | png_to_icns(o.png, icns) 337 | sys.exit(0) 338 | 339 | # Post notification 340 | if o.title == o.text == "": 341 | print("ERROR: empty notification.", file=sys.stderr) 342 | sys.exit(1) 343 | else: 344 | notify(o.title, o.text, o.sound) 345 | -------------------------------------------------------------------------------- /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 | 25 | import json 26 | import os 27 | import re 28 | import subprocess 29 | import tempfile 30 | from collections import defaultdict 31 | from functools import total_ordering 32 | from itertools import zip_longest 33 | from urllib import request 34 | 35 | from workflow.util import atomic_writer 36 | 37 | from . import workflow 38 | 39 | # __all__ = [] 40 | 41 | 42 | RELEASES_BASE = "https://api.github.com/repos/{}/releases" 43 | match_workflow = re.compile(r"\.alfred(\d+)?workflow$").search 44 | 45 | _wf = None 46 | 47 | 48 | def wf(): 49 | """Lazy `Workflow` object.""" 50 | global _wf 51 | if _wf is None: 52 | _wf = workflow.Workflow() 53 | return _wf 54 | 55 | 56 | @total_ordering 57 | class Download(object): 58 | """A workflow file that is available for download. 59 | 60 | .. versionadded: 1.37 61 | 62 | Attributes: 63 | url (str): URL of workflow file. 64 | filename (str): Filename of workflow file. 65 | version (Version): Semantic version of workflow. 66 | prerelease (bool): Whether version is a pre-release. 67 | alfred_version (Version): Minimum compatible version 68 | of Alfred. 69 | 70 | """ 71 | 72 | @classmethod 73 | def from_dict(cls, d): 74 | """Create a `Download` from a `dict`.""" 75 | return cls( 76 | url=d["url"], 77 | filename=d["filename"], 78 | version=Version(d["version"]), 79 | prerelease=d["prerelease"], 80 | ) 81 | 82 | @classmethod 83 | def from_releases(cls, js): 84 | """Extract downloads from GitHub releases. 85 | 86 | Searches releases with semantic tags for assets with 87 | file extension .alfredworkflow or .alfredXworkflow where 88 | X is a number. 89 | 90 | Files are returned sorted by latest version first. Any 91 | releases containing multiple files with the same (workflow) 92 | extension are rejected as ambiguous. 93 | 94 | Args: 95 | js (str): JSON response from GitHub's releases endpoint. 96 | 97 | Returns: 98 | list: Sequence of `Download`. 99 | """ 100 | releases = json.loads(js) 101 | downloads = [] 102 | for release in releases: 103 | tag = release["tag_name"] 104 | dupes = defaultdict(int) 105 | try: 106 | version = Version(tag) 107 | except ValueError as err: 108 | wf().logger.debug('ignored release: bad version "%s": %s', tag, err) 109 | continue 110 | 111 | dls = [] 112 | for asset in release.get("assets", []): 113 | url = asset.get("browser_download_url") 114 | filename = os.path.basename(url) 115 | m = match_workflow(filename) 116 | if not m: 117 | wf().logger.debug("unwanted file: %s", filename) 118 | continue 119 | 120 | ext = m.group(0) 121 | dupes[ext] = dupes[ext] + 1 122 | dls.append(Download(url, filename, version, release["prerelease"])) 123 | 124 | valid = True 125 | for ext, n in list(dupes.items()): 126 | if n > 1: 127 | wf().logger.debug( 128 | 'ignored release "%s": multiple assets ' 'with extension "%s"', 129 | tag, 130 | ext, 131 | ) 132 | valid = False 133 | break 134 | 135 | if valid: 136 | downloads.extend(dls) 137 | 138 | downloads.sort(reverse=True) 139 | return downloads 140 | 141 | def __init__(self, url, filename, version, prerelease=False): 142 | """Create a new Download. 143 | 144 | Args: 145 | url (str): URL of workflow file. 146 | filename (str): Filename of workflow file. 147 | version (Version): Version of workflow. 148 | prerelease (bool, optional): Whether version is 149 | pre-release. Defaults to False. 150 | 151 | """ 152 | if isinstance(version, str): 153 | version = Version(version) 154 | 155 | self.url = url 156 | self.filename = filename 157 | self.version = version 158 | self.prerelease = prerelease 159 | 160 | @property 161 | def alfred_version(self): 162 | """Minimum Alfred version based on filename extension.""" 163 | m = match_workflow(self.filename) 164 | if not m or not m.group(1): 165 | return Version("0") 166 | return Version(m.group(1)) 167 | 168 | @property 169 | def dict(self): 170 | """Convert `Download` to `dict`.""" 171 | return dict( 172 | url=self.url, 173 | filename=self.filename, 174 | version=str(self.version), 175 | prerelease=self.prerelease, 176 | ) 177 | 178 | def __str__(self): 179 | """Format `Download` for printing.""" 180 | return ( 181 | "Download(" 182 | "url={dl.url!r}, " 183 | "filename={dl.filename!r}, " 184 | "version={dl.version!r}, " 185 | "prerelease={dl.prerelease!r}" 186 | ")" 187 | ).format(dl=self) 188 | 189 | def __repr__(self): 190 | """Code-like representation of `Download`.""" 191 | return str(self) 192 | 193 | def __eq__(self, other): 194 | """Compare Downloads based on version numbers.""" 195 | if ( 196 | self.url != other.url 197 | or self.filename != other.filename 198 | or self.version != other.version 199 | or self.prerelease != other.prerelease 200 | ): 201 | return False 202 | return True 203 | 204 | def __ne__(self, other): 205 | """Compare Downloads based on version numbers.""" 206 | return not self.__eq__(other) 207 | 208 | def __lt__(self, other): 209 | """Compare Downloads based on version numbers.""" 210 | if self.version != other.version: 211 | return self.version < other.version 212 | return self.alfred_version < other.alfred_version 213 | 214 | 215 | class Version(object): 216 | """Mostly semantic versioning. 217 | 218 | The main difference to proper :ref:`semantic versioning ` 219 | is that this implementation doesn't require a minor or patch version. 220 | 221 | Version strings may also be prefixed with "v", e.g.: 222 | 223 | >>> v = Version('v1.1.1') 224 | >>> v.tuple 225 | (1, 1, 1, '') 226 | 227 | >>> v = Version('2.0') 228 | >>> v.tuple 229 | (2, 0, 0, '') 230 | 231 | >>> Version('3.1-beta').tuple 232 | (3, 1, 0, 'beta') 233 | 234 | >>> Version('1.0.1') > Version('0.0.1') 235 | True 236 | """ 237 | 238 | #: Match version and pre-release/build information in version strings 239 | match_version = re.compile(r"([0-9][0-9\.]*)(.+)?").match 240 | 241 | def __init__(self, vstr): 242 | """Create new `Version` object. 243 | 244 | Args: 245 | vstr (basestring): Semantic version string. 246 | """ 247 | if not vstr: 248 | raise ValueError("invalid version number: {!r}".format(vstr)) 249 | 250 | self.vstr = vstr 251 | self.major = 0 252 | self.minor = 0 253 | self.patch = 0 254 | self.suffix = "" 255 | self.build = "" 256 | self._parse(vstr) 257 | 258 | def _parse(self, vstr): 259 | vstr = str(vstr) 260 | if vstr.startswith("v"): 261 | m = self.match_version(vstr[1:]) 262 | else: 263 | m = self.match_version(vstr) 264 | if not m: 265 | raise ValueError("invalid version number: " + vstr) 266 | 267 | version, suffix = m.groups() 268 | parts = self._parse_dotted_string(version) 269 | self.major = parts.pop(0) 270 | if len(parts): 271 | self.minor = parts.pop(0) 272 | if len(parts): 273 | self.patch = parts.pop(0) 274 | if not len(parts) == 0: 275 | raise ValueError("version number too long: " + vstr) 276 | 277 | if suffix: 278 | # Build info 279 | idx = suffix.find("+") 280 | if idx > -1: 281 | self.build = suffix[idx + 1 :] 282 | suffix = suffix[:idx] 283 | if suffix: 284 | if not suffix.startswith("-"): 285 | raise ValueError("suffix must start with - : " + suffix) 286 | self.suffix = suffix[1:] 287 | 288 | def _parse_dotted_string(self, s): 289 | """Parse string ``s`` into list of ints and strings.""" 290 | parsed = [] 291 | parts = s.split(".") 292 | for p in parts: 293 | if p.isdigit(): 294 | p = int(p) 295 | parsed.append(p) 296 | return parsed 297 | 298 | @property 299 | def tuple(self): 300 | """Version number as a tuple of major, minor, patch, pre-release.""" 301 | return (self.major, self.minor, self.patch, self.suffix) 302 | 303 | def __lt__(self, other): 304 | """Implement comparison.""" 305 | if not isinstance(other, Version): 306 | raise ValueError("not a Version instance: {0!r}".format(other)) 307 | t = self.tuple[:3] 308 | o = other.tuple[:3] 309 | if t < o: 310 | return True 311 | if t == o: # We need to compare suffixes 312 | if self.suffix and not other.suffix: 313 | return True 314 | if other.suffix and not self.suffix: 315 | return False 316 | 317 | self_suffix = self._parse_dotted_string(self.suffix) 318 | other_suffix = self._parse_dotted_string(other.suffix) 319 | 320 | for s, o in zip_longest(self_suffix, other_suffix): 321 | if s is None: # shorter value wins 322 | return True 323 | elif o is None: # longer value loses 324 | return False 325 | elif type(s) != type(o): # type coersion 326 | s, o = str(s), str(o) 327 | if s == o: # next if the same compare 328 | continue 329 | return s < o # finally compare 330 | # t > o 331 | return False 332 | 333 | def __eq__(self, other): 334 | """Implement comparison.""" 335 | if not isinstance(other, Version): 336 | raise ValueError("not a Version instance: {0!r}".format(other)) 337 | return self.tuple == other.tuple 338 | 339 | def __ne__(self, other): 340 | """Implement comparison.""" 341 | return not self.__eq__(other) 342 | 343 | def __gt__(self, other): 344 | """Implement comparison.""" 345 | if not isinstance(other, Version): 346 | raise ValueError("not a Version instance: {0!r}".format(other)) 347 | return other.__lt__(self) 348 | 349 | def __le__(self, other): 350 | """Implement comparison.""" 351 | if not isinstance(other, Version): 352 | raise ValueError("not a Version instance: {0!r}".format(other)) 353 | return not other.__lt__(self) 354 | 355 | def __ge__(self, other): 356 | """Implement comparison.""" 357 | return not self.__lt__(other) 358 | 359 | def __str__(self): 360 | """Return semantic version string.""" 361 | vstr = "{0}.{1}.{2}".format(self.major, self.minor, self.patch) 362 | if self.suffix: 363 | vstr = "{0}-{1}".format(vstr, self.suffix) 364 | if self.build: 365 | vstr = "{0}+{1}".format(vstr, self.build) 366 | return vstr 367 | 368 | def __repr__(self): 369 | """Return 'code' representation of `Version`.""" 370 | return "Version('{0}')".format(str(self)) 371 | 372 | 373 | def retrieve_download(dl): 374 | """Saves a download to a temporary file and returns path. 375 | 376 | .. versionadded: 1.37 377 | 378 | Args: 379 | url (unicode): URL to .alfredworkflow file in GitHub repo 380 | 381 | Returns: 382 | unicode: path to downloaded file 383 | 384 | """ 385 | if not match_workflow(dl.filename): 386 | raise ValueError("attachment not a workflow: " + dl.filename) 387 | 388 | path = os.path.join(tempfile.gettempdir(), dl.filename) 389 | wf().logger.debug("downloading update from " "%r to %r ...", dl.url, path) 390 | 391 | r = request.urlopen(dl.url) 392 | 393 | with atomic_writer(path, "wb") as file_obj: 394 | file_obj.write(r.read()) 395 | 396 | return path 397 | 398 | 399 | def build_api_url(repo): 400 | """Generate releases URL from GitHub repo. 401 | 402 | Args: 403 | repo (unicode): Repo name in form ``username/repo`` 404 | 405 | Returns: 406 | unicode: URL to the API endpoint for the repo's releases 407 | 408 | """ 409 | if len(repo.split("/")) != 2: 410 | raise ValueError("invalid GitHub repo: {!r}".format(repo)) 411 | 412 | return RELEASES_BASE.format(repo) 413 | 414 | 415 | def get_downloads(repo): 416 | """Load available ``Download``s for GitHub repo. 417 | 418 | .. versionadded: 1.37 419 | 420 | Args: 421 | repo (unicode): GitHub repo to load releases for. 422 | 423 | Returns: 424 | list: Sequence of `Download` contained in GitHub releases. 425 | """ 426 | url = build_api_url(repo) 427 | 428 | def _fetch(): 429 | wf().logger.info("retrieving releases for %r ...", repo) 430 | r = request.urlopen(url) 431 | return r.read() 432 | 433 | key = "github-releases-" + repo.replace("/", "-") 434 | js = wf().cached_data(key, _fetch, max_age=60) 435 | 436 | return Download.from_releases(js) 437 | 438 | 439 | def latest_download(dls, alfred_version=None, prereleases=False): 440 | """Return newest `Download`.""" 441 | alfred_version = alfred_version or os.getenv("alfred_version") 442 | version = None 443 | if alfred_version: 444 | version = Version(alfred_version) 445 | 446 | dls.sort(reverse=True) 447 | for dl in dls: 448 | if dl.prerelease and not prereleases: 449 | wf().logger.debug("ignored prerelease: %s", dl.version) 450 | continue 451 | if version and dl.alfred_version > version: 452 | wf().logger.debug( 453 | "ignored incompatible (%s > %s): %s", 454 | dl.alfred_version, 455 | version, 456 | dl.filename, 457 | ) 458 | continue 459 | 460 | wf().logger.debug("latest version: %s (%s)", dl.version, dl.filename) 461 | return dl 462 | 463 | return None 464 | 465 | 466 | def check_update(repo, current_version, prereleases=False, alfred_version=None): 467 | """Check whether a newer release is available on GitHub. 468 | 469 | Args: 470 | repo (unicode): ``username/repo`` for workflow's GitHub repo 471 | current_version (unicode): the currently installed version of the 472 | workflow. :ref:`Semantic versioning ` is required. 473 | prereleases (bool): Whether to include pre-releases. 474 | alfred_version (unicode): version of currently-running Alfred. 475 | if empty, defaults to ``$alfred_version`` environment variable. 476 | 477 | Returns: 478 | bool: ``True`` if an update is available, else ``False`` 479 | 480 | If an update is available, its version number and download URL will 481 | be cached. 482 | 483 | """ 484 | key = "__workflow_latest_version" 485 | # data stored when no update is available 486 | no_update = {"available": False, "download": None, "version": None} 487 | current = Version(current_version) 488 | 489 | dls = get_downloads(repo) 490 | if not len(dls): 491 | wf().logger.warning("no valid downloads for %s", repo) 492 | wf().cache_data(key, no_update) 493 | return False 494 | 495 | wf().logger.info("%d download(s) for %s", len(dls), repo) 496 | 497 | dl = latest_download(dls, alfred_version, prereleases) 498 | 499 | if not dl: 500 | wf().logger.warning("no compatible downloads for %s", repo) 501 | wf().cache_data(key, no_update) 502 | return False 503 | 504 | wf().logger.debug("latest=%r, installed=%r", dl.version, current) 505 | 506 | if dl.version > current: 507 | wf().cache_data( 508 | key, {"version": str(dl.version), "download": dl.dict, "available": True} 509 | ) 510 | return True 511 | 512 | wf().cache_data(key, no_update) 513 | return False 514 | 515 | 516 | def install_update(): 517 | """If a newer release is available, download and install it. 518 | 519 | :returns: ``True`` if an update is installed, else ``False`` 520 | 521 | """ 522 | key = "__workflow_latest_version" 523 | # data stored when no update is available 524 | no_update = {"available": False, "download": None, "version": None} 525 | status = wf().cached_data(key, max_age=0) 526 | 527 | if not status or not status.get("available"): 528 | wf().logger.info("no update available") 529 | return False 530 | 531 | dl = status.get("download") 532 | if not dl: 533 | wf().logger.info("no download information") 534 | return False 535 | 536 | path = retrieve_download(Download.from_dict(dl)) 537 | 538 | wf().logger.info("installing updated workflow ...") 539 | subprocess.call(["open", path]) # nosec 540 | 541 | wf().cache_data(key, no_update) 542 | return True 543 | 544 | 545 | if __name__ == "__main__": # pragma: nocover 546 | import sys 547 | 548 | prereleases = False 549 | 550 | def show_help(status=0): 551 | """Print help message.""" 552 | print("usage: update.py (check|install) " "[--prereleases] ") 553 | sys.exit(status) 554 | 555 | argv = sys.argv[:] 556 | if "-h" in argv or "--help" in argv: 557 | show_help() 558 | 559 | if "--prereleases" in argv: 560 | argv.remove("--prereleases") 561 | prereleases = True 562 | 563 | if len(argv) != 4: 564 | show_help(1) 565 | 566 | action = argv[1] 567 | repo = argv[2] 568 | version = argv[3] 569 | 570 | try: 571 | 572 | if action == "check": 573 | check_update(repo, version, prereleases) 574 | elif action == "install": 575 | install_update() 576 | else: 577 | show_help(1) 578 | 579 | except Exception as err: # ensure traceback is in log file 580 | wf().logger.exception(err) 581 | raise err 582 | -------------------------------------------------------------------------------- /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 | 14 | import atexit 15 | import errno 16 | import fcntl 17 | import functools 18 | import json 19 | import os 20 | import signal 21 | import subprocess 22 | import sys 23 | import time 24 | from collections import namedtuple 25 | from contextlib import contextmanager 26 | from threading import Event 27 | 28 | # JXA scripts to call Alfred's API via the Scripting Bridge 29 | # {app} is automatically replaced with "Alfred 3" or 30 | # "com.runningwithcrayons.Alfred" depending on version. 31 | # 32 | # Open Alfred in search (regular) mode 33 | JXA_SEARCH = "Application({app}).search({arg});" 34 | # Open Alfred's File Actions on an argument 35 | JXA_ACTION = "Application({app}).action({arg});" 36 | # Open Alfred's navigation mode at path 37 | JXA_BROWSE = "Application({app}).browse({arg});" 38 | # Set the specified theme 39 | JXA_SET_THEME = "Application({app}).setTheme({arg});" 40 | # Call an External Trigger 41 | JXA_TRIGGER = "Application({app}).runTrigger({arg}, {opts});" 42 | # Save a variable to the workflow configuration sheet/info.plist 43 | JXA_SET_CONFIG = "Application({app}).setConfiguration({arg}, {opts});" 44 | # Delete a variable from the workflow configuration sheet/info.plist 45 | JXA_UNSET_CONFIG = "Application({app}).removeConfiguration({arg}, {opts});" 46 | # Tell Alfred to reload a workflow from disk 47 | JXA_RELOAD_WORKFLOW = "Application({app}).reloadWorkflow({arg});" 48 | 49 | 50 | class AcquisitionError(Exception): 51 | """Raised if a lock cannot be acquired.""" 52 | 53 | 54 | AppInfo = namedtuple("AppInfo", ["name", "path", "bundleid"]) 55 | """Information about an installed application. 56 | 57 | Returned by :func:`appinfo`. All attributes are Unicode. 58 | 59 | .. py:attribute:: name 60 | 61 | Name of the application, e.g. ``u'Safari'``. 62 | 63 | .. py:attribute:: path 64 | 65 | Path to the application bundle, e.g. ``u'/Applications/Safari.app'``. 66 | 67 | .. py:attribute:: bundleid 68 | 69 | Application's bundle ID, e.g. ``u'com.apple.Safari'``. 70 | 71 | """ 72 | 73 | 74 | def jxa_app_name(): 75 | """Return name of application to call currently running Alfred. 76 | 77 | .. versionadded: 1.37 78 | 79 | Returns 'Alfred 3' or 'com.runningwithcrayons.Alfred' depending 80 | on which version of Alfred is running. 81 | 82 | This name is suitable for use with ``Application(name)`` in JXA. 83 | 84 | Returns: 85 | unicode: Application name or ID. 86 | 87 | """ 88 | if os.getenv("alfred_version", "").startswith("3"): 89 | # Alfred 3 90 | return "Alfred 3" 91 | # Alfred 4+ 92 | return "com.runningwithcrayons.Alfred" 93 | 94 | 95 | def unicodify(s, encoding="utf-8", norm=None): 96 | """Ensure string is Unicode. 97 | 98 | .. versionadded:: 1.31 99 | 100 | Decode encoded strings using ``encoding`` and normalise Unicode 101 | to form ``norm`` if specified. 102 | 103 | Args: 104 | s (str): String to decode. May also be Unicode. 105 | encoding (str, optional): Encoding to use on bytestrings. 106 | norm (None, optional): Normalisation form to apply to Unicode string. 107 | 108 | Returns: 109 | unicode: Decoded, optionally normalised, Unicode string. 110 | 111 | """ 112 | if not isinstance(s, str): 113 | s = str(s, encoding) 114 | 115 | if norm: 116 | from unicodedata import normalize 117 | 118 | s = normalize(norm, s) 119 | 120 | return s 121 | 122 | 123 | def utf8ify(s): 124 | """Ensure string is a bytestring. 125 | 126 | .. versionadded:: 1.31 127 | 128 | Returns `str` objects unchanced, encodes `unicode` objects to 129 | UTF-8, and calls :func:`str` on anything else. 130 | 131 | Args: 132 | s (object): A Python object 133 | 134 | Returns: 135 | str: UTF-8 string or string representation of s. 136 | 137 | """ 138 | if isinstance(s, str): 139 | return s 140 | 141 | if isinstance(s, str): 142 | return s.encode("utf-8") 143 | 144 | return str(s) 145 | 146 | 147 | def applescriptify(s): 148 | """Escape string for insertion into an AppleScript string. 149 | 150 | .. versionadded:: 1.31 151 | 152 | Replaces ``"`` with `"& quote &"`. Use this function if you want 153 | to insert a string into an AppleScript script: 154 | 155 | >>> applescriptify('g "python" test') 156 | 'g " & quote & "python" & quote & "test' 157 | 158 | Args: 159 | s (unicode): Unicode string to escape. 160 | 161 | Returns: 162 | unicode: Escaped string. 163 | 164 | """ 165 | return s.replace('"', '" & quote & "') 166 | 167 | 168 | def run_command(cmd, **kwargs): 169 | """Run a command and return the output. 170 | 171 | .. versionadded:: 1.31 172 | 173 | A thin wrapper around :func:`subprocess.check_output` that ensures 174 | all arguments are encoded to UTF-8 first. 175 | 176 | Args: 177 | cmd (list): Command arguments to pass to :func:`~subprocess.check_output`. 178 | **kwargs: Keyword arguments to pass to :func:`~subprocess.check_output`. 179 | 180 | Returns: 181 | str: Output returned by :func:`~subprocess.check_output`. 182 | 183 | """ 184 | cmd = [str(s) for s in cmd] 185 | return subprocess.check_output(cmd, **kwargs).decode() 186 | 187 | 188 | def run_applescript(script, *args, **kwargs): 189 | """Execute an AppleScript script and return its output. 190 | 191 | .. versionadded:: 1.31 192 | 193 | Run AppleScript either by filepath or code. If ``script`` is a valid 194 | filepath, that script will be run, otherwise ``script`` is treated 195 | as code. 196 | 197 | Args: 198 | script (str, optional): Filepath of script or code to run. 199 | *args: Optional command-line arguments to pass to the script. 200 | **kwargs: Pass ``lang`` to run a language other than AppleScript. 201 | Any other keyword arguments are passed to :func:`run_command`. 202 | 203 | Returns: 204 | str: Output of run command. 205 | 206 | """ 207 | lang = "AppleScript" 208 | if "lang" in kwargs: 209 | lang = kwargs["lang"] 210 | del kwargs["lang"] 211 | 212 | cmd = ["/usr/bin/osascript", "-l", lang] 213 | 214 | if os.path.exists(script): 215 | cmd += [script] 216 | else: 217 | cmd += ["-e", script] 218 | 219 | cmd.extend(args) 220 | 221 | return run_command(cmd, **kwargs) 222 | 223 | 224 | def run_jxa(script, *args): 225 | """Execute a JXA script and return its output. 226 | 227 | .. versionadded:: 1.31 228 | 229 | Wrapper around :func:`run_applescript` that passes ``lang=JavaScript``. 230 | 231 | Args: 232 | script (str): Filepath of script or code to run. 233 | *args: Optional command-line arguments to pass to script. 234 | 235 | Returns: 236 | str: Output of script. 237 | 238 | """ 239 | return run_applescript(script, *args, lang="JavaScript") 240 | 241 | 242 | def run_trigger(name, bundleid=None, arg=None): 243 | """Call an Alfred External Trigger. 244 | 245 | .. versionadded:: 1.31 246 | 247 | If ``bundleid`` is not specified, the bundle ID of the calling 248 | workflow is used. 249 | 250 | Args: 251 | name (str): Name of External Trigger to call. 252 | bundleid (str, optional): Bundle ID of workflow trigger belongs to. 253 | arg (str, optional): Argument to pass to trigger. 254 | 255 | """ 256 | bundleid = bundleid or os.getenv("alfred_workflow_bundleid") 257 | appname = jxa_app_name() 258 | opts = {"inWorkflow": bundleid} 259 | if arg: 260 | opts["withArgument"] = arg 261 | 262 | script = JXA_TRIGGER.format( 263 | app=json.dumps(appname), 264 | arg=json.dumps(name), 265 | opts=json.dumps(opts, sort_keys=True), 266 | ) 267 | 268 | run_applescript(script, lang="JavaScript") 269 | 270 | 271 | def set_theme(theme_name): 272 | """Change Alfred's theme. 273 | 274 | .. versionadded:: 1.39.0 275 | 276 | Args: 277 | theme_name (unicode): Name of theme Alfred should use. 278 | 279 | """ 280 | appname = jxa_app_name() 281 | script = JXA_SET_THEME.format(app=json.dumps(appname), arg=json.dumps(theme_name)) 282 | run_applescript(script, lang="JavaScript") 283 | 284 | 285 | def set_config(name, value, bundleid=None, exportable=False): 286 | """Set a workflow variable in ``info.plist``. 287 | 288 | .. versionadded:: 1.33 289 | 290 | If ``bundleid`` is not specified, the bundle ID of the calling 291 | workflow is used. 292 | 293 | Args: 294 | name (str): Name of variable to set. 295 | value (str): Value to set variable to. 296 | bundleid (str, optional): Bundle ID of workflow variable belongs to. 297 | exportable (bool, optional): Whether variable should be marked 298 | as exportable (Don't Export checkbox). 299 | 300 | """ 301 | bundleid = bundleid or os.getenv("alfred_workflow_bundleid") 302 | appname = jxa_app_name() 303 | opts = {"toValue": value, "inWorkflow": bundleid, "exportable": exportable} 304 | 305 | script = JXA_SET_CONFIG.format( 306 | app=json.dumps(appname), 307 | arg=json.dumps(name), 308 | opts=json.dumps(opts, sort_keys=True), 309 | ) 310 | 311 | run_applescript(script, lang="JavaScript") 312 | 313 | 314 | def unset_config(name, bundleid=None): 315 | """Delete a workflow variable from ``info.plist``. 316 | 317 | .. versionadded:: 1.33 318 | 319 | If ``bundleid`` is not specified, the bundle ID of the calling 320 | workflow is used. 321 | 322 | Args: 323 | name (str): Name of variable to delete. 324 | bundleid (str, optional): Bundle ID of workflow variable belongs to. 325 | 326 | """ 327 | bundleid = bundleid or os.getenv("alfred_workflow_bundleid") 328 | appname = jxa_app_name() 329 | opts = {"inWorkflow": bundleid} 330 | 331 | script = JXA_UNSET_CONFIG.format( 332 | app=json.dumps(appname), 333 | arg=json.dumps(name), 334 | opts=json.dumps(opts, sort_keys=True), 335 | ) 336 | 337 | run_applescript(script, lang="JavaScript") 338 | 339 | 340 | def search_in_alfred(query=None): 341 | """Open Alfred with given search query. 342 | 343 | .. versionadded:: 1.39.0 344 | 345 | Omit ``query`` to simply open Alfred's main window. 346 | 347 | Args: 348 | query (unicode, optional): Search query. 349 | 350 | """ 351 | query = query or "" 352 | appname = jxa_app_name() 353 | script = JXA_SEARCH.format(app=json.dumps(appname), arg=json.dumps(query)) 354 | run_applescript(script, lang="JavaScript") 355 | 356 | 357 | def browse_in_alfred(path): 358 | """Open Alfred's filesystem navigation mode at ``path``. 359 | 360 | .. versionadded:: 1.39.0 361 | 362 | Args: 363 | path (unicode): File or directory path. 364 | 365 | """ 366 | appname = jxa_app_name() 367 | script = JXA_BROWSE.format(app=json.dumps(appname), arg=json.dumps(path)) 368 | run_applescript(script, lang="JavaScript") 369 | 370 | 371 | def action_in_alfred(paths): 372 | """Action the give filepaths in Alfred. 373 | 374 | .. versionadded:: 1.39.0 375 | 376 | Args: 377 | paths (list): Unicode paths to files/directories to action. 378 | 379 | """ 380 | appname = jxa_app_name() 381 | script = JXA_ACTION.format(app=json.dumps(appname), arg=json.dumps(paths)) 382 | run_applescript(script, lang="JavaScript") 383 | 384 | 385 | def reload_workflow(bundleid=None): 386 | """Tell Alfred to reload a workflow from disk. 387 | 388 | .. versionadded:: 1.39.0 389 | 390 | If ``bundleid`` is not specified, the bundle ID of the calling 391 | workflow is used. 392 | 393 | Args: 394 | bundleid (unicode, optional): Bundle ID of workflow to reload. 395 | 396 | """ 397 | bundleid = bundleid or os.getenv("alfred_workflow_bundleid") 398 | appname = jxa_app_name() 399 | script = JXA_RELOAD_WORKFLOW.format( 400 | app=json.dumps(appname), arg=json.dumps(bundleid) 401 | ) 402 | 403 | run_applescript(script, lang="JavaScript") 404 | 405 | 406 | def appinfo(name): 407 | """Get information about an installed application. 408 | 409 | .. versionadded:: 1.31 410 | 411 | Args: 412 | name (str): Name of application to look up. 413 | 414 | Returns: 415 | AppInfo: :class:`AppInfo` tuple or ``None`` if app isn't found. 416 | 417 | """ 418 | cmd = [ 419 | "mdfind", 420 | "-onlyin", 421 | "/Applications", 422 | "-onlyin", 423 | "/System/Applications", 424 | "-onlyin", 425 | os.path.expanduser("~/Applications"), 426 | "(kMDItemContentTypeTree == com.apple.application &&" 427 | '(kMDItemDisplayName == "{0}" || kMDItemFSName == "{0}.app"))'.format(name), 428 | ] 429 | 430 | output = run_command(cmd).strip() 431 | if not output: 432 | return None 433 | 434 | path = output.split("\n")[0] 435 | 436 | cmd = ["mdls", "-raw", "-name", "kMDItemCFBundleIdentifier", path] 437 | bid = run_command(cmd).strip() 438 | if not bid: # pragma: no cover 439 | return None 440 | 441 | return AppInfo(name, path, bid) 442 | 443 | 444 | @contextmanager 445 | def atomic_writer(fpath, mode): 446 | """Atomic file writer. 447 | 448 | .. versionadded:: 1.12 449 | 450 | Context manager that ensures the file is only written if the write 451 | succeeds. The data is first written to a temporary file. 452 | 453 | :param fpath: path of file to write to. 454 | :type fpath: ``unicode`` 455 | :param mode: sames as for :func:`open` 456 | :type mode: string 457 | 458 | """ 459 | suffix = ".{}.tmp".format(os.getpid()) 460 | temppath = fpath + suffix 461 | with open(temppath, mode) as fp: 462 | try: 463 | yield fp 464 | os.rename(temppath, fpath) 465 | finally: 466 | try: 467 | os.remove(temppath) 468 | except OSError: 469 | pass 470 | 471 | 472 | class LockFile(object): 473 | """Context manager to protect filepaths with lockfiles. 474 | 475 | .. versionadded:: 1.13 476 | 477 | Creates a lockfile alongside ``protected_path``. Other ``LockFile`` 478 | instances will refuse to lock the same path. 479 | 480 | >>> path = '/path/to/file' 481 | >>> with LockFile(path): 482 | >>> with open(path, 'w') as fp: 483 | >>> fp.write(data) 484 | 485 | Args: 486 | protected_path (unicode): File to protect with a lockfile 487 | timeout (float, optional): Raises an :class:`AcquisitionError` 488 | if lock cannot be acquired within this number of seconds. 489 | If ``timeout`` is 0 (the default), wait forever. 490 | delay (float, optional): How often to check (in seconds) if 491 | lock has been released. 492 | 493 | Attributes: 494 | delay (float): How often to check (in seconds) whether the lock 495 | can be acquired. 496 | lockfile (unicode): Path of the lockfile. 497 | timeout (float): How long to wait to acquire the lock. 498 | 499 | """ 500 | 501 | def __init__(self, protected_path, timeout=0.0, delay=0.05): 502 | """Create new :class:`LockFile` object.""" 503 | self.lockfile = protected_path + ".lock" 504 | self._lockfile = None 505 | self.timeout = timeout 506 | self.delay = delay 507 | self._lock = Event() 508 | atexit.register(self.release) 509 | 510 | @property 511 | def locked(self): 512 | """``True`` if file is locked by this instance.""" 513 | return self._lock.is_set() 514 | 515 | def acquire(self, blocking=True): 516 | """Acquire the lock if possible. 517 | 518 | If the lock is in use and ``blocking`` is ``False``, return 519 | ``False``. 520 | 521 | Otherwise, check every :attr:`delay` seconds until it acquires 522 | lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`. 523 | 524 | """ 525 | if self.locked and not blocking: 526 | return False 527 | 528 | start = time.time() 529 | while True: 530 | # Raise error if we've been waiting too long to acquire the lock 531 | if self.timeout and (time.time() - start) >= self.timeout: 532 | raise AcquisitionError("lock acquisition timed out") 533 | 534 | # If already locked, wait then try again 535 | if self.locked: 536 | time.sleep(self.delay) 537 | continue 538 | 539 | # Create in append mode so we don't lose any contents 540 | if self._lockfile is None: 541 | self._lockfile = open(self.lockfile, "a") 542 | 543 | # Try to acquire the lock 544 | try: 545 | fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB) 546 | self._lock.set() 547 | break 548 | except IOError as err: # pragma: no cover 549 | if err.errno not in (errno.EACCES, errno.EAGAIN): 550 | raise 551 | 552 | # Don't try again 553 | if not blocking: # pragma: no cover 554 | return False 555 | 556 | # Wait, then try again 557 | time.sleep(self.delay) 558 | 559 | return True 560 | 561 | def release(self): 562 | """Release the lock by deleting `self.lockfile`.""" 563 | if not self._lock.is_set(): 564 | return False 565 | 566 | try: 567 | fcntl.lockf(self._lockfile, fcntl.LOCK_UN) 568 | except IOError: # pragma: no cover 569 | pass 570 | finally: 571 | self._lock.clear() 572 | self._lockfile = None 573 | try: 574 | os.unlink(self.lockfile) 575 | except OSError: # pragma: no cover 576 | pass 577 | 578 | return True # noqa: B012 579 | 580 | def __enter__(self): 581 | """Acquire lock.""" 582 | self.acquire() 583 | return self 584 | 585 | def __exit__(self, typ, value, traceback): 586 | """Release lock.""" 587 | self.release() 588 | 589 | def __del__(self): 590 | """Clear up `self.lockfile`.""" 591 | self.release() # pragma: no cover 592 | 593 | 594 | class uninterruptible(object): 595 | """Decorator that postpones SIGTERM until wrapped function returns. 596 | 597 | .. versionadded:: 1.12 598 | 599 | .. important:: This decorator is NOT thread-safe. 600 | 601 | As of version 2.7, Alfred allows Script Filters to be killed. If 602 | your workflow is killed in the middle of critical code (e.g. 603 | writing data to disk), this may corrupt your workflow's data. 604 | 605 | Use this decorator to wrap critical functions that *must* complete. 606 | If the script is killed while a wrapped function is executing, 607 | the SIGTERM will be caught and handled after your function has 608 | finished executing. 609 | 610 | Alfred-Workflow uses this internally to ensure its settings, data 611 | and cache writes complete. 612 | 613 | """ 614 | 615 | def __init__(self, func, class_name=""): 616 | """Decorate `func`.""" 617 | self.func = func 618 | functools.update_wrapper(self, func) 619 | self._caught_signal = None 620 | 621 | def signal_handler(self, signum, frame): 622 | """Called when process receives SIGTERM.""" 623 | self._caught_signal = (signum, frame) 624 | 625 | def __call__(self, *args, **kwargs): 626 | """Trap ``SIGTERM`` and call wrapped function.""" 627 | self._caught_signal = None 628 | # Register handler for SIGTERM, then call `self.func` 629 | self.old_signal_handler = signal.getsignal(signal.SIGTERM) 630 | signal.signal(signal.SIGTERM, self.signal_handler) 631 | 632 | self.func(*args, **kwargs) 633 | 634 | # Restore old signal handler 635 | signal.signal(signal.SIGTERM, self.old_signal_handler) 636 | 637 | # Handle any signal caught during execution 638 | if self._caught_signal is not None: 639 | signum, frame = self._caught_signal 640 | if callable(self.old_signal_handler): 641 | self.old_signal_handler(signum, frame) 642 | elif self.old_signal_handler == signal.SIG_DFL: 643 | sys.exit(0) 644 | 645 | def __get__(self, obj=None, klass=None): 646 | """Decorator API.""" 647 | return self.__class__(self.func.__get__(obj, klass), klass.__name__) 648 | -------------------------------------------------------------------------------- /src/workflow/version: -------------------------------------------------------------------------------- 1 | 1.40.0 -------------------------------------------------------------------------------- /src/workflow/workflow.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2014 Dean Jackson 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2014-02-15 8 | # 9 | 10 | """The :class:`Workflow` object is the main interface to this library. 11 | 12 | :class:`Workflow` is targeted at Alfred 2. Use 13 | :class:`~workflow.Workflow3` if you want to use Alfred 3's new 14 | features, such as :ref:`workflow variables ` or 15 | more powerful modifiers. 16 | 17 | See :ref:`setup` in the :ref:`user-manual` for an example of how to set 18 | up your Python script to best utilise the :class:`Workflow` object. 19 | 20 | """ 21 | 22 | 23 | import binascii 24 | import json 25 | import logging 26 | import logging.handlers 27 | import os 28 | import pickle 29 | import plistlib 30 | import re 31 | import shutil 32 | import string 33 | import subprocess 34 | import sys 35 | import time 36 | import unicodedata 37 | from contextlib import contextmanager 38 | from copy import deepcopy 39 | from typing import Optional 40 | 41 | try: 42 | import xml.etree.cElementTree as ET 43 | except ImportError: # pragma: no cover 44 | import xml.etree.ElementTree as ET 45 | 46 | # imported to maintain API 47 | from workflow.util import AcquisitionError # noqa: F401 48 | from workflow.util import LockFile, atomic_writer, uninterruptible 49 | 50 | assert sys.version_info[0] == 3 51 | 52 | #: Sentinel for properties that haven't been set yet (that might 53 | #: correctly have the value ``None``) 54 | UNSET = object() 55 | 56 | #################################################################### 57 | # Standard system icons 58 | #################################################################### 59 | 60 | # These icons are default macOS icons. They are super-high quality, and 61 | # will be familiar to users. 62 | # This library uses `ICON_ERROR` when a workflow dies in flames, so 63 | # in my own workflows, I use `ICON_WARNING` for less fatal errors 64 | # (e.g. bad user input, no results etc.) 65 | 66 | # The system icons are all in this directory. There are many more than 67 | # are listed here 68 | 69 | ICON_ROOT = "/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources" 70 | 71 | ICON_ACCOUNT = os.path.join(ICON_ROOT, "Accounts.icns") 72 | ICON_BURN = os.path.join(ICON_ROOT, "BurningIcon.icns") 73 | ICON_CLOCK = os.path.join(ICON_ROOT, "Clock.icns") 74 | ICON_COLOR = os.path.join(ICON_ROOT, "ProfileBackgroundColor.icns") 75 | ICON_COLOUR = ICON_COLOR # Queen's English, if you please 76 | ICON_EJECT = os.path.join(ICON_ROOT, "EjectMediaIcon.icns") 77 | # Shown when a workflow throws an error 78 | ICON_ERROR = os.path.join(ICON_ROOT, "AlertStopIcon.icns") 79 | ICON_FAVORITE = os.path.join(ICON_ROOT, "ToolbarFavoritesIcon.icns") 80 | ICON_FAVOURITE = ICON_FAVORITE 81 | ICON_GROUP = os.path.join(ICON_ROOT, "GroupIcon.icns") 82 | ICON_HELP = os.path.join(ICON_ROOT, "HelpIcon.icns") 83 | ICON_HOME = os.path.join(ICON_ROOT, "HomeFolderIcon.icns") 84 | ICON_INFO = os.path.join(ICON_ROOT, "ToolbarInfo.icns") 85 | ICON_NETWORK = os.path.join(ICON_ROOT, "GenericNetworkIcon.icns") 86 | ICON_NOTE = os.path.join(ICON_ROOT, "AlertNoteIcon.icns") 87 | ICON_SETTINGS = os.path.join(ICON_ROOT, "ToolbarAdvanced.icns") 88 | ICON_SWIRL = os.path.join(ICON_ROOT, "ErasingIcon.icns") 89 | ICON_SWITCH = os.path.join(ICON_ROOT, "General.icns") 90 | ICON_SYNC = os.path.join(ICON_ROOT, "Sync.icns") 91 | ICON_TRASH = os.path.join(ICON_ROOT, "TrashIcon.icns") 92 | ICON_USER = os.path.join(ICON_ROOT, "UserIcon.icns") 93 | ICON_WARNING = os.path.join(ICON_ROOT, "AlertCautionIcon.icns") 94 | ICON_WEB = os.path.join(ICON_ROOT, "BookmarkIcon.icns") 95 | 96 | #################################################################### 97 | # non-ASCII to ASCII diacritic folding. 98 | # Used by `fold_to_ascii` method 99 | #################################################################### 100 | 101 | ASCII_REPLACEMENTS = { 102 | "À": "A", 103 | "Á": "A", 104 | "Â": "A", 105 | "Ã": "A", 106 | "Ä": "A", 107 | "Å": "A", 108 | "Æ": "AE", 109 | "Ç": "C", 110 | "È": "E", 111 | "É": "E", 112 | "Ê": "E", 113 | "Ë": "E", 114 | "Ì": "I", 115 | "Í": "I", 116 | "Î": "I", 117 | "Ï": "I", 118 | "Ð": "D", 119 | "Ñ": "N", 120 | "Ò": "O", 121 | "Ó": "O", 122 | "Ô": "O", 123 | "Õ": "O", 124 | "Ö": "O", 125 | "Ø": "O", 126 | "Ù": "U", 127 | "Ú": "U", 128 | "Û": "U", 129 | "Ü": "U", 130 | "Ý": "Y", 131 | "Þ": "Th", 132 | "ß": "ss", 133 | "à": "a", 134 | "á": "a", 135 | "â": "a", 136 | "ã": "a", 137 | "ä": "a", 138 | "å": "a", 139 | "æ": "ae", 140 | "ç": "c", 141 | "è": "e", 142 | "é": "e", 143 | "ê": "e", 144 | "ë": "e", 145 | "ì": "i", 146 | "í": "i", 147 | "î": "i", 148 | "ï": "i", 149 | "ð": "d", 150 | "ñ": "n", 151 | "ò": "o", 152 | "ó": "o", 153 | "ô": "o", 154 | "õ": "o", 155 | "ö": "o", 156 | "ø": "o", 157 | "ù": "u", 158 | "ú": "u", 159 | "û": "u", 160 | "ü": "u", 161 | "ý": "y", 162 | "þ": "th", 163 | "ÿ": "y", 164 | "Ł": "L", 165 | "ł": "l", 166 | "Ń": "N", 167 | "ń": "n", 168 | "Ņ": "N", 169 | "ņ": "n", 170 | "Ň": "N", 171 | "ň": "n", 172 | "Ŋ": "ng", 173 | "ŋ": "NG", 174 | "Ō": "O", 175 | "ō": "o", 176 | "Ŏ": "O", 177 | "ŏ": "o", 178 | "Ő": "O", 179 | "ő": "o", 180 | "Œ": "OE", 181 | "œ": "oe", 182 | "Ŕ": "R", 183 | "ŕ": "r", 184 | "Ŗ": "R", 185 | "ŗ": "r", 186 | "Ř": "R", 187 | "ř": "r", 188 | "Ś": "S", 189 | "ś": "s", 190 | "Ŝ": "S", 191 | "ŝ": "s", 192 | "Ş": "S", 193 | "ş": "s", 194 | "Š": "S", 195 | "š": "s", 196 | "Ţ": "T", 197 | "ţ": "t", 198 | "Ť": "T", 199 | "ť": "t", 200 | "Ŧ": "T", 201 | "ŧ": "t", 202 | "Ũ": "U", 203 | "ũ": "u", 204 | "Ū": "U", 205 | "ū": "u", 206 | "Ŭ": "U", 207 | "ŭ": "u", 208 | "Ů": "U", 209 | "ů": "u", 210 | "Ű": "U", 211 | "ű": "u", 212 | "Ŵ": "W", 213 | "ŵ": "w", 214 | "Ŷ": "Y", 215 | "ŷ": "y", 216 | "Ÿ": "Y", 217 | "Ź": "Z", 218 | "ź": "z", 219 | "Ż": "Z", 220 | "ż": "z", 221 | "Ž": "Z", 222 | "ž": "z", 223 | "ſ": "s", 224 | "Α": "A", 225 | "Β": "B", 226 | "Γ": "G", 227 | "Δ": "D", 228 | "Ε": "E", 229 | "Ζ": "Z", 230 | "Η": "E", 231 | "Θ": "Th", 232 | "Ι": "I", 233 | "Κ": "K", 234 | "Λ": "L", 235 | "Μ": "M", 236 | "Ν": "N", 237 | "Ξ": "Ks", 238 | "Ο": "O", 239 | "Π": "P", 240 | "Ρ": "R", 241 | "Σ": "S", 242 | "Τ": "T", 243 | "Υ": "U", 244 | "Φ": "Ph", 245 | "Χ": "Kh", 246 | "Ψ": "Ps", 247 | "Ω": "O", 248 | "α": "a", 249 | "β": "b", 250 | "γ": "g", 251 | "δ": "d", 252 | "ε": "e", 253 | "ζ": "z", 254 | "η": "e", 255 | "θ": "th", 256 | "ι": "i", 257 | "κ": "k", 258 | "λ": "l", 259 | "μ": "m", 260 | "ν": "n", 261 | "ξ": "x", 262 | "ο": "o", 263 | "π": "p", 264 | "ρ": "r", 265 | "ς": "s", 266 | "σ": "s", 267 | "τ": "t", 268 | "υ": "u", 269 | "φ": "ph", 270 | "χ": "kh", 271 | "ψ": "ps", 272 | "ω": "o", 273 | "А": "A", 274 | "Б": "B", 275 | "В": "V", 276 | "Г": "G", 277 | "Д": "D", 278 | "Е": "E", 279 | "Ж": "Zh", 280 | "З": "Z", 281 | "И": "I", 282 | "Й": "I", 283 | "К": "K", 284 | "Л": "L", 285 | "М": "M", 286 | "Н": "N", 287 | "О": "O", 288 | "П": "P", 289 | "Р": "R", 290 | "С": "S", 291 | "Т": "T", 292 | "У": "U", 293 | "Ф": "F", 294 | "Х": "Kh", 295 | "Ц": "Ts", 296 | "Ч": "Ch", 297 | "Ш": "Sh", 298 | "Щ": "Shch", 299 | "Ъ": "'", 300 | "Ы": "Y", 301 | "Ь": "'", 302 | "Э": "E", 303 | "Ю": "Iu", 304 | "Я": "Ia", 305 | "а": "a", 306 | "б": "b", 307 | "в": "v", 308 | "г": "g", 309 | "д": "d", 310 | "е": "e", 311 | "ж": "zh", 312 | "з": "z", 313 | "и": "i", 314 | "й": "i", 315 | "к": "k", 316 | "л": "l", 317 | "м": "m", 318 | "н": "n", 319 | "о": "o", 320 | "п": "p", 321 | "р": "r", 322 | "с": "s", 323 | "т": "t", 324 | "у": "u", 325 | "ф": "f", 326 | "х": "kh", 327 | "ц": "ts", 328 | "ч": "ch", 329 | "ш": "sh", 330 | "щ": "shch", 331 | "ъ": "'", 332 | "ы": "y", 333 | "ь": "'", 334 | "э": "e", 335 | "ю": "iu", 336 | "я": "ia", 337 | # 'ᴀ': '', 338 | # 'ᴁ': '', 339 | # 'ᴂ': '', 340 | # 'ᴃ': '', 341 | # 'ᴄ': '', 342 | # 'ᴅ': '', 343 | # 'ᴆ': '', 344 | # 'ᴇ': '', 345 | # 'ᴈ': '', 346 | # 'ᴉ': '', 347 | # 'ᴊ': '', 348 | # 'ᴋ': '', 349 | # 'ᴌ': '', 350 | # 'ᴍ': '', 351 | # 'ᴎ': '', 352 | # 'ᴏ': '', 353 | # 'ᴐ': '', 354 | # 'ᴑ': '', 355 | # 'ᴒ': '', 356 | # 'ᴓ': '', 357 | # 'ᴔ': '', 358 | # 'ᴕ': '', 359 | # 'ᴖ': '', 360 | # 'ᴗ': '', 361 | # 'ᴘ': '', 362 | # 'ᴙ': '', 363 | # 'ᴚ': '', 364 | # 'ᴛ': '', 365 | # 'ᴜ': '', 366 | # 'ᴝ': '', 367 | # 'ᴞ': '', 368 | # 'ᴟ': '', 369 | # 'ᴠ': '', 370 | # 'ᴡ': '', 371 | # 'ᴢ': '', 372 | # 'ᴣ': '', 373 | # 'ᴤ': '', 374 | # 'ᴥ': '', 375 | "ᴦ": "G", 376 | "ᴧ": "L", 377 | "ᴨ": "P", 378 | "ᴩ": "R", 379 | "ᴪ": "PS", 380 | "ẞ": "Ss", 381 | "Ỳ": "Y", 382 | "ỳ": "y", 383 | "Ỵ": "Y", 384 | "ỵ": "y", 385 | "Ỹ": "Y", 386 | "ỹ": "y", 387 | } 388 | 389 | #################################################################### 390 | # Smart-to-dumb punctuation mapping 391 | #################################################################### 392 | 393 | DUMB_PUNCTUATION = { 394 | "‘": "'", 395 | "’": "'", 396 | "‚": "'", 397 | "“": '"', 398 | "”": '"', 399 | "„": '"', 400 | "–": "-", 401 | "—": "-", 402 | } 403 | 404 | 405 | #################################################################### 406 | # Used by `Workflow.filter` 407 | #################################################################### 408 | 409 | # Anchor characters in a name 410 | #: Characters that indicate the beginning of a "word" in CamelCase 411 | INITIALS = string.ascii_uppercase + string.digits 412 | 413 | #: Split on non-letters, numbers 414 | split_on_delimiters = re.compile("[^a-zA-Z0-9]").split 415 | 416 | # Match filter flags 417 | #: Match items that start with ``query`` 418 | MATCH_STARTSWITH = 1 419 | #: Match items whose capital letters start with ``query`` 420 | MATCH_CAPITALS = 2 421 | #: Match items with a component "word" that matches ``query`` 422 | MATCH_ATOM = 4 423 | #: Match items whose initials (based on atoms) start with ``query`` 424 | MATCH_INITIALS_STARTSWITH = 8 425 | #: Match items whose initials (based on atoms) contain ``query`` 426 | MATCH_INITIALS_CONTAIN = 16 427 | #: Combination of :const:`MATCH_INITIALS_STARTSWITH` and 428 | #: :const:`MATCH_INITIALS_CONTAIN` 429 | MATCH_INITIALS = 24 430 | #: Match items if ``query`` is a substring 431 | MATCH_SUBSTRING = 32 432 | #: Match items if all characters in ``query`` appear in the item in order 433 | MATCH_ALLCHARS = 64 434 | #: Combination of all other ``MATCH_*`` constants 435 | MATCH_ALL = 127 436 | 437 | 438 | #################################################################### 439 | # Used by `Workflow.check_update` 440 | #################################################################### 441 | 442 | # Number of days to wait between checking for updates to the workflow 443 | DEFAULT_UPDATE_FREQUENCY = 1 444 | 445 | 446 | #################################################################### 447 | # Keychain access errors 448 | #################################################################### 449 | 450 | 451 | class KeychainError(Exception): 452 | """Raised for unknown Keychain errors. 453 | 454 | Raised by methods :meth:`Workflow.save_password`, 455 | :meth:`Workflow.get_password` and :meth:`Workflow.delete_password` 456 | when ``security`` CLI app returns an unknown error code. 457 | 458 | """ 459 | 460 | 461 | class PasswordNotFound(KeychainError): 462 | """Password not in Keychain. 463 | 464 | Raised by method :meth:`Workflow.get_password` when ``account`` 465 | is unknown to the Keychain. 466 | 467 | """ 468 | 469 | 470 | class PasswordExists(KeychainError): 471 | """Raised when trying to overwrite an existing account password. 472 | 473 | You should never receive this error: it is used internally 474 | by the :meth:`Workflow.save_password` method to know if it needs 475 | to delete the old password first (a Keychain implementation detail). 476 | 477 | """ 478 | 479 | 480 | #################################################################### 481 | # Helper functions 482 | #################################################################### 483 | 484 | 485 | def isascii(text): 486 | """Test if ``text`` contains only ASCII characters. 487 | 488 | :param text: text to test for ASCII-ness 489 | :type text: ``unicode`` 490 | :returns: ``True`` if ``text`` contains only ASCII characters 491 | :rtype: ``Boolean`` 492 | 493 | """ 494 | try: 495 | text.encode("ascii") 496 | except UnicodeEncodeError: 497 | return False 498 | return True 499 | 500 | 501 | #################################################################### 502 | # Implementation classes 503 | #################################################################### 504 | 505 | 506 | class SerializerManager(object): 507 | """Contains registered serializers. 508 | 509 | .. versionadded:: 1.8 510 | 511 | A configured instance of this class is available at 512 | :attr:`workflow.manager`. 513 | 514 | Use :meth:`register()` to register new (or replace 515 | existing) serializers, which you can specify by name when calling 516 | :class:`~workflow.Workflow` data storage methods. 517 | 518 | See :ref:`guide-serialization` and :ref:`guide-persistent-data` 519 | for further information. 520 | 521 | """ 522 | 523 | def __init__(self): 524 | """Create new SerializerManager object.""" 525 | self._serializers = {} 526 | 527 | def register(self, name, serializer): 528 | """Register ``serializer`` object under ``name``. 529 | 530 | Raises :class:`AttributeError` if ``serializer`` in invalid. 531 | 532 | .. note:: 533 | 534 | ``name`` will be used as the file extension of the saved files. 535 | 536 | :param name: Name to register ``serializer`` under 537 | :type name: ``unicode`` or ``str`` 538 | :param serializer: object with ``load()`` and ``dump()`` 539 | methods 540 | 541 | """ 542 | # Basic validation 543 | serializer.load 544 | serializer.dump 545 | 546 | self._serializers[name] = serializer 547 | 548 | def serializer(self, name): 549 | """Return serializer object for ``name``. 550 | 551 | :param name: Name of serializer to return 552 | :type name: ``unicode`` or ``str`` 553 | :returns: serializer object or ``None`` if no such serializer 554 | is registered. 555 | 556 | """ 557 | return self._serializers.get(name) 558 | 559 | def unregister(self, name): 560 | """Remove registered serializer with ``name``. 561 | 562 | Raises a :class:`ValueError` if there is no such registered 563 | serializer. 564 | 565 | :param name: Name of serializer to remove 566 | :type name: ``unicode`` or ``str`` 567 | :returns: serializer object 568 | 569 | """ 570 | if name not in self._serializers: 571 | raise ValueError("No such serializer registered : {0}".format(name)) 572 | 573 | serializer = self._serializers[name] 574 | del self._serializers[name] 575 | 576 | return serializer 577 | 578 | @property 579 | def serializers(self): 580 | """Return names of registered serializers.""" 581 | return sorted(self._serializers.keys()) 582 | 583 | 584 | class BaseSerializer: 585 | is_binary: Optional[bool] = None 586 | 587 | @classmethod 588 | def binary_mode(cls): 589 | return "b" if cls.is_binary else "" 590 | 591 | @classmethod 592 | def _opener(cls, opener, path, mode="r"): 593 | with opener(path, mode + cls.binary_mode()) as fp: 594 | yield fp 595 | 596 | @classmethod 597 | @contextmanager 598 | def atomic_writer(cls, path, mode): 599 | yield from cls._opener(atomic_writer, path, mode) 600 | 601 | @classmethod 602 | @contextmanager 603 | def open(cls, path, mode): 604 | yield from cls._opener(open, path, mode) 605 | 606 | 607 | class JSONSerializer(BaseSerializer): 608 | """Wrapper around :mod:`json`. Sets ``indent`` and ``encoding``. 609 | 610 | .. versionadded:: 1.8 611 | 612 | Use this serializer if you need readable data files. JSON doesn't 613 | support Python objects as well as ``pickle``, so be 614 | careful which data you try to serialize as JSON. 615 | 616 | """ 617 | 618 | is_binary = False 619 | 620 | @classmethod 621 | def load(cls, file_obj): 622 | """Load serialized object from open JSON file. 623 | 624 | .. versionadded:: 1.8 625 | 626 | :param file_obj: file handle 627 | :type file_obj: ``file`` object 628 | :returns: object loaded from JSON file 629 | :rtype: object 630 | 631 | """ 632 | return json.load(file_obj) 633 | 634 | @classmethod 635 | def dump(cls, obj, file_obj): 636 | """Serialize object ``obj`` to open JSON file. 637 | 638 | .. versionadded:: 1.8 639 | 640 | :param obj: Python object to serialize 641 | :type obj: JSON-serializable data structure 642 | :param file_obj: file handle 643 | :type file_obj: ``file`` object 644 | 645 | """ 646 | return json.dump(obj, file_obj, indent=2) 647 | 648 | 649 | class PickleSerializer(BaseSerializer): 650 | """Wrapper around :mod:`pickle`. Sets ``protocol``. 651 | 652 | .. versionadded:: 1.8 653 | 654 | Use this serializer if you need to add custom pickling. 655 | 656 | """ 657 | 658 | is_binary = True 659 | 660 | @classmethod 661 | def load(cls, file_obj): 662 | """Load serialized object from open pickle file. 663 | 664 | .. versionadded:: 1.8 665 | 666 | :param file_obj: file handle 667 | :type file_obj: ``file`` object 668 | :returns: object loaded from pickle file 669 | :rtype: object 670 | 671 | """ 672 | return pickle.load(file_obj) 673 | 674 | @classmethod 675 | def dump(cls, obj, file_obj): 676 | """Serialize object ``obj`` to open pickle file. 677 | 678 | .. versionadded:: 1.8 679 | 680 | :param obj: Python object to serialize 681 | :type obj: Python object 682 | :param file_obj: file handle 683 | :type file_obj: ``file`` object 684 | 685 | """ 686 | return pickle.dump(obj, file_obj, protocol=-1) 687 | 688 | 689 | # Set up default manager and register built-in serializers 690 | manager = SerializerManager() 691 | manager.register("pickle", PickleSerializer) 692 | manager.register("json", JSONSerializer) 693 | 694 | 695 | class Item(object): 696 | """Represents a feedback item for Alfred. 697 | 698 | Generates Alfred-compliant XML for a single item. 699 | 700 | You probably shouldn't use this class directly, but via 701 | :meth:`Workflow.add_item`. See :meth:`~Workflow.add_item` 702 | for details of arguments. 703 | 704 | """ 705 | 706 | def __init__( 707 | self, 708 | title, 709 | subtitle="", 710 | modifier_subtitles=None, 711 | arg=None, 712 | autocomplete=None, 713 | valid=False, 714 | uid=None, 715 | icon=None, 716 | icontype=None, 717 | type=None, 718 | largetext=None, 719 | copytext=None, 720 | quicklookurl=None, 721 | ): 722 | """Same arguments as :meth:`Workflow.add_item`.""" 723 | self.title = title 724 | self.subtitle = subtitle 725 | self.modifier_subtitles = modifier_subtitles or {} 726 | self.arg = arg 727 | self.autocomplete = autocomplete 728 | self.valid = valid 729 | self.uid = uid 730 | self.icon = icon 731 | self.icontype = icontype 732 | self.type = type 733 | self.largetext = largetext 734 | self.copytext = copytext 735 | self.quicklookurl = quicklookurl 736 | 737 | @property 738 | def elem(self): 739 | """Create and return feedback item for Alfred. 740 | 741 | :returns: :class:`ElementTree.Element ` 742 | instance for this :class:`Item` instance. 743 | 744 | """ 745 | # Attributes on element 746 | attr = {} 747 | if self.valid: 748 | attr["valid"] = "yes" 749 | else: 750 | attr["valid"] = "no" 751 | # Allow empty string for autocomplete. This is a useful value, 752 | # as TABing the result will revert the query back to just the 753 | # keyword 754 | if self.autocomplete is not None: 755 | attr["autocomplete"] = self.autocomplete 756 | 757 | # Optional attributes 758 | for name in ("uid", "type"): 759 | value = getattr(self, name, None) 760 | if value: 761 | attr[name] = value 762 | 763 | root = ET.Element("item", attr) 764 | ET.SubElement(root, "title").text = self.title 765 | ET.SubElement(root, "subtitle").text = self.subtitle 766 | 767 | # Add modifier subtitles 768 | for mod in ("cmd", "ctrl", "alt", "shift", "fn"): 769 | if mod in self.modifier_subtitles: 770 | ET.SubElement( 771 | root, "subtitle", {"mod": mod} 772 | ).text = self.modifier_subtitles[mod] 773 | 774 | # Add arg as element instead of attribute on , as it's more 775 | # flexible (newlines aren't allowed in attributes) 776 | if self.arg: 777 | ET.SubElement(root, "arg").text = self.arg 778 | 779 | # Add icon if there is one 780 | if self.icon: 781 | if self.icontype: 782 | attr = dict(type=self.icontype) 783 | else: 784 | attr = {} 785 | ET.SubElement(root, "icon", attr).text = self.icon 786 | 787 | if self.largetext: 788 | ET.SubElement(root, "text", {"type": "largetype"}).text = self.largetext 789 | 790 | if self.copytext: 791 | ET.SubElement(root, "text", {"type": "copy"}).text = self.copytext 792 | 793 | if self.quicklookurl: 794 | ET.SubElement(root, "quicklookurl").text = self.quicklookurl 795 | 796 | return root 797 | 798 | 799 | class Settings(dict): 800 | """A dictionary that saves itself when changed. 801 | 802 | Dictionary keys & values will be saved as a JSON file 803 | at ``filepath``. If the file does not exist, the dictionary 804 | (and settings file) will be initialised with ``defaults``. 805 | 806 | :param filepath: where to save the settings 807 | :type filepath: :class:`unicode` 808 | :param defaults: dict of default settings 809 | :type defaults: :class:`dict` 810 | 811 | 812 | An appropriate instance is provided by :class:`Workflow` instances at 813 | :attr:`Workflow.settings`. 814 | 815 | """ 816 | 817 | def __init__(self, filepath, defaults=None): 818 | """Create new :class:`Settings` object.""" 819 | super(Settings, self).__init__() 820 | self._filepath = filepath 821 | self._nosave = False 822 | self._original = {} 823 | if os.path.exists(self._filepath): 824 | self._load() 825 | elif defaults: 826 | for key, val in list(defaults.items()): 827 | self[key] = val 828 | self.save() # save default settings 829 | 830 | def _load(self): 831 | """Load cached settings from JSON file `self._filepath`.""" 832 | data = {} 833 | with LockFile(self._filepath, 0.5): 834 | with open(self._filepath, "r") as fp: 835 | data.update(json.load(fp)) 836 | 837 | self._original = deepcopy(data) 838 | 839 | self._nosave = True 840 | self.update(data) 841 | self._nosave = False 842 | 843 | @uninterruptible 844 | def save(self): 845 | """Save settings to JSON file specified in ``self._filepath``. 846 | 847 | If you're using this class via :attr:`Workflow.settings`, which 848 | you probably are, ``self._filepath`` will be ``settings.json`` 849 | in your workflow's data directory (see :attr:`~Workflow.datadir`). 850 | """ 851 | if self._nosave: 852 | return 853 | 854 | data = {} 855 | data.update(self) 856 | 857 | with LockFile(self._filepath, 0.5): 858 | with atomic_writer(self._filepath, "w") as fp: 859 | json.dump(data, fp, sort_keys=True, indent=2) 860 | 861 | # dict methods 862 | def __setitem__(self, key, value): 863 | """Implement :class:`dict` interface.""" 864 | if self._original.get(key) != value: 865 | super(Settings, self).__setitem__(key, value) 866 | self.save() 867 | 868 | def __delitem__(self, key): 869 | """Implement :class:`dict` interface.""" 870 | super(Settings, self).__delitem__(key) 871 | self.save() 872 | 873 | def update(self, *args, **kwargs): 874 | """Override :class:`dict` method to save on update.""" 875 | super(Settings, self).update(*args, **kwargs) 876 | self.save() 877 | 878 | def setdefault(self, key, value=None): 879 | """Override :class:`dict` method to save on update.""" 880 | ret = super(Settings, self).setdefault(key, value) 881 | self.save() 882 | return ret 883 | 884 | 885 | class Workflow(object): 886 | """The ``Workflow`` object is the main interface to Alfred-Workflow. 887 | 888 | It provides APIs for accessing the Alfred/workflow environment, 889 | storing & caching data, using Keychain, and generating Script 890 | Filter feedback. 891 | 892 | ``Workflow`` is compatible with Alfred 2+. Subclass 893 | :class:`~workflow.Workflow3` provides additional features, 894 | only available in Alfred 3+, such as workflow variables. 895 | 896 | :param default_settings: default workflow settings. If no settings file 897 | exists, :class:`Workflow.settings` will be pre-populated with 898 | ``default_settings``. 899 | :type default_settings: :class:`dict` 900 | :param update_settings: settings for updating your workflow from 901 | GitHub releases. The only required key is ``github_slug``, 902 | whose value must take the form of ``username/repo``. 903 | If specified, ``Workflow`` will check the repo's releases 904 | for updates. Your workflow must also have a semantic version 905 | number. Please see the :ref:`User Manual ` and 906 | `update API docs ` for more information. 907 | :type update_settings: :class:`dict` 908 | :param input_encoding: encoding of command line arguments. You 909 | should probably leave this as the default (``utf-8``), which 910 | is the encoding Alfred uses. 911 | :type input_encoding: :class:`unicode` 912 | :param normalization: normalisation to apply to CLI args. 913 | See :meth:`Workflow.decode` for more details. 914 | :type normalization: :class:`unicode` 915 | :param capture_args: Capture and act on ``workflow:*`` arguments. See 916 | :ref:`Magic arguments ` for details. 917 | :type capture_args: :class:`Boolean` 918 | :param libraries: sequence of paths to directories containing 919 | libraries. These paths will be prepended to ``sys.path``. 920 | :type libraries: :class:`tuple` or :class:`list` 921 | :param help_url: URL to webpage where a user can ask for help with 922 | the workflow, report bugs, etc. This could be the GitHub repo 923 | or a page on AlfredForum.com. If your workflow throws an error, 924 | this URL will be displayed in the log and Alfred's debugger. It can 925 | also be opened directly in a web browser with the ``workflow:help`` 926 | :ref:`magic argument `. 927 | :type help_url: :class:`unicode` or :class:`str` 928 | 929 | """ 930 | 931 | # Which class to use to generate feedback items. You probably 932 | # won't want to change this 933 | item_class = Item 934 | 935 | def __init__( 936 | self, 937 | default_settings=None, 938 | update_settings=None, 939 | input_encoding="utf-8", 940 | normalization="NFC", 941 | capture_args=True, 942 | libraries=None, 943 | help_url=None, 944 | ): 945 | """Create new :class:`Workflow` object.""" 946 | 947 | seralizer = "pickle" 948 | 949 | self._default_settings = default_settings or {} 950 | self._update_settings = update_settings or {} 951 | self._input_encoding = input_encoding 952 | self._normalizsation = normalization 953 | self._capture_args = capture_args 954 | self.help_url = help_url 955 | self._workflowdir = None 956 | self._settings_path = None 957 | self._settings = None 958 | self._bundleid = None 959 | self._debugging = None 960 | self._name = None 961 | self._cache_serializer = seralizer 962 | self._data_serializer = seralizer 963 | self._info = None 964 | self._info_loaded = False 965 | self._logger = None 966 | self._items = [] 967 | self._alfred_env = None 968 | # Version number of the workflow 969 | self._version = UNSET 970 | # Version from last workflow run 971 | self._last_version_run = UNSET 972 | # Cache for regex patterns created for filter keys 973 | self._search_pattern_cache = {} 974 | #: Prefix for all magic arguments. 975 | #: The default value is ``workflow:`` so keyword 976 | #: ``config`` would match user query ``workflow:config``. 977 | self.magic_prefix = "workflow:" 978 | #: Mapping of available magic arguments. The built-in magic 979 | #: arguments are registered by default. To add your own magic arguments 980 | #: (or override built-ins), add a key:value pair where the key is 981 | #: what the user should enter (prefixed with :attr:`magic_prefix`) 982 | #: and the value is a callable that will be called when the argument 983 | #: is entered. If you would like to display a message in Alfred, the 984 | #: function should return a ``unicode`` string. 985 | #: 986 | #: By default, the magic arguments documented 987 | #: :ref:`here ` are registered. 988 | self.magic_arguments = {} 989 | 990 | self._register_default_magic() 991 | 992 | if libraries: 993 | sys.path = libraries + sys.path 994 | 995 | #################################################################### 996 | # API methods 997 | #################################################################### 998 | 999 | # info.plist contents and alfred_* environment variables ---------- 1000 | 1001 | @property 1002 | def alfred_version(self): 1003 | """Alfred version as :class:`~workflow.update.Version` object.""" 1004 | from .update import Version 1005 | 1006 | return Version(self.alfred_env.get("version")) 1007 | 1008 | @property 1009 | def alfred_env(self): 1010 | """Dict of Alfred's environmental variables minus ``alfred_`` prefix. 1011 | 1012 | .. versionadded:: 1.7 1013 | 1014 | The variables Alfred 2.4+ exports are: 1015 | 1016 | ============================ ========================================= 1017 | Variable Description 1018 | ============================ ========================================= 1019 | debug Set to ``1`` if Alfred's debugger is 1020 | open, otherwise unset. 1021 | preferences Path to Alfred.alfredpreferences 1022 | (where your workflows and settings are 1023 | stored). 1024 | preferences_localhash Machine-specific preferences are stored 1025 | in ``Alfred.alfredpreferences/preferences/local/`` 1026 | (see ``preferences`` above for 1027 | the path to ``Alfred.alfredpreferences``) 1028 | theme ID of selected theme 1029 | theme_background Background colour of selected theme in 1030 | format ``rgba(r,g,b,a)`` 1031 | theme_subtext Show result subtext. 1032 | ``0`` = Always, 1033 | ``1`` = Alternative actions only, 1034 | ``2`` = Selected result only, 1035 | ``3`` = Never 1036 | version Alfred version number, e.g. ``'2.4'`` 1037 | version_build Alfred build number, e.g. ``277`` 1038 | workflow_bundleid Bundle ID, e.g. 1039 | ``net.deanishe.alfred-mailto`` 1040 | workflow_cache Path to workflow's cache directory 1041 | workflow_data Path to workflow's data directory 1042 | workflow_name Name of current workflow 1043 | workflow_uid UID of workflow 1044 | workflow_version The version number specified in the 1045 | workflow configuration sheet/info.plist 1046 | ============================ ========================================= 1047 | 1048 | **Note:** all values are Unicode strings except ``version_build`` and 1049 | ``theme_subtext``, which are integers. 1050 | 1051 | :returns: ``dict`` of Alfred's environmental variables without the 1052 | ``alfred_`` prefix, e.g. ``preferences``, ``workflow_data``. 1053 | 1054 | """ 1055 | if self._alfred_env is not None: 1056 | return self._alfred_env 1057 | 1058 | data = {} 1059 | 1060 | for key in ( 1061 | "debug", 1062 | "preferences", 1063 | "preferences_localhash", 1064 | "theme", 1065 | "theme_background", 1066 | "theme_subtext", 1067 | "version", 1068 | "version_build", 1069 | "workflow_bundleid", 1070 | "workflow_cache", 1071 | "workflow_data", 1072 | "workflow_name", 1073 | "workflow_uid", 1074 | "workflow_version", 1075 | ): 1076 | 1077 | value = os.getenv("alfred_" + key, "") 1078 | 1079 | if value: 1080 | if key in ("debug", "version_build", "theme_subtext"): 1081 | if value.isdigit(): 1082 | value = int(value) 1083 | else: 1084 | value = False 1085 | else: 1086 | value = self.decode(value) 1087 | 1088 | data[key] = value 1089 | 1090 | self._alfred_env = data 1091 | 1092 | return self._alfred_env 1093 | 1094 | @property 1095 | def info(self): 1096 | """:class:`dict` of ``info.plist`` contents.""" 1097 | if not self._info_loaded: 1098 | self._load_info_plist() 1099 | return self._info 1100 | 1101 | @property 1102 | def bundleid(self): 1103 | """Workflow bundle ID from environmental vars or ``info.plist``. 1104 | 1105 | :returns: bundle ID 1106 | :rtype: ``unicode`` 1107 | 1108 | """ 1109 | if not self._bundleid: 1110 | if self.alfred_env.get("workflow_bundleid"): 1111 | self._bundleid = self.alfred_env.get("workflow_bundleid") 1112 | else: 1113 | self._bundleid = self.info["bundleid"] 1114 | 1115 | return self._bundleid 1116 | 1117 | @property 1118 | def debugging(self): 1119 | """Whether Alfred's debugger is open. 1120 | 1121 | :returns: ``True`` if Alfred's debugger is open. 1122 | :rtype: ``bool`` 1123 | 1124 | """ 1125 | return bool( 1126 | self.alfred_env.get("debug") == 1 or os.environ.get("PYTEST_RUNNING") 1127 | ) 1128 | 1129 | @property 1130 | def name(self): 1131 | """Workflow name from Alfred's environmental vars or ``info.plist``. 1132 | 1133 | :returns: workflow name 1134 | :rtype: ``unicode`` 1135 | 1136 | """ 1137 | if not self._name: 1138 | if self.alfred_env.get("workflow_name"): 1139 | self._name = self.decode(self.alfred_env.get("workflow_name")) 1140 | else: 1141 | self._name = self.decode(self.info["name"]) 1142 | 1143 | return self._name 1144 | 1145 | @property 1146 | def version(self): 1147 | """Return the version of the workflow. 1148 | 1149 | .. versionadded:: 1.9.10 1150 | 1151 | Get the workflow version from environment variable, 1152 | the ``update_settings`` dict passed on 1153 | instantiation, the ``version`` file located in the workflow's 1154 | root directory or ``info.plist``. Return ``None`` if none 1155 | exists or :class:`ValueError` if the version number is invalid 1156 | (i.e. not semantic). 1157 | 1158 | :returns: Version of the workflow (not Alfred-Workflow) 1159 | :rtype: :class:`~workflow.update.Version` object 1160 | 1161 | """ 1162 | if self._version is UNSET: 1163 | 1164 | version = None 1165 | # environment variable has priority 1166 | if self.alfred_env.get("workflow_version"): 1167 | version = self.alfred_env["workflow_version"] 1168 | 1169 | # Try `update_settings` 1170 | elif self._update_settings: 1171 | version = self._update_settings.get("version") 1172 | 1173 | # `version` file 1174 | if not version: 1175 | filepath = self.workflowfile("version") 1176 | 1177 | if os.path.exists(filepath): 1178 | with open(filepath, "r") as fileobj: 1179 | version = fileobj.read() 1180 | 1181 | # info.plist 1182 | if not version: 1183 | version = self.info.get("version") 1184 | 1185 | if version: 1186 | from .update import Version 1187 | 1188 | version = Version(version) 1189 | 1190 | self._version = version 1191 | 1192 | return self._version 1193 | 1194 | # Workflow utility methods ----------------------------------------- 1195 | 1196 | @property 1197 | def args(self): 1198 | """Return command line args as normalised unicode. 1199 | 1200 | Args are decoded and normalised via :meth:`~Workflow.decode`. 1201 | 1202 | The encoding and normalisation are the ``input_encoding`` and 1203 | ``normalization`` arguments passed to :class:`Workflow` (``UTF-8`` 1204 | and ``NFC`` are the defaults). 1205 | 1206 | If :class:`Workflow` is called with ``capture_args=True`` 1207 | (the default), :class:`Workflow` will look for certain 1208 | ``workflow:*`` args and, if found, perform the corresponding 1209 | actions and exit the workflow. 1210 | 1211 | See :ref:`Magic arguments ` for details. 1212 | 1213 | """ 1214 | msg = None 1215 | args = [self.decode(arg) for arg in sys.argv[1:]] 1216 | 1217 | # Handle magic args 1218 | if len(args) and self._capture_args: 1219 | for name in self.magic_arguments: 1220 | key = "{0}{1}".format(self.magic_prefix, name) 1221 | if key in args: 1222 | msg = self.magic_arguments[name]() 1223 | 1224 | if msg: 1225 | self.logger.debug(msg) 1226 | if not sys.stdout.isatty(): # Show message in Alfred 1227 | self.add_item(msg, valid=False, icon=ICON_INFO) 1228 | self.send_feedback() 1229 | sys.exit(0) 1230 | return args 1231 | 1232 | @property 1233 | def cachedir(self): 1234 | """Path to workflow's cache directory. 1235 | 1236 | The cache directory is a subdirectory of Alfred's own cache directory 1237 | in ``~/Library/Caches``. The full path is in Alfred 4+ is: 1238 | 1239 | ``~/Library/Caches/com.runningwithcrayons.Alfred/Workflow Data/`` 1240 | 1241 | For earlier versions: 1242 | 1243 | ``~/Library/Caches/com.runningwithcrayons.Alfred-X/Workflow Data/`` 1244 | 1245 | where ``Alfred-X`` may be ``Alfred-2`` or ``Alfred-3``. 1246 | 1247 | Returns: 1248 | unicode: full path to workflow's cache directory 1249 | 1250 | """ 1251 | if self.alfred_env.get("workflow_cache"): 1252 | dirpath = self.alfred_env.get("workflow_cache") 1253 | 1254 | else: 1255 | dirpath = self._default_cachedir 1256 | 1257 | return self._create(dirpath) 1258 | 1259 | @property 1260 | def _default_cachedir(self): 1261 | """Alfred 2's default cache directory.""" 1262 | return os.path.join( 1263 | os.path.expanduser( 1264 | "~/Library/Caches/com.runningwithcrayons.Alfred-2/" "Workflow Data/" 1265 | ), 1266 | self.bundleid, 1267 | ) 1268 | 1269 | @property 1270 | def datadir(self): 1271 | """Path to workflow's data directory. 1272 | 1273 | The data directory is a subdirectory of Alfred's own data directory in 1274 | ``~/Library/Application Support``. The full path for Alfred 4+ is: 1275 | 1276 | ``~/Library/Application Support/Alfred/Workflow Data/`` 1277 | 1278 | For earlier versions, the path is: 1279 | 1280 | ``~/Library/Application Support/Alfred X/Workflow Data/`` 1281 | 1282 | where ``Alfred X` is ``Alfred 2`` or ``Alfred 3``. 1283 | 1284 | Returns: 1285 | unicode: full path to workflow data directory 1286 | 1287 | """ 1288 | if self.alfred_env.get("workflow_data"): 1289 | dirpath = self.alfred_env.get("workflow_data") 1290 | 1291 | else: 1292 | dirpath = self._default_datadir 1293 | 1294 | return self._create(dirpath) 1295 | 1296 | @property 1297 | def _default_datadir(self): 1298 | """Alfred 2's default data directory.""" 1299 | return os.path.join( 1300 | os.path.expanduser("~/Library/Application Support/Alfred 2/Workflow Data/"), 1301 | self.bundleid, 1302 | ) 1303 | 1304 | @property 1305 | def workflowdir(self): 1306 | """Path to workflow's root directory (where ``info.plist`` is). 1307 | 1308 | Returns: 1309 | unicode: full path to workflow root directory 1310 | 1311 | """ 1312 | if not self._workflowdir: 1313 | # Try the working directory first, then the directory 1314 | # the library is in. CWD will be the workflow root if 1315 | # a workflow is being run in Alfred 1316 | candidates = [ 1317 | os.path.abspath(os.getcwd()), 1318 | os.path.dirname(os.path.abspath(os.path.dirname(__file__))), 1319 | ] 1320 | 1321 | # climb the directory tree until we find `info.plist` 1322 | for dirpath in candidates: 1323 | 1324 | # Ensure directory path is Unicode 1325 | dirpath = self.decode(dirpath) 1326 | 1327 | while True: 1328 | if os.path.exists(os.path.join(dirpath, "info.plist")): 1329 | self._workflowdir = dirpath 1330 | break 1331 | 1332 | elif dirpath == "/": 1333 | # no `info.plist` found 1334 | break 1335 | 1336 | # Check the parent directory 1337 | dirpath = os.path.dirname(dirpath) 1338 | 1339 | # No need to check other candidates 1340 | if self._workflowdir: 1341 | break 1342 | 1343 | if not self._workflowdir: 1344 | raise IOError("'info.plist' not found in directory tree") 1345 | 1346 | return self._workflowdir 1347 | 1348 | def cachefile(self, filename): 1349 | """Path to ``filename`` in workflow's cache directory. 1350 | 1351 | Return absolute path to ``filename`` within your workflow's 1352 | :attr:`cache directory `. 1353 | 1354 | :param filename: basename of file 1355 | :type filename: ``unicode`` 1356 | :returns: full path to file within cache directory 1357 | :rtype: ``unicode`` 1358 | 1359 | """ 1360 | return os.path.join(self.cachedir, filename) 1361 | 1362 | def datafile(self, filename): 1363 | """Path to ``filename`` in workflow's data directory. 1364 | 1365 | Return absolute path to ``filename`` within your workflow's 1366 | :attr:`data directory `. 1367 | 1368 | :param filename: basename of file 1369 | :type filename: ``unicode`` 1370 | :returns: full path to file within data directory 1371 | :rtype: ``unicode`` 1372 | 1373 | """ 1374 | return os.path.join(self.datadir, filename) 1375 | 1376 | def workflowfile(self, filename): 1377 | """Return full path to ``filename`` in workflow's root directory. 1378 | 1379 | :param filename: basename of file 1380 | :type filename: ``unicode`` 1381 | :returns: full path to file within data directory 1382 | :rtype: ``unicode`` 1383 | 1384 | """ 1385 | return os.path.join(self.workflowdir, filename) 1386 | 1387 | @property 1388 | def logfile(self): 1389 | """Path to logfile. 1390 | 1391 | :returns: path to logfile within workflow's cache directory 1392 | :rtype: ``unicode`` 1393 | 1394 | """ 1395 | return self.cachefile("%s.log" % self.bundleid) 1396 | 1397 | @property 1398 | def logger(self): 1399 | """Logger that logs to both console and a log file. 1400 | 1401 | If Alfred's debugger is open, log level will be ``DEBUG``, 1402 | else it will be ``INFO``. 1403 | 1404 | Use :meth:`open_log` to open the log file in Console. 1405 | 1406 | :returns: an initialised :class:`~logging.Logger` 1407 | 1408 | """ 1409 | if self._logger: 1410 | return self._logger 1411 | 1412 | # Initialise new logger and optionally handlers 1413 | logger = logging.getLogger("") 1414 | 1415 | # Only add one set of handlers 1416 | # Exclude from coverage, as pytest will have configured the 1417 | # root logger already 1418 | if not len(logger.handlers): # pragma: no cover 1419 | 1420 | fmt = logging.Formatter( 1421 | "%(asctime)s %(filename)s:%(lineno)s" " %(levelname)-8s %(message)s", 1422 | datefmt="%H:%M:%S", 1423 | ) 1424 | 1425 | logfile = logging.handlers.RotatingFileHandler( 1426 | self.logfile, maxBytes=1024 * 1024, backupCount=1 1427 | ) 1428 | logfile.setFormatter(fmt) 1429 | logger.addHandler(logfile) 1430 | 1431 | console = logging.StreamHandler() 1432 | console.setFormatter(fmt) 1433 | logger.addHandler(console) 1434 | 1435 | if self.debugging: 1436 | logger.setLevel(logging.DEBUG) 1437 | else: 1438 | logger.setLevel(logging.INFO) 1439 | 1440 | self._logger = logger 1441 | 1442 | return self._logger 1443 | 1444 | @logger.setter 1445 | def logger(self, logger): 1446 | """Set a custom logger. 1447 | 1448 | :param logger: The logger to use 1449 | :type logger: `~logging.Logger` instance 1450 | 1451 | """ 1452 | self._logger = logger 1453 | 1454 | @property 1455 | def settings_path(self): 1456 | """Path to settings file within workflow's data directory. 1457 | 1458 | :returns: path to ``settings.json`` file 1459 | :rtype: ``unicode`` 1460 | 1461 | """ 1462 | if not self._settings_path: 1463 | self._settings_path = self.datafile("settings.json") 1464 | return self._settings_path 1465 | 1466 | @property 1467 | def settings(self): 1468 | """Return a dictionary subclass that saves itself when changed. 1469 | 1470 | See :ref:`guide-settings` in the :ref:`user-manual` for more 1471 | information on how to use :attr:`settings` and **important 1472 | limitations** on what it can do. 1473 | 1474 | :returns: :class:`~workflow.workflow.Settings` instance 1475 | initialised from the data in JSON file at 1476 | :attr:`settings_path` or if that doesn't exist, with the 1477 | ``default_settings`` :class:`dict` passed to 1478 | :class:`Workflow` on instantiation. 1479 | :rtype: :class:`~workflow.workflow.Settings` instance 1480 | 1481 | """ 1482 | if not self._settings: 1483 | self.logger.debug("reading settings from %s", self.settings_path) 1484 | self._settings = Settings(self.settings_path, self._default_settings) 1485 | return self._settings 1486 | 1487 | @property 1488 | def cache_serializer(self): 1489 | """Name of default cache serializer. 1490 | 1491 | .. versionadded:: 1.8 1492 | 1493 | This serializer is used by :meth:`cache_data()` and 1494 | :meth:`cached_data()` 1495 | 1496 | See :class:`SerializerManager` for details. 1497 | 1498 | :returns: serializer name 1499 | :rtype: ``unicode`` 1500 | 1501 | """ 1502 | return self._cache_serializer 1503 | 1504 | @cache_serializer.setter 1505 | def cache_serializer(self, serializer_name): 1506 | """Set the default cache serialization format. 1507 | 1508 | .. versionadded:: 1.8 1509 | 1510 | This serializer is used by :meth:`cache_data()` and 1511 | :meth:`cached_data()` 1512 | 1513 | The specified serializer must already by registered with the 1514 | :class:`SerializerManager` at `~workflow.workflow.manager`, 1515 | otherwise a :class:`ValueError` will be raised. 1516 | 1517 | :param serializer_name: Name of default serializer to use. 1518 | :type serializer_name: 1519 | 1520 | """ 1521 | if manager.serializer(serializer_name) is None: 1522 | raise ValueError( 1523 | "Unknown serializer : `{0}`. Register your serializer " 1524 | "with `manager` first.".format(serializer_name) 1525 | ) 1526 | 1527 | self.logger.debug("default cache serializer: %s", serializer_name) 1528 | 1529 | self._cache_serializer = serializer_name 1530 | 1531 | @property 1532 | def data_serializer(self): 1533 | """Name of default data serializer. 1534 | 1535 | .. versionadded:: 1.8 1536 | 1537 | This serializer is used by :meth:`store_data()` and 1538 | :meth:`stored_data()` 1539 | 1540 | See :class:`SerializerManager` for details. 1541 | 1542 | :returns: serializer name 1543 | :rtype: ``unicode`` 1544 | 1545 | """ 1546 | return self._data_serializer 1547 | 1548 | @data_serializer.setter 1549 | def data_serializer(self, serializer_name): 1550 | """Set the default cache serialization format. 1551 | 1552 | .. versionadded:: 1.8 1553 | 1554 | This serializer is used by :meth:`store_data()` and 1555 | :meth:`stored_data()` 1556 | 1557 | The specified serializer must already by registered with the 1558 | :class:`SerializerManager` at `~workflow.workflow.manager`, 1559 | otherwise a :class:`ValueError` will be raised. 1560 | 1561 | :param serializer_name: Name of serializer to use by default. 1562 | 1563 | """ 1564 | if manager.serializer(serializer_name) is None: 1565 | raise ValueError( 1566 | "Unknown serializer : `{0}`. Register your serializer " 1567 | "with `manager` first.".format(serializer_name) 1568 | ) 1569 | 1570 | self.logger.debug("default data serializer: %s", serializer_name) 1571 | 1572 | self._data_serializer = serializer_name 1573 | 1574 | def stored_data(self, name): 1575 | """Retrieve data from data directory. 1576 | 1577 | Returns ``None`` if there are no data stored under ``name``. 1578 | 1579 | .. versionadded:: 1.8 1580 | 1581 | :param name: name of datastore 1582 | 1583 | """ 1584 | metadata_path = self.datafile(".{0}.alfred-workflow".format(name)) 1585 | 1586 | if not os.path.exists(metadata_path): 1587 | self.logger.debug("no data stored for `%s`", name) 1588 | return None 1589 | 1590 | with open(metadata_path, "r") as file_obj: 1591 | serializer_name = file_obj.read().strip() 1592 | 1593 | serializer = manager.serializer(serializer_name) 1594 | 1595 | if serializer is None: 1596 | raise ValueError( 1597 | "Unknown serializer `{0}`. Register a corresponding " 1598 | "serializer with `manager.register()` " 1599 | "to load this data.".format(serializer_name) 1600 | ) 1601 | 1602 | self.logger.debug("data `%s` stored as `%s`", name, serializer_name) 1603 | 1604 | filename = "{0}.{1}".format(name, serializer_name) 1605 | data_path = self.datafile(filename) 1606 | 1607 | if not os.path.exists(data_path): 1608 | self.logger.debug("no data stored: %s", name) 1609 | if os.path.exists(metadata_path): 1610 | os.unlink(metadata_path) 1611 | 1612 | return None 1613 | 1614 | with open(data_path, "rb") as file_obj: 1615 | data = serializer.load(file_obj) 1616 | 1617 | self.logger.debug("stored data loaded: %s", data_path) 1618 | 1619 | return data 1620 | 1621 | def store_data(self, name, data, serializer=None): 1622 | """Save data to data directory. 1623 | 1624 | .. versionadded:: 1.8 1625 | 1626 | If ``data`` is ``None``, the datastore will be deleted. 1627 | 1628 | Note that the datastore does NOT support mutliple threads. 1629 | 1630 | :param name: name of datastore 1631 | :param data: object(s) to store. **Note:** some serializers 1632 | can only handled certain types of data. 1633 | :param serializer: name of serializer to use. If no serializer 1634 | is specified, the default will be used. See 1635 | :class:`SerializerManager` for more information. 1636 | :returns: data in datastore or ``None`` 1637 | 1638 | """ 1639 | # Ensure deletion is not interrupted by SIGTERM 1640 | @uninterruptible 1641 | def delete_paths(paths): 1642 | """Clear one or more data stores""" 1643 | for path in paths: 1644 | if os.path.exists(path): 1645 | os.unlink(path) 1646 | self.logger.debug("deleted data file: %s", path) 1647 | 1648 | serializer_name = serializer or self.data_serializer 1649 | 1650 | # In order for `stored_data()` to be able to load data stored with 1651 | # an arbitrary serializer, yet still have meaningful file extensions, 1652 | # the format (i.e. extension) is saved to an accompanying file 1653 | metadata_path = self.datafile(".{0}.alfred-workflow".format(name)) 1654 | filename = "{0}.{1}".format(name, serializer_name) 1655 | data_path = self.datafile(filename) 1656 | 1657 | if data_path == self.settings_path: 1658 | raise ValueError( 1659 | "Cannot save data to" 1660 | + "`{0}` with format `{1}`. ".format(name, serializer_name) 1661 | + "This would overwrite Alfred-Workflow's settings file." 1662 | ) 1663 | 1664 | serializer = manager.serializer(serializer_name) 1665 | 1666 | if serializer is None: 1667 | raise ValueError( 1668 | "Invalid serializer `{0}`. Register your serializer with " 1669 | "`manager.register()` first.".format(serializer_name) 1670 | ) 1671 | 1672 | if data is None: # Delete cached data 1673 | delete_paths((metadata_path, data_path)) 1674 | return 1675 | 1676 | if isinstance(data, str): 1677 | data = bytearray(data) 1678 | 1679 | # Ensure write is not interrupted by SIGTERM 1680 | @uninterruptible 1681 | def _store(): 1682 | # Save file extension 1683 | with atomic_writer(metadata_path, "w") as file_obj: 1684 | file_obj.write(serializer_name) 1685 | 1686 | with serializer.atomic_writer(data_path, "w") as file_obj: 1687 | serializer.dump(data, file_obj) 1688 | 1689 | _store() 1690 | 1691 | self.logger.debug("saved data: %s", data_path) 1692 | 1693 | def cached_data(self, name, data_func=None, max_age=60): 1694 | """Return cached data if younger than ``max_age`` seconds. 1695 | 1696 | Retrieve data from cache or re-generate and re-cache data if 1697 | stale/non-existant. If ``max_age`` is 0, return cached data no 1698 | matter how old. 1699 | 1700 | :param name: name of datastore 1701 | :param data_func: function to (re-)generate data. 1702 | :type data_func: ``callable`` 1703 | :param max_age: maximum age of cached data in seconds 1704 | :type max_age: ``int`` 1705 | :returns: cached data, return value of ``data_func`` or ``None`` 1706 | if ``data_func`` is not set 1707 | 1708 | """ 1709 | serializer = manager.serializer(self.cache_serializer) 1710 | 1711 | cache_path = self.cachefile("%s.%s" % (name, self.cache_serializer)) 1712 | age = self.cached_data_age(name) 1713 | 1714 | if (age < max_age or max_age == 0) and os.path.exists(cache_path): 1715 | 1716 | with open(cache_path, "rb") as file_obj: 1717 | self.logger.debug("loading cached data: %s", cache_path) 1718 | return serializer.load(file_obj) 1719 | 1720 | if not data_func: 1721 | return None 1722 | 1723 | data = data_func() 1724 | self.cache_data(name, data) 1725 | 1726 | return data 1727 | 1728 | def cache_data(self, name, data): 1729 | """Save ``data`` to cache under ``name``. 1730 | 1731 | If ``data`` is ``None``, the corresponding cache file will be 1732 | deleted. 1733 | 1734 | :param name: name of datastore 1735 | :param data: data to store. This may be any object supported by 1736 | the cache serializer 1737 | 1738 | """ 1739 | serializer = manager.serializer(self.cache_serializer) 1740 | 1741 | cache_path = self.cachefile("%s.%s" % (name, self.cache_serializer)) 1742 | 1743 | if data is None: 1744 | if os.path.exists(cache_path): 1745 | os.unlink(cache_path) 1746 | self.logger.debug("deleted cache file: %s", cache_path) 1747 | return 1748 | 1749 | with serializer.atomic_writer(cache_path, "w") as file_obj: 1750 | serializer.dump(data, file_obj) 1751 | 1752 | self.logger.debug("cached data: %s", cache_path) 1753 | 1754 | def cached_data_fresh(self, name, max_age): 1755 | """Whether cache `name` is less than `max_age` seconds old. 1756 | 1757 | :param name: name of datastore 1758 | :param max_age: maximum age of data in seconds 1759 | :type max_age: ``int`` 1760 | :returns: ``True`` if data is less than ``max_age`` old, else 1761 | ``False`` 1762 | 1763 | """ 1764 | age = self.cached_data_age(name) 1765 | 1766 | if not age: 1767 | return False 1768 | 1769 | return age < max_age 1770 | 1771 | def cached_data_age(self, name): 1772 | """Return age in seconds of cache `name` or 0 if cache doesn't exist. 1773 | 1774 | :param name: name of datastore 1775 | :type name: ``unicode`` 1776 | :returns: age of datastore in seconds 1777 | :rtype: ``int`` 1778 | 1779 | """ 1780 | cache_path = self.cachefile("%s.%s" % (name, self.cache_serializer)) 1781 | 1782 | if not os.path.exists(cache_path): 1783 | return 0 1784 | 1785 | return time.time() - os.stat(cache_path).st_mtime 1786 | 1787 | def filter( 1788 | self, 1789 | query, 1790 | items, 1791 | key=lambda x: x, 1792 | ascending=False, 1793 | include_score=False, 1794 | min_score=0, 1795 | max_results=0, 1796 | match_on=MATCH_ALL, 1797 | fold_diacritics=True, 1798 | ): 1799 | """Fuzzy search filter. Returns list of ``items`` that match ``query``. 1800 | 1801 | ``query`` is case-insensitive. Any item that does not contain the 1802 | entirety of ``query`` is rejected. 1803 | 1804 | If ``query`` is an empty string or contains only whitespace, 1805 | all items will match. 1806 | 1807 | :param query: query to test items against 1808 | :type query: ``unicode`` 1809 | :param items: iterable of items to test 1810 | :type items: ``list`` or ``tuple`` 1811 | :param key: function to get comparison key from ``items``. 1812 | Must return a ``unicode`` string. The default simply returns 1813 | the item. 1814 | :type key: ``callable`` 1815 | :param ascending: set to ``True`` to get worst matches first 1816 | :type ascending: ``Boolean`` 1817 | :param include_score: Useful for debugging the scoring algorithm. 1818 | If ``True``, results will be a list of tuples 1819 | ``(item, score, rule)``. 1820 | :type include_score: ``Boolean`` 1821 | :param min_score: If non-zero, ignore results with a score lower 1822 | than this. 1823 | :type min_score: ``int`` 1824 | :param max_results: If non-zero, prune results list to this length. 1825 | :type max_results: ``int`` 1826 | :param match_on: Filter option flags. Bitwise-combined list of 1827 | ``MATCH_*`` constants (see below). 1828 | :type match_on: ``int`` 1829 | :param fold_diacritics: Convert search keys to ASCII-only 1830 | characters if ``query`` only contains ASCII characters. 1831 | :type fold_diacritics: ``Boolean`` 1832 | :returns: list of ``items`` matching ``query`` or list of 1833 | ``(item, score, rule)`` `tuples` if ``include_score`` is ``True``. 1834 | ``rule`` is the ``MATCH_*`` rule that matched the item. 1835 | :rtype: ``list`` 1836 | 1837 | **Matching rules** 1838 | 1839 | By default, :meth:`filter` uses all of the following flags (i.e. 1840 | :const:`MATCH_ALL`). The tests are always run in the given order: 1841 | 1842 | 1. :const:`MATCH_STARTSWITH` 1843 | Item search key starts with ``query`` (case-insensitive). 1844 | 2. :const:`MATCH_CAPITALS` 1845 | The list of capital letters in item search key starts with 1846 | ``query`` (``query`` may be lower-case). E.g., ``of`` 1847 | would match ``OmniFocus``, ``gc`` would match ``Google Chrome``. 1848 | 3. :const:`MATCH_ATOM` 1849 | Search key is split into "atoms" on non-word characters 1850 | (.,-,' etc.). Matches if ``query`` is one of these atoms 1851 | (case-insensitive). 1852 | 4. :const:`MATCH_INITIALS_STARTSWITH` 1853 | Initials are the first characters of the above-described 1854 | "atoms" (case-insensitive). 1855 | 5. :const:`MATCH_INITIALS_CONTAIN` 1856 | ``query`` is a substring of the above-described initials. 1857 | 6. :const:`MATCH_INITIALS` 1858 | Combination of (4) and (5). 1859 | 7. :const:`MATCH_SUBSTRING` 1860 | ``query`` is a substring of item search key (case-insensitive). 1861 | 8. :const:`MATCH_ALLCHARS` 1862 | All characters in ``query`` appear in item search key in 1863 | the same order (case-insensitive). 1864 | 9. :const:`MATCH_ALL` 1865 | Combination of all the above. 1866 | 1867 | 1868 | :const:`MATCH_ALLCHARS` is considerably slower than the other 1869 | tests and provides much less accurate results. 1870 | 1871 | **Examples:** 1872 | 1873 | To ignore :const:`MATCH_ALLCHARS` (tends to provide the worst 1874 | matches and is expensive to run), use 1875 | ``match_on=MATCH_ALL ^ MATCH_ALLCHARS``. 1876 | 1877 | To match only on capitals, use ``match_on=MATCH_CAPITALS``. 1878 | 1879 | To match only on startswith and substring, use 1880 | ``match_on=MATCH_STARTSWITH | MATCH_SUBSTRING``. 1881 | 1882 | **Diacritic folding** 1883 | 1884 | .. versionadded:: 1.3 1885 | 1886 | If ``fold_diacritics`` is ``True`` (the default), and ``query`` 1887 | contains only ASCII characters, non-ASCII characters in search keys 1888 | will be converted to ASCII equivalents (e.g. **ü** -> **u**, 1889 | **ß** -> **ss**, **é** -> **e**). 1890 | 1891 | See :const:`ASCII_REPLACEMENTS` for all replacements. 1892 | 1893 | If ``query`` contains non-ASCII characters, search keys will not be 1894 | altered. 1895 | 1896 | """ 1897 | if not query: 1898 | return items 1899 | 1900 | # Remove preceding/trailing spaces 1901 | query = query.strip() 1902 | 1903 | if not query: 1904 | return items 1905 | 1906 | # Use user override if there is one 1907 | fold_diacritics = self.settings.get( 1908 | "__workflow_diacritic_folding", fold_diacritics 1909 | ) 1910 | 1911 | results = [] 1912 | 1913 | for item in items: 1914 | skip = False 1915 | score = 0 1916 | words = [s.strip() for s in query.split(" ")] 1917 | value = key(item).strip() 1918 | if value == "": 1919 | continue 1920 | for word in words: 1921 | if word == "": 1922 | continue 1923 | s, rule = self._filter_item(value, word, match_on, fold_diacritics) 1924 | 1925 | if not s: # Skip items that don't match part of the query 1926 | skip = True 1927 | score += s 1928 | 1929 | if skip: 1930 | continue 1931 | 1932 | if score: 1933 | # use "reversed" `score` (i.e. highest becomes lowest) and 1934 | # `value` as sort key. This means items with the same score 1935 | # will be sorted in alphabetical not reverse alphabetical order 1936 | results.append( 1937 | ((100.0 / score, value.lower(), score), (item, score, rule)) 1938 | ) 1939 | 1940 | # sort on keys, then discard the keys 1941 | results.sort(reverse=ascending) 1942 | results = [t[1] for t in results] 1943 | 1944 | if min_score: 1945 | results = [r for r in results if r[1] > min_score] 1946 | 1947 | if max_results and len(results) > max_results: 1948 | results = results[:max_results] 1949 | 1950 | # return list of ``(item, score, rule)`` 1951 | if include_score: 1952 | return results 1953 | # just return list of items 1954 | return [t[0] for t in results] 1955 | 1956 | def _filter_item(self, value, query, match_on, fold_diacritics): 1957 | """Filter ``value`` against ``query`` using rules ``match_on``. 1958 | 1959 | :returns: ``(score, rule)`` 1960 | 1961 | """ 1962 | query = query.lower() 1963 | 1964 | if not isascii(query): 1965 | fold_diacritics = False 1966 | 1967 | if fold_diacritics: 1968 | value = self.fold_to_ascii(value) 1969 | 1970 | # pre-filter any items that do not contain all characters 1971 | # of ``query`` to save on running several more expensive tests 1972 | if not set(query) <= set(value.lower()): 1973 | 1974 | return (0, None) 1975 | 1976 | # item starts with query 1977 | if match_on & MATCH_STARTSWITH and value.lower().startswith(query): 1978 | score = 100.0 - (len(value) / len(query)) 1979 | 1980 | return (score, MATCH_STARTSWITH) 1981 | 1982 | # query matches capitalised letters in item, 1983 | # e.g. of = OmniFocus 1984 | if match_on & MATCH_CAPITALS: 1985 | initials = "".join([c for c in value if c in INITIALS]) 1986 | if initials.lower().startswith(query): 1987 | score = 100.0 - (len(initials) / len(query)) 1988 | 1989 | return (score, MATCH_CAPITALS) 1990 | 1991 | # split the item into "atoms", i.e. words separated by 1992 | # spaces or other non-word characters 1993 | if ( 1994 | match_on & MATCH_ATOM 1995 | or match_on & MATCH_INITIALS_CONTAIN 1996 | or match_on & MATCH_INITIALS_STARTSWITH 1997 | ): 1998 | atoms = [s.lower() for s in split_on_delimiters(value)] 1999 | # print('atoms : %s --> %s' % (value, atoms)) 2000 | # initials of the atoms 2001 | initials = "".join([s[0] for s in atoms if s]) 2002 | 2003 | if match_on & MATCH_ATOM: 2004 | # is `query` one of the atoms in item? 2005 | # similar to substring, but scores more highly, as it's 2006 | # a word within the item 2007 | if query in atoms: 2008 | score = 100.0 - (len(value) / len(query)) 2009 | 2010 | return (score, MATCH_ATOM) 2011 | 2012 | # `query` matches start (or all) of the initials of the 2013 | # atoms, e.g. ``himym`` matches "How I Met Your Mother" 2014 | # *and* "how i met your mother" (the ``capitals`` rule only 2015 | # matches the former) 2016 | if match_on & MATCH_INITIALS_STARTSWITH and initials.startswith(query): 2017 | score = 100.0 - (len(initials) / len(query)) 2018 | 2019 | return (score, MATCH_INITIALS_STARTSWITH) 2020 | 2021 | # `query` is a substring of initials, e.g. ``doh`` matches 2022 | # "The Dukes of Hazzard" 2023 | elif match_on & MATCH_INITIALS_CONTAIN and query in initials: 2024 | score = 95.0 - (len(initials) / len(query)) 2025 | 2026 | return (score, MATCH_INITIALS_CONTAIN) 2027 | 2028 | # `query` is a substring of item 2029 | if match_on & MATCH_SUBSTRING and query in value.lower(): 2030 | score = 90.0 - (len(value) / len(query)) 2031 | 2032 | return (score, MATCH_SUBSTRING) 2033 | 2034 | # finally, assign a score based on how close together the 2035 | # characters in `query` are in item. 2036 | if match_on & MATCH_ALLCHARS: 2037 | search = self._search_for_query(query) 2038 | match = search(value) 2039 | if match: 2040 | score = 100.0 / ( 2041 | (1 + match.start()) * (match.end() - match.start() + 1) 2042 | ) 2043 | 2044 | return (score, MATCH_ALLCHARS) 2045 | 2046 | # Nothing matched 2047 | return (0, None) 2048 | 2049 | def _search_for_query(self, query): 2050 | if query in self._search_pattern_cache: 2051 | return self._search_pattern_cache[query] 2052 | 2053 | # Build pattern: include all characters 2054 | pattern = [] 2055 | for c in query: 2056 | # pattern.append('[^{0}]*{0}'.format(re.escape(c))) 2057 | pattern.append(".*?{0}".format(re.escape(c))) 2058 | pattern = "".join(pattern) 2059 | search = re.compile(pattern, re.IGNORECASE).search 2060 | 2061 | self._search_pattern_cache[query] = search 2062 | return search 2063 | 2064 | def run(self, func, text_errors=False): 2065 | """Call ``func`` to run your workflow. 2066 | 2067 | :param func: Callable to call with ``self`` (i.e. the :class:`Workflow` 2068 | instance) as first argument. 2069 | :param text_errors: Emit error messages in plain text, not in 2070 | Alfred's XML/JSON feedback format. Use this when you're not 2071 | running Alfred-Workflow in a Script Filter and would like 2072 | to pass the error message to, say, a notification. 2073 | :type text_errors: ``Boolean`` 2074 | 2075 | ``func`` will be called with :class:`Workflow` instance as first 2076 | argument. 2077 | 2078 | ``func`` should be the main entry point to your workflow. 2079 | 2080 | Any exceptions raised will be logged and an error message will be 2081 | output to Alfred. 2082 | 2083 | """ 2084 | start = time.time() 2085 | 2086 | # Write to debugger to ensure "real" output starts on a new line 2087 | print(".", file=sys.stderr) 2088 | 2089 | # Call workflow's entry function/method within a try-except block 2090 | # to catch any errors and display an error message in Alfred 2091 | try: 2092 | if self.version: 2093 | self.logger.debug( 2094 | "---------- %s (%s) ----------", self.name, self.version 2095 | ) 2096 | else: 2097 | self.logger.debug("---------- %s ----------", self.name) 2098 | 2099 | # Run update check if configured for self-updates. 2100 | # This call has to go in the `run` try-except block, as it will 2101 | # initialise `self.settings`, which will raise an exception 2102 | # if `settings.json` isn't valid. 2103 | if self._update_settings: 2104 | self.check_update() 2105 | 2106 | # Run workflow's entry function/method 2107 | func(self) 2108 | 2109 | # Set last version run to current version after a successful 2110 | # run 2111 | self.set_last_version() 2112 | 2113 | except Exception as err: 2114 | self.logger.exception(err) 2115 | if self.help_url: 2116 | self.logger.info("for assistance, see: %s", self.help_url) 2117 | 2118 | if not sys.stdout.isatty(): # Show error in Alfred 2119 | if text_errors: 2120 | print(str(err).encode("utf-8"), end="") 2121 | else: 2122 | self._items = [] 2123 | if self._name: 2124 | name = self._name 2125 | elif self._bundleid: # pragma: no cover 2126 | name = self._bundleid 2127 | else: # pragma: no cover 2128 | name = os.path.dirname(__file__) 2129 | self.add_item( 2130 | "Error in workflow '%s'" % name, str(err), icon=ICON_ERROR 2131 | ) 2132 | self.send_feedback() 2133 | return 1 2134 | 2135 | finally: 2136 | self.logger.debug( 2137 | "---------- finished in %0.3fs ----------", time.time() - start 2138 | ) 2139 | 2140 | return 0 2141 | 2142 | # Alfred feedback methods ------------------------------------------ 2143 | 2144 | def add_item( 2145 | self, 2146 | title, 2147 | subtitle="", 2148 | modifier_subtitles=None, 2149 | arg=None, 2150 | autocomplete=None, 2151 | valid=False, 2152 | uid=None, 2153 | icon=None, 2154 | icontype=None, 2155 | type=None, 2156 | largetext=None, 2157 | copytext=None, 2158 | quicklookurl=None, 2159 | ): 2160 | """Add an item to be output to Alfred. 2161 | 2162 | :param title: Title shown in Alfred 2163 | :type title: ``unicode`` 2164 | :param subtitle: Subtitle shown in Alfred 2165 | :type subtitle: ``unicode`` 2166 | :param modifier_subtitles: Subtitles shown when modifier 2167 | (CMD, OPT etc.) is pressed. Use a ``dict`` with the lowercase 2168 | keys ``cmd``, ``ctrl``, ``shift``, ``alt`` and ``fn`` 2169 | :type modifier_subtitles: ``dict`` 2170 | :param arg: Argument passed by Alfred as ``{query}`` when item is 2171 | actioned 2172 | :type arg: ``unicode`` 2173 | :param autocomplete: Text expanded in Alfred when item is TABbed 2174 | :type autocomplete: ``unicode`` 2175 | :param valid: Whether or not item can be actioned 2176 | :type valid: ``Boolean`` 2177 | :param uid: Used by Alfred to remember/sort items 2178 | :type uid: ``unicode`` 2179 | :param icon: Filename of icon to use 2180 | :type icon: ``unicode`` 2181 | :param icontype: Type of icon. Must be one of ``None`` , ``'filetype'`` 2182 | or ``'fileicon'``. Use ``'filetype'`` when ``icon`` is a filetype 2183 | such as ``'public.folder'``. Use ``'fileicon'`` when you wish to 2184 | use the icon of the file specified as ``icon``, e.g. 2185 | ``icon='/Applications/Safari.app', icontype='fileicon'``. 2186 | Leave as `None` if ``icon`` points to an actual 2187 | icon file. 2188 | :type icontype: ``unicode`` 2189 | :param type: Result type. Currently only ``'file'`` is supported 2190 | (by Alfred). This will tell Alfred to enable file actions for 2191 | this item. 2192 | :type type: ``unicode`` 2193 | :param largetext: Text to be displayed in Alfred's large text box 2194 | if user presses CMD+L on item. 2195 | :type largetext: ``unicode`` 2196 | :param copytext: Text to be copied to pasteboard if user presses 2197 | CMD+C on item. 2198 | :type copytext: ``unicode`` 2199 | :param quicklookurl: URL to be displayed using Alfred's Quick Look 2200 | feature (tapping ``SHIFT`` or ``⌘+Y`` on a result). 2201 | :type quicklookurl: ``unicode`` 2202 | :returns: :class:`Item` instance 2203 | 2204 | See :ref:`icons` for a list of the supported system icons. 2205 | 2206 | .. note:: 2207 | 2208 | Although this method returns an :class:`Item` instance, you don't 2209 | need to hold onto it or worry about it. All generated :class:`Item` 2210 | instances are also collected internally and sent to Alfred when 2211 | :meth:`send_feedback` is called. 2212 | 2213 | The generated :class:`Item` is only returned in case you want to 2214 | edit it or do something with it other than send it to Alfred. 2215 | 2216 | """ 2217 | item = self.item_class( 2218 | title, 2219 | subtitle, 2220 | modifier_subtitles, 2221 | arg, 2222 | autocomplete, 2223 | valid, 2224 | uid, 2225 | icon, 2226 | icontype, 2227 | type, 2228 | largetext, 2229 | copytext, 2230 | quicklookurl, 2231 | ) 2232 | self._items.append(item) 2233 | return item 2234 | 2235 | def send_feedback(self): 2236 | """Print stored items to console/Alfred as XML.""" 2237 | root = ET.Element("items") 2238 | for item in self._items: 2239 | root.append(item.elem) 2240 | sys.stdout.write('\n') 2241 | sys.stdout.write(ET.tostring(root, encoding="unicode")) 2242 | sys.stdout.flush() 2243 | 2244 | #################################################################### 2245 | # Updating methods 2246 | #################################################################### 2247 | 2248 | @property 2249 | def first_run(self): 2250 | """Return ``True`` if it's the first time this version has run. 2251 | 2252 | .. versionadded:: 1.9.10 2253 | 2254 | Raises a :class:`ValueError` if :attr:`version` isn't set. 2255 | 2256 | """ 2257 | if not self.version: 2258 | raise ValueError("No workflow version set") 2259 | 2260 | if not self.last_version_run: 2261 | return True 2262 | 2263 | return self.version != self.last_version_run 2264 | 2265 | @property 2266 | def last_version_run(self): 2267 | """Return version of last version to run (or ``None``). 2268 | 2269 | .. versionadded:: 1.9.10 2270 | 2271 | :returns: :class:`~workflow.update.Version` instance 2272 | or ``None`` 2273 | 2274 | """ 2275 | if self._last_version_run is UNSET: 2276 | 2277 | version = self.settings.get("__workflow_last_version") 2278 | if version: 2279 | from .update import Version 2280 | 2281 | version = Version(version) 2282 | 2283 | self._last_version_run = version 2284 | 2285 | self.logger.debug("last run version: %s", self._last_version_run) 2286 | 2287 | return self._last_version_run 2288 | 2289 | def set_last_version(self, version=None): 2290 | """Set :attr:`last_version_run` to current version. 2291 | 2292 | .. versionadded:: 1.9.10 2293 | 2294 | :param version: version to store (default is current version) 2295 | :type version: :class:`~workflow.update.Version` instance 2296 | or ``unicode`` 2297 | :returns: ``True`` if version is saved, else ``False`` 2298 | 2299 | """ 2300 | if not version: 2301 | if not self.version: 2302 | self.logger.warning("Can't save last version: workflow has no version") 2303 | return False 2304 | 2305 | version = self.version 2306 | 2307 | if isinstance(version, str): 2308 | from .update import Version 2309 | 2310 | version = Version(version) 2311 | 2312 | self.settings["__workflow_last_version"] = str(version) 2313 | 2314 | self.logger.debug("set last run version: %s", version) 2315 | 2316 | return True 2317 | 2318 | @property 2319 | def update_available(self): 2320 | """Whether an update is available. 2321 | 2322 | .. versionadded:: 1.9 2323 | 2324 | See :ref:`guide-updates` in the :ref:`user-manual` for detailed 2325 | information on how to enable your workflow to update itself. 2326 | 2327 | :returns: ``True`` if an update is available, else ``False`` 2328 | 2329 | """ 2330 | key = "__workflow_latest_version" 2331 | # Create a new workflow object to ensure standard serialiser 2332 | # is used (update.py is called without the user's settings) 2333 | status = Workflow().cached_data(key, max_age=0) 2334 | 2335 | # self.logger.debug('update status: %r', status) 2336 | if not status or not status.get("available"): 2337 | return False 2338 | 2339 | return status["available"] 2340 | 2341 | @property 2342 | def prereleases(self): 2343 | """Whether workflow should update to pre-release versions. 2344 | 2345 | .. versionadded:: 1.16 2346 | 2347 | :returns: ``True`` if pre-releases are enabled with the :ref:`magic 2348 | argument ` or the ``update_settings`` dict, else 2349 | ``False``. 2350 | 2351 | """ 2352 | if self._update_settings.get("prereleases"): 2353 | return True 2354 | 2355 | return self.settings.get("__workflow_prereleases") or False 2356 | 2357 | def check_update(self, force=False): 2358 | """Call update script if it's time to check for a new release. 2359 | 2360 | .. versionadded:: 1.9 2361 | 2362 | The update script will be run in the background, so it won't 2363 | interfere in the execution of your workflow. 2364 | 2365 | See :ref:`guide-updates` in the :ref:`user-manual` for detailed 2366 | information on how to enable your workflow to update itself. 2367 | 2368 | :param force: Force update check 2369 | :type force: ``Boolean`` 2370 | 2371 | """ 2372 | key = "__workflow_latest_version" 2373 | frequency = self._update_settings.get("frequency", DEFAULT_UPDATE_FREQUENCY) 2374 | 2375 | if not force and not self.settings.get("__workflow_autoupdate", True): 2376 | self.logger.debug("Auto update turned off by user") 2377 | return 2378 | 2379 | # Check for new version if it's time 2380 | if force or not self.cached_data_fresh(key, frequency * 86400): 2381 | repo = self._update_settings["github_slug"] 2382 | # version = self._update_settings['version'] 2383 | version = str(self.version) 2384 | 2385 | from .background import run_in_background 2386 | 2387 | # update.py is adjacent to this file 2388 | update_script = os.path.join(os.path.dirname(__file__), "update.py") 2389 | 2390 | cmd = [sys.executable, update_script, "check", repo, version] 2391 | if self.prereleases: 2392 | cmd.append("--prereleases") 2393 | 2394 | self.logger.info("checking for update ...") 2395 | 2396 | run_in_background("__workflow_update_check", cmd) 2397 | 2398 | else: 2399 | self.logger.debug("update check not due") 2400 | 2401 | def start_update(self): 2402 | """Check for update and download and install new workflow file. 2403 | 2404 | .. versionadded:: 1.9 2405 | 2406 | See :ref:`guide-updates` in the :ref:`user-manual` for detailed 2407 | information on how to enable your workflow to update itself. 2408 | 2409 | :returns: ``True`` if an update is available and will be 2410 | installed, else ``False`` 2411 | 2412 | """ 2413 | from . import update 2414 | 2415 | repo = self._update_settings["github_slug"] 2416 | # version = self._update_settings['version'] 2417 | version = str(self.version) 2418 | 2419 | if not update.check_update(repo, version, self.prereleases): 2420 | return False 2421 | 2422 | from .background import run_in_background 2423 | 2424 | # update.py is adjacent to this file 2425 | update_script = os.path.join(os.path.dirname(__file__), "update.py") 2426 | 2427 | cmd = [sys.executable, update_script, "install", repo, version] 2428 | 2429 | if self.prereleases: 2430 | cmd.append("--prereleases") 2431 | 2432 | self.logger.debug("downloading update ...") 2433 | run_in_background("__workflow_update_install", cmd) 2434 | 2435 | return True 2436 | 2437 | #################################################################### 2438 | # Keychain password storage methods 2439 | #################################################################### 2440 | 2441 | def save_password(self, account, password, service=None): 2442 | """Save account credentials. 2443 | 2444 | If the account exists, the old password will first be deleted 2445 | (Keychain throws an error otherwise). 2446 | 2447 | If something goes wrong, a :class:`KeychainError` exception will 2448 | be raised. 2449 | 2450 | :param account: name of the account the password is for, e.g. 2451 | "Pinboard" 2452 | :type account: ``unicode`` 2453 | :param password: the password to secure 2454 | :type password: ``unicode`` 2455 | :param service: Name of the service. By default, this is the 2456 | workflow's bundle ID 2457 | :type service: ``unicode`` 2458 | 2459 | """ 2460 | if not service: 2461 | service = self.bundleid 2462 | 2463 | try: 2464 | self._call_security( 2465 | "add-generic-password", service, account, "-w", password 2466 | ) 2467 | self.logger.debug("saved password : %s:%s", service, account) 2468 | 2469 | except PasswordExists: 2470 | self.logger.debug("password exists : %s:%s", service, account) 2471 | current_password = self.get_password(account, service) 2472 | 2473 | if current_password == password: 2474 | self.logger.debug("password unchanged") 2475 | 2476 | else: 2477 | self.delete_password(account, service) 2478 | self._call_security( 2479 | "add-generic-password", service, account, "-w", password 2480 | ) 2481 | self.logger.debug("save_password : %s:%s", service, account) 2482 | 2483 | def get_password(self, account, service=None): 2484 | """Retrieve the password saved at ``service/account``. 2485 | 2486 | Raise :class:`PasswordNotFound` exception if password doesn't exist. 2487 | 2488 | :param account: name of the account the password is for, e.g. 2489 | "Pinboard" 2490 | :type account: ``unicode`` 2491 | :param service: Name of the service. By default, this is the workflow's 2492 | bundle ID 2493 | :type service: ``unicode`` 2494 | :returns: account password 2495 | :rtype: ``unicode`` 2496 | 2497 | """ 2498 | if not service: 2499 | service = self.bundleid 2500 | 2501 | output = self._call_security("find-generic-password", service, account, "-g") 2502 | 2503 | # Parsing of `security` output is adapted from python-keyring 2504 | # by Jason R. Coombs 2505 | # https://pypi.python.org/pypi/keyring 2506 | m = re.search( 2507 | r'password:\s*(?:0x(?P[0-9A-F]+)\s*)?(?:"(?P.*)")?', output 2508 | ) 2509 | 2510 | if m: 2511 | groups = m.groupdict() 2512 | h = groups.get("hex") 2513 | password = groups.get("pw") 2514 | if h: 2515 | password = str(binascii.unhexlify(h), "utf-8") 2516 | 2517 | self.logger.debug("got password : %s:%s", service, account) 2518 | 2519 | return password 2520 | 2521 | def delete_password(self, account, service=None): 2522 | """Delete the password stored at ``service/account``. 2523 | 2524 | Raise :class:`PasswordNotFound` if account is unknown. 2525 | 2526 | :param account: name of the account the password is for, e.g. 2527 | "Pinboard" 2528 | :type account: ``unicode`` 2529 | :param service: Name of the service. By default, this is the workflow's 2530 | bundle ID 2531 | :type service: ``unicode`` 2532 | 2533 | """ 2534 | if not service: 2535 | service = self.bundleid 2536 | 2537 | self._call_security("delete-generic-password", service, account) 2538 | 2539 | self.logger.debug("deleted password : %s:%s", service, account) 2540 | 2541 | #################################################################### 2542 | # Methods for workflow:* magic args 2543 | #################################################################### 2544 | 2545 | def _register_default_magic(self): # noqa: C901 2546 | """Register the built-in magic arguments.""" 2547 | # TODO: refactor & simplify 2548 | # Wrap callback and message with callable 2549 | def callback(func, msg): 2550 | def wrapper(): 2551 | func() 2552 | return msg 2553 | 2554 | return wrapper 2555 | 2556 | self.magic_arguments["delcache"] = callback( 2557 | self.clear_cache, "Deleted workflow cache" 2558 | ) 2559 | self.magic_arguments["deldata"] = callback( 2560 | self.clear_data, "Deleted workflow data" 2561 | ) 2562 | self.magic_arguments["delsettings"] = callback( 2563 | self.clear_settings, "Deleted workflow settings" 2564 | ) 2565 | self.magic_arguments["reset"] = callback(self.reset, "Reset workflow") 2566 | self.magic_arguments["openlog"] = callback( 2567 | self.open_log, "Opening workflow log file" 2568 | ) 2569 | self.magic_arguments["opencache"] = callback( 2570 | self.open_cachedir, "Opening workflow cache directory" 2571 | ) 2572 | self.magic_arguments["opendata"] = callback( 2573 | self.open_datadir, "Opening workflow data directory" 2574 | ) 2575 | self.magic_arguments["openworkflow"] = callback( 2576 | self.open_workflowdir, "Opening workflow directory" 2577 | ) 2578 | self.magic_arguments["openterm"] = callback( 2579 | self.open_terminal, "Opening workflow root directory in Terminal" 2580 | ) 2581 | 2582 | # Diacritic folding 2583 | def fold_on(): 2584 | self.settings["__workflow_diacritic_folding"] = True 2585 | return "Diacritics will always be folded" 2586 | 2587 | def fold_off(): 2588 | self.settings["__workflow_diacritic_folding"] = False 2589 | return "Diacritics will never be folded" 2590 | 2591 | def fold_default(): 2592 | if "__workflow_diacritic_folding" in self.settings: 2593 | del self.settings["__workflow_diacritic_folding"] 2594 | return "Diacritics folding reset" 2595 | 2596 | self.magic_arguments["foldingon"] = fold_on 2597 | self.magic_arguments["foldingoff"] = fold_off 2598 | self.magic_arguments["foldingdefault"] = fold_default 2599 | 2600 | # Updates 2601 | def update_on(): 2602 | self.settings["__workflow_autoupdate"] = True 2603 | return "Auto update turned on" 2604 | 2605 | def update_off(): 2606 | self.settings["__workflow_autoupdate"] = False 2607 | return "Auto update turned off" 2608 | 2609 | def prereleases_on(): 2610 | self.settings["__workflow_prereleases"] = True 2611 | return "Prerelease updates turned on" 2612 | 2613 | def prereleases_off(): 2614 | self.settings["__workflow_prereleases"] = False 2615 | return "Prerelease updates turned off" 2616 | 2617 | def do_update(): 2618 | if self.start_update(): 2619 | return "Downloading and installing update ..." 2620 | else: 2621 | return "No update available" 2622 | 2623 | self.magic_arguments["autoupdate"] = update_on 2624 | self.magic_arguments["noautoupdate"] = update_off 2625 | self.magic_arguments["prereleases"] = prereleases_on 2626 | self.magic_arguments["noprereleases"] = prereleases_off 2627 | self.magic_arguments["update"] = do_update 2628 | 2629 | # Help 2630 | def do_help(): 2631 | if self.help_url: 2632 | self.open_help() 2633 | return "Opening workflow help URL in browser" 2634 | else: 2635 | return "Workflow has no help URL" 2636 | 2637 | def show_version(): 2638 | if self.version: 2639 | return "Version: {0}".format(self.version) 2640 | else: 2641 | return "This workflow has no version number" 2642 | 2643 | def list_magic(): 2644 | """Display all available magic args in Alfred.""" 2645 | isatty = sys.stderr.isatty() 2646 | for name in sorted(self.magic_arguments.keys()): 2647 | if name == "magic": 2648 | continue 2649 | arg = self.magic_prefix + name 2650 | self.logger.debug(arg) 2651 | 2652 | if not isatty: 2653 | self.add_item(arg, icon=ICON_INFO) 2654 | 2655 | if not isatty: 2656 | self.send_feedback() 2657 | 2658 | self.magic_arguments["help"] = do_help 2659 | self.magic_arguments["magic"] = list_magic 2660 | self.magic_arguments["version"] = show_version 2661 | 2662 | def clear_cache(self, filter_func=lambda f: True): 2663 | """Delete all files in workflow's :attr:`cachedir`. 2664 | 2665 | :param filter_func: Callable to determine whether a file should be 2666 | deleted or not. ``filter_func`` is called with the filename 2667 | of each file in the data directory. If it returns ``True``, 2668 | the file will be deleted. 2669 | By default, *all* files will be deleted. 2670 | :type filter_func: ``callable`` 2671 | """ 2672 | self._delete_directory_contents(self.cachedir, filter_func) 2673 | 2674 | def clear_data(self, filter_func=lambda f: True): 2675 | """Delete all files in workflow's :attr:`datadir`. 2676 | 2677 | :param filter_func: Callable to determine whether a file should be 2678 | deleted or not. ``filter_func`` is called with the filename 2679 | of each file in the data directory. If it returns ``True``, 2680 | the file will be deleted. 2681 | By default, *all* files will be deleted. 2682 | :type filter_func: ``callable`` 2683 | """ 2684 | self._delete_directory_contents(self.datadir, filter_func) 2685 | 2686 | def clear_settings(self): 2687 | """Delete workflow's :attr:`settings_path`.""" 2688 | if os.path.exists(self.settings_path): 2689 | os.unlink(self.settings_path) 2690 | self.logger.debug("deleted : %r", self.settings_path) 2691 | 2692 | def reset(self): 2693 | """Delete workflow settings, cache and data. 2694 | 2695 | File :attr:`settings ` and directories 2696 | :attr:`cache ` and :attr:`data ` are deleted. 2697 | 2698 | """ 2699 | self.clear_cache() 2700 | self.clear_data() 2701 | self.clear_settings() 2702 | 2703 | def open_log(self): 2704 | """Open :attr:`logfile` in default app (usually Console.app).""" 2705 | subprocess.call(["open", self.logfile]) # nosec 2706 | 2707 | def open_cachedir(self): 2708 | """Open the workflow's :attr:`cachedir` in Finder.""" 2709 | subprocess.call(["open", self.cachedir]) # nosec 2710 | 2711 | def open_datadir(self): 2712 | """Open the workflow's :attr:`datadir` in Finder.""" 2713 | subprocess.call(["open", self.datadir]) # nosec 2714 | 2715 | def open_workflowdir(self): 2716 | """Open the workflow's :attr:`workflowdir` in Finder.""" 2717 | subprocess.call(["open", self.workflowdir]) # nosec 2718 | 2719 | def open_terminal(self): 2720 | """Open a Terminal window at workflow's :attr:`workflowdir`.""" 2721 | subprocess.call(["open", "-a", "Terminal", self.workflowdir]) # nosec 2722 | 2723 | def open_help(self): 2724 | """Open :attr:`help_url` in default browser.""" 2725 | subprocess.call(["open", self.help_url]) # nosec 2726 | 2727 | return "Opening workflow help URL in browser" 2728 | 2729 | #################################################################### 2730 | # Helper methods 2731 | #################################################################### 2732 | 2733 | def decode(self, text, encoding=None, normalization=None): 2734 | """Return ``text`` as normalised unicode. 2735 | 2736 | If ``encoding`` and/or ``normalization`` is ``None``, the 2737 | ``input_encoding``and ``normalization`` parameters passed to 2738 | :class:`Workflow` are used. 2739 | 2740 | :param text: string 2741 | :type text: encoded or Unicode string. If ``text`` is already a 2742 | Unicode string, it will only be normalised. 2743 | :param encoding: The text encoding to use to decode ``text`` to 2744 | Unicode. 2745 | :type encoding: ``unicode`` or ``None`` 2746 | :param normalization: The nomalisation form to apply to ``text``. 2747 | :type normalization: ``unicode`` or ``None`` 2748 | :returns: decoded and normalised ``unicode`` 2749 | 2750 | :class:`Workflow` uses "NFC" normalisation by default. This is the 2751 | standard for Python and will work well with data from the web (via 2752 | :mod:`~workflow.web` or :mod:`json`). 2753 | 2754 | macOS, on the other hand, uses "NFD" normalisation (nearly), so data 2755 | coming from the system (e.g. via :mod:`subprocess` or 2756 | :func:`os.listdir`/:mod:`os.path`) may not match. You should either 2757 | normalise this data, too, or change the default normalisation used by 2758 | :class:`Workflow`. 2759 | 2760 | """ 2761 | encoding = encoding or self._input_encoding 2762 | normalization = normalization or self._normalizsation 2763 | if not isinstance(text, str): 2764 | text = str(text, encoding) 2765 | return unicodedata.normalize(normalization, text) 2766 | 2767 | def fold_to_ascii(self, text): 2768 | """Convert non-ASCII characters to closest ASCII equivalent. 2769 | 2770 | .. versionadded:: 1.3 2771 | 2772 | .. note:: This only works for a subset of European languages. 2773 | 2774 | :param text: text to convert 2775 | :type text: ``unicode`` 2776 | :returns: text containing only ASCII characters 2777 | :rtype: ``unicode`` 2778 | 2779 | """ 2780 | if isascii(text): 2781 | return text 2782 | text = "".join([ASCII_REPLACEMENTS.get(c, c) for c in text]) 2783 | return unicodedata.normalize("NFKD", text) 2784 | 2785 | def dumbify_punctuation(self, text): 2786 | """Convert non-ASCII punctuation to closest ASCII equivalent. 2787 | 2788 | This method replaces "smart" quotes and n- or m-dashes with their 2789 | workaday ASCII equivalents. This method is currently not used 2790 | internally, but exists as a helper method for workflow authors. 2791 | 2792 | .. versionadded: 1.9.7 2793 | 2794 | :param text: text to convert 2795 | :type text: ``unicode`` 2796 | :returns: text with only ASCII punctuation 2797 | :rtype: ``unicode`` 2798 | 2799 | """ 2800 | if isascii(text): 2801 | return text 2802 | 2803 | text = "".join([DUMB_PUNCTUATION.get(c, c) for c in text]) 2804 | return text 2805 | 2806 | def _delete_directory_contents(self, dirpath, filter_func): 2807 | """Delete all files in a directory. 2808 | 2809 | :param dirpath: path to directory to clear 2810 | :type dirpath: ``unicode`` or ``str`` 2811 | :param filter_func function to determine whether a file shall be 2812 | deleted or not. 2813 | :type filter_func ``callable`` 2814 | 2815 | """ 2816 | if os.path.exists(dirpath): 2817 | for filename in os.listdir(dirpath): 2818 | if not filter_func(filename): 2819 | continue 2820 | path = os.path.join(dirpath, filename) 2821 | if os.path.isdir(path): 2822 | shutil.rmtree(path) 2823 | else: 2824 | os.unlink(path) 2825 | self.logger.debug("deleted : %r", path) 2826 | 2827 | def _load_info_plist(self): 2828 | """Load workflow info from ``info.plist``.""" 2829 | # info.plist should be in the directory above this one 2830 | with open(self.workflowfile("info.plist"), "rb") as file_obj: 2831 | self._info = plistlib.load(file_obj) 2832 | self._info_loaded = True 2833 | 2834 | def _create(self, dirpath): 2835 | """Create directory `dirpath` if it doesn't exist. 2836 | 2837 | :param dirpath: path to directory 2838 | :type dirpath: ``unicode`` 2839 | :returns: ``dirpath`` argument 2840 | :rtype: ``unicode`` 2841 | 2842 | """ 2843 | if not os.path.exists(dirpath): 2844 | os.makedirs(dirpath) 2845 | return dirpath 2846 | 2847 | def _call_security(self, action, service, account, *args): 2848 | """Call ``security`` CLI program that provides access to keychains. 2849 | 2850 | May raise `PasswordNotFound`, `PasswordExists` or `KeychainError` 2851 | exceptions (the first two are subclasses of `KeychainError`). 2852 | 2853 | :param action: The ``security`` action to call, e.g. 2854 | ``add-generic-password`` 2855 | :type action: ``unicode`` 2856 | :param service: Name of the service. 2857 | :type service: ``unicode`` 2858 | :param account: name of the account the password is for, e.g. 2859 | "Pinboard" 2860 | :type account: ``unicode`` 2861 | :param password: the password to secure 2862 | :type password: ``unicode`` 2863 | :param *args: list of command line arguments to be passed to 2864 | ``security`` 2865 | :type *args: `list` or `tuple` 2866 | :returns: ``(retcode, output)``. ``retcode`` is an `int`, ``output`` a 2867 | ``unicode`` string. 2868 | :rtype: `tuple` (`int`, ``unicode``) 2869 | 2870 | """ 2871 | cmd = ["security", action, "-s", service, "-a", account] + list(args) 2872 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) 2873 | stdout, _ = p.communicate() 2874 | if p.returncode == 44: # password does not exist 2875 | raise PasswordNotFound() 2876 | elif p.returncode == 45: # password already exists 2877 | raise PasswordExists() 2878 | elif p.returncode > 0: 2879 | err = KeychainError("Unknown Keychain error : %s" % stdout) 2880 | err.retcode = p.returncode 2881 | raise err 2882 | return stdout.strip().decode("utf-8") 2883 | -------------------------------------------------------------------------------- /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+ version of :class:`~workflow.Workflow`. 11 | 12 | :class:`~workflow.Workflow3` supports new features, such as 13 | setting :ref:`workflow-variables` and 14 | :class:`the more advanced modifiers ` supported by Alfred 3+. 15 | 16 | In order for the feedback mechanism to work correctly, it's important 17 | to create :class:`Item3` and :class:`Modifier` objects via the 18 | :meth:`Workflow3.add_item()` and :meth:`Item3.add_modifier()` methods 19 | respectively. If you instantiate :class:`Item3` or :class:`Modifier` 20 | objects directly, the current :class:`Workflow3` object won't be aware 21 | of them, and they won't be sent to Alfred when you call 22 | :meth:`Workflow3.send_feedback()`. 23 | 24 | """ 25 | 26 | 27 | import json 28 | import os 29 | import sys 30 | 31 | from .workflow import ICON_WARNING, Workflow 32 | 33 | 34 | class Variables(dict): 35 | """Workflow variables for Run Script actions. 36 | 37 | .. versionadded: 1.26 38 | 39 | This class allows you to set workflow variables from 40 | Run Script actions. 41 | 42 | It is a subclass of :class:`dict`. 43 | 44 | >>> v = Variables(username='deanishe', password='hunter2') 45 | >>> v.arg = u'output value' 46 | >>> print(v) 47 | 48 | See :ref:`variables-run-script` in the User Guide for more 49 | information. 50 | 51 | Args: 52 | arg (unicode or list, optional): Main output/``{query}``. 53 | **variables: Workflow variables to set. 54 | 55 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a 56 | :class:`list` or :class:`tuple`. 57 | 58 | Attributes: 59 | arg (unicode or list): Output value (``{query}``). 60 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a 61 | :class:`list` or :class:`tuple`. 62 | config (dict): Configuration for downstream workflow element. 63 | 64 | """ 65 | 66 | def __init__(self, arg=None, **variables): 67 | """Create a new `Variables` object.""" 68 | self.arg = arg 69 | self.config = {} 70 | super(Variables, self).__init__(**variables) 71 | 72 | @property 73 | def obj(self): 74 | """``alfredworkflow`` :class:`dict`.""" 75 | o = {} 76 | if self: 77 | d2 = {} 78 | for k, v in list(self.items()): 79 | d2[k] = v 80 | o["variables"] = d2 81 | 82 | if self.config: 83 | o["config"] = self.config 84 | 85 | if self.arg is not None: 86 | o["arg"] = self.arg 87 | 88 | return {"alfredworkflow": o} 89 | 90 | def __str__(self): 91 | """Convert to ``alfredworkflow`` JSON object. 92 | 93 | Returns: 94 | unicode: ``alfredworkflow`` JSON object 95 | 96 | """ 97 | if not self and not self.config: 98 | if not self.arg: 99 | return "" 100 | if isinstance(self.arg, str): 101 | return self.arg 102 | 103 | return json.dumps(self.obj) 104 | 105 | 106 | class Modifier(object): 107 | """Modify :class:`Item3` arg/icon/variables when modifier key is pressed. 108 | 109 | Don't use this class directly (as it won't be associated with any 110 | :class:`Item3`), but rather use :meth:`Item3.add_modifier()` 111 | to add modifiers to results. 112 | 113 | >>> it = wf.add_item('Title', 'Subtitle', valid=True) 114 | >>> it.setvar('name', 'default') 115 | >>> m = it.add_modifier('cmd') 116 | >>> m.setvar('name', 'alternate') 117 | 118 | See :ref:`workflow-variables` in the User Guide for more information 119 | and :ref:`example usage `. 120 | 121 | Args: 122 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. 123 | subtitle (unicode, optional): Override default subtitle. 124 | arg (unicode, optional): Argument to pass for this modifier. 125 | valid (bool, optional): Override item's validity. 126 | icon (unicode, optional): Filepath/UTI of icon to use 127 | icontype (unicode, optional): Type of icon. See 128 | :meth:`Workflow.add_item() ` 129 | for valid values. 130 | 131 | Attributes: 132 | arg (unicode): Arg to pass to following action. 133 | config (dict): Configuration for a downstream element, such as 134 | a File Filter. 135 | icon (unicode): Filepath/UTI of icon. 136 | icontype (unicode): Type of icon. See 137 | :meth:`Workflow.add_item() ` 138 | for valid values. 139 | key (unicode): Modifier key (see above). 140 | subtitle (unicode): Override item subtitle. 141 | valid (bool): Override item validity. 142 | variables (dict): Workflow variables set by this modifier. 143 | 144 | """ 145 | 146 | def __init__( 147 | self, key, subtitle=None, arg=None, valid=None, icon=None, icontype=None 148 | ): 149 | """Create a new :class:`Modifier`. 150 | 151 | Don't use this class directly (as it won't be associated with any 152 | :class:`Item3`), but rather use :meth:`Item3.add_modifier()` 153 | to add modifiers to results. 154 | 155 | Args: 156 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. 157 | subtitle (unicode, optional): Override default subtitle. 158 | arg (unicode, optional): Argument to pass for this modifier. 159 | valid (bool, optional): Override item's validity. 160 | icon (unicode, optional): Filepath/UTI of icon to use 161 | icontype (unicode, optional): Type of icon. See 162 | :meth:`Workflow.add_item() ` 163 | for valid values. 164 | 165 | """ 166 | self.key = key 167 | self.subtitle = subtitle 168 | self.arg = arg 169 | self.valid = valid 170 | self.icon = icon 171 | self.icontype = icontype 172 | 173 | self.config = {} 174 | self.variables = {} 175 | 176 | def setvar(self, name, value): 177 | """Set a workflow variable for this Item. 178 | 179 | Args: 180 | name (unicode): Name of variable. 181 | value (unicode): Value of variable. 182 | 183 | """ 184 | self.variables[name] = value 185 | 186 | def getvar(self, name, default=None): 187 | """Return value of workflow variable for ``name`` or ``default``. 188 | 189 | Args: 190 | name (unicode): Variable name. 191 | default (None, optional): Value to return if variable is unset. 192 | 193 | Returns: 194 | unicode or ``default``: Value of variable if set or ``default``. 195 | 196 | """ 197 | return self.variables.get(name, default) 198 | 199 | @property 200 | def obj(self): 201 | """Modifier formatted for JSON serialization for Alfred 3. 202 | 203 | Returns: 204 | dict: Modifier for serializing to JSON. 205 | 206 | """ 207 | o = {} 208 | 209 | if self.subtitle is not None: 210 | o["subtitle"] = self.subtitle 211 | 212 | if self.arg is not None: 213 | o["arg"] = self.arg 214 | 215 | if self.valid is not None: 216 | o["valid"] = self.valid 217 | 218 | if self.variables: 219 | o["variables"] = self.variables 220 | 221 | if self.config: 222 | o["config"] = self.config 223 | 224 | icon = self._icon() 225 | if icon: 226 | o["icon"] = icon 227 | 228 | return o 229 | 230 | def _icon(self): 231 | """Return `icon` object for item. 232 | 233 | Returns: 234 | dict: Mapping for item `icon` (may be empty). 235 | 236 | """ 237 | icon = {} 238 | if self.icon is not None: 239 | icon["path"] = self.icon 240 | 241 | if self.icontype is not None: 242 | icon["type"] = self.icontype 243 | 244 | return icon 245 | 246 | 247 | class Item3(object): 248 | """Represents a feedback item for Alfred 3+. 249 | 250 | Generates Alfred-compliant JSON for a single item. 251 | 252 | Don't use this class directly (as it then won't be associated with 253 | any :class:`Workflow3 ` object), but rather use 254 | :meth:`Workflow3.add_item() `. 255 | See :meth:`~workflow.Workflow3.add_item` for details of arguments. 256 | 257 | """ 258 | 259 | def __init__( 260 | self, 261 | title, 262 | subtitle="", 263 | arg=None, 264 | autocomplete=None, 265 | match=None, 266 | valid=False, 267 | uid=None, 268 | icon=None, 269 | icontype=None, 270 | type=None, 271 | largetext=None, 272 | copytext=None, 273 | quicklookurl=None, 274 | ): 275 | """Create a new :class:`Item3` object. 276 | 277 | Use same arguments as for 278 | :class:`Workflow.Item `. 279 | 280 | Argument ``subtitle_modifiers`` is not supported. 281 | 282 | """ 283 | self.title = title 284 | self.subtitle = subtitle 285 | self.arg = arg 286 | self.autocomplete = autocomplete 287 | self.match = match 288 | self.valid = valid 289 | self.uid = uid 290 | self.icon = icon 291 | self.icontype = icontype 292 | self.type = type 293 | self.quicklookurl = quicklookurl 294 | self.largetext = largetext 295 | self.copytext = copytext 296 | 297 | self.modifiers = {} 298 | 299 | self.config = {} 300 | self.variables = {} 301 | 302 | def setvar(self, name, value): 303 | """Set a workflow variable for this Item. 304 | 305 | Args: 306 | name (unicode): Name of variable. 307 | value (unicode): Value of variable. 308 | 309 | """ 310 | self.variables[name] = value 311 | 312 | def getvar(self, name, default=None): 313 | """Return value of workflow variable for ``name`` or ``default``. 314 | 315 | Args: 316 | name (unicode): Variable name. 317 | default (None, optional): Value to return if variable is unset. 318 | 319 | Returns: 320 | unicode or ``default``: Value of variable if set or ``default``. 321 | 322 | """ 323 | return self.variables.get(name, default) 324 | 325 | def add_modifier( 326 | self, key, subtitle=None, arg=None, valid=None, icon=None, icontype=None 327 | ): 328 | """Add alternative values for a modifier key. 329 | 330 | Args: 331 | key (unicode): Modifier key, e.g. ``"cmd"`` or ``"alt"`` 332 | subtitle (unicode, optional): Override item subtitle. 333 | arg (unicode, optional): Input for following action. 334 | valid (bool, optional): Override item validity. 335 | icon (unicode, optional): Filepath/UTI of icon. 336 | icontype (unicode, optional): Type of icon. See 337 | :meth:`Workflow.add_item() ` 338 | for valid values. 339 | 340 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a 341 | :class:`list` or :class:`tuple`. 342 | 343 | Returns: 344 | Modifier: Configured :class:`Modifier`. 345 | 346 | """ 347 | mod = Modifier(key, subtitle, arg, valid, icon, icontype) 348 | 349 | # Add Item variables to Modifier 350 | mod.variables.update(self.variables) 351 | 352 | self.modifiers[key] = mod 353 | 354 | return mod 355 | 356 | @property 357 | def obj(self): 358 | """Item formatted for JSON serialization. 359 | 360 | Returns: 361 | dict: Data suitable for Alfred 3 feedback. 362 | 363 | """ 364 | # Required values 365 | o = {"title": self.title, "subtitle": self.subtitle, "valid": self.valid} 366 | 367 | # Optional values 368 | if self.arg is not None: 369 | o["arg"] = self.arg 370 | 371 | if self.autocomplete is not None: 372 | o["autocomplete"] = self.autocomplete 373 | 374 | if self.match is not None: 375 | o["match"] = self.match 376 | 377 | if self.uid is not None: 378 | o["uid"] = self.uid 379 | 380 | if self.type is not None: 381 | o["type"] = self.type 382 | 383 | if self.quicklookurl is not None: 384 | o["quicklookurl"] = self.quicklookurl 385 | 386 | if self.variables: 387 | o["variables"] = self.variables 388 | 389 | if self.config: 390 | o["config"] = self.config 391 | 392 | # Largetype and copytext 393 | text = self._text() 394 | if text: 395 | o["text"] = text 396 | 397 | icon = self._icon() 398 | if icon: 399 | o["icon"] = icon 400 | 401 | # Modifiers 402 | mods = self._modifiers() 403 | if mods: 404 | o["mods"] = mods 405 | 406 | return o 407 | 408 | def _icon(self): 409 | """Return `icon` object for item. 410 | 411 | Returns: 412 | dict: Mapping for item `icon` (may be empty). 413 | 414 | """ 415 | icon = {} 416 | if self.icon is not None: 417 | icon["path"] = self.icon 418 | 419 | if self.icontype is not None: 420 | icon["type"] = self.icontype 421 | 422 | return icon 423 | 424 | def _text(self): 425 | """Return `largetext` and `copytext` object for item. 426 | 427 | Returns: 428 | dict: `text` mapping (may be empty) 429 | 430 | """ 431 | text = {} 432 | if self.largetext is not None: 433 | text["largetype"] = self.largetext 434 | 435 | if self.copytext is not None: 436 | text["copy"] = self.copytext 437 | 438 | return text 439 | 440 | def _modifiers(self): 441 | """Build `mods` dictionary for JSON feedback. 442 | 443 | Returns: 444 | dict: Modifier mapping or `None`. 445 | 446 | """ 447 | if self.modifiers: 448 | mods = {} 449 | for k, mod in list(self.modifiers.items()): 450 | mods[k] = mod.obj 451 | 452 | return mods 453 | 454 | return None 455 | 456 | 457 | class Workflow3(Workflow): 458 | """Workflow class that generates Alfred 3+ feedback. 459 | 460 | It is a subclass of :class:`~workflow.Workflow` and most of its 461 | methods are documented there. 462 | 463 | Attributes: 464 | item_class (class): Class used to generate feedback items. 465 | variables (dict): Top level workflow variables. 466 | 467 | """ 468 | 469 | item_class = Item3 470 | 471 | def __init__(self, **kwargs): 472 | """Create a new :class:`Workflow3` object. 473 | 474 | See :class:`~workflow.Workflow` for documentation. 475 | 476 | """ 477 | Workflow.__init__(self, **kwargs) 478 | self.variables = {} 479 | self._rerun = 0 480 | # Get session ID from environment if present 481 | self._session_id = os.getenv("_WF_SESSION_ID") or None 482 | if self._session_id: 483 | self.setvar("_WF_SESSION_ID", self._session_id) 484 | 485 | @property 486 | def _default_cachedir(self): 487 | """Alfred 4's default cache directory.""" 488 | return os.path.join( 489 | os.path.expanduser( 490 | "~/Library/Caches/com.runningwithcrayons.Alfred/" "Workflow Data/" 491 | ), 492 | self.bundleid, 493 | ) 494 | 495 | @property 496 | def _default_datadir(self): 497 | """Alfred 4's default data directory.""" 498 | return os.path.join( 499 | os.path.expanduser("~/Library/Application Support/Alfred/Workflow Data/"), 500 | self.bundleid, 501 | ) 502 | 503 | @property 504 | def rerun(self): 505 | """How often (in seconds) Alfred should re-run the Script Filter.""" 506 | return self._rerun 507 | 508 | @rerun.setter 509 | def rerun(self, seconds): 510 | """Interval at which Alfred should re-run the Script Filter. 511 | 512 | Args: 513 | seconds (int): Interval between runs. 514 | """ 515 | self._rerun = seconds 516 | 517 | @property 518 | def session_id(self): 519 | """A unique session ID every time the user uses the workflow. 520 | 521 | .. versionadded:: 1.25 522 | 523 | The session ID persists while the user is using this workflow. 524 | It expires when the user runs a different workflow or closes 525 | Alfred. 526 | 527 | """ 528 | if not self._session_id: 529 | from uuid import uuid4 530 | 531 | self._session_id = uuid4().hex 532 | self.setvar("_WF_SESSION_ID", self._session_id) 533 | 534 | return self._session_id 535 | 536 | def setvar(self, name, value, persist=False): 537 | """Set a "global" workflow variable. 538 | 539 | .. versionchanged:: 1.33 540 | 541 | These variables are always passed to downstream workflow objects. 542 | 543 | If you have set :attr:`rerun`, these variables are also passed 544 | back to the script when Alfred runs it again. 545 | 546 | Args: 547 | name (unicode): Name of variable. 548 | value (unicode): Value of variable. 549 | persist (bool, optional): Also save variable to ``info.plist``? 550 | 551 | """ 552 | self.variables[name] = value 553 | if persist: 554 | from .util import set_config 555 | 556 | set_config(name, value, self.bundleid) 557 | self.logger.debug( 558 | "saved variable %r with value %r to info.plist", name, value 559 | ) 560 | 561 | def getvar(self, name, default=None): 562 | """Return value of workflow variable for ``name`` or ``default``. 563 | 564 | Args: 565 | name (unicode): Variable name. 566 | default (None, optional): Value to return if variable is unset. 567 | 568 | Returns: 569 | unicode or ``default``: Value of variable if set or ``default``. 570 | 571 | """ 572 | return self.variables.get(name, default) 573 | 574 | def add_item( 575 | self, 576 | title, 577 | subtitle="", 578 | arg=None, 579 | autocomplete=None, 580 | valid=False, 581 | uid=None, 582 | icon=None, 583 | icontype=None, 584 | type=None, 585 | largetext=None, 586 | copytext=None, 587 | quicklookurl=None, 588 | match=None, 589 | ): 590 | """Add an item to be output to Alfred. 591 | 592 | Args: 593 | match (unicode, optional): If you have "Alfred filters results" 594 | turned on for your Script Filter, Alfred (version 3.5 and 595 | above) will filter against this field, not ``title``. 596 | 597 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a 598 | :class:`list` or :class:`tuple`. 599 | 600 | See :meth:`Workflow.add_item() ` for 601 | the main documentation and other parameters. 602 | 603 | The key difference is that this method does not support the 604 | ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()` 605 | method instead on the returned item instead. 606 | 607 | Returns: 608 | Item3: Alfred feedback item. 609 | 610 | """ 611 | item = self.item_class( 612 | title, 613 | subtitle, 614 | arg, 615 | autocomplete, 616 | match, 617 | valid, 618 | uid, 619 | icon, 620 | icontype, 621 | type, 622 | largetext, 623 | copytext, 624 | quicklookurl, 625 | ) 626 | 627 | # Add variables to child item 628 | item.variables.update(self.variables) 629 | 630 | self._items.append(item) 631 | return item 632 | 633 | @property 634 | def _session_prefix(self): 635 | """Filename prefix for current session.""" 636 | return "_wfsess-{0}-".format(self.session_id) 637 | 638 | def _mk_session_name(self, name): 639 | """New cache name/key based on session ID.""" 640 | return self._session_prefix + name 641 | 642 | def cache_data(self, name, data, session=False): 643 | """Cache API with session-scoped expiry. 644 | 645 | .. versionadded:: 1.25 646 | 647 | Args: 648 | name (str): Cache key 649 | data (object): Data to cache 650 | session (bool, optional): Whether to scope the cache 651 | to the current session. 652 | 653 | ``name`` and ``data`` are the same as for the 654 | :meth:`~workflow.Workflow.cache_data` method on 655 | :class:`~workflow.Workflow`. 656 | 657 | If ``session`` is ``True``, then ``name`` is prefixed 658 | with :attr:`session_id`. 659 | 660 | """ 661 | if session: 662 | name = self._mk_session_name(name) 663 | 664 | return super(Workflow3, self).cache_data(name, data) 665 | 666 | def cached_data(self, name, data_func=None, max_age=60, session=False): 667 | """Cache API with session-scoped expiry. 668 | 669 | .. versionadded:: 1.25 670 | 671 | Args: 672 | name (str): Cache key 673 | data_func (callable): Callable that returns fresh data. It 674 | is called if the cache has expired or doesn't exist. 675 | max_age (int): Maximum allowable age of cache in seconds. 676 | session (bool, optional): Whether to scope the cache 677 | to the current session. 678 | 679 | ``name``, ``data_func`` and ``max_age`` are the same as for the 680 | :meth:`~workflow.Workflow.cached_data` method on 681 | :class:`~workflow.Workflow`. 682 | 683 | If ``session`` is ``True``, then ``name`` is prefixed 684 | with :attr:`session_id`. 685 | 686 | """ 687 | if session: 688 | name = self._mk_session_name(name) 689 | 690 | return super(Workflow3, self).cached_data(name, data_func, max_age) 691 | 692 | def clear_session_cache(self, current=False): 693 | """Remove session data from the cache. 694 | 695 | .. versionadded:: 1.25 696 | .. versionchanged:: 1.27 697 | 698 | By default, data belonging to the current session won't be 699 | deleted. Set ``current=True`` to also clear current session. 700 | 701 | Args: 702 | current (bool, optional): If ``True``, also remove data for 703 | current session. 704 | 705 | """ 706 | 707 | def _is_session_file(filename): 708 | if current: 709 | return filename.startswith("_wfsess-") 710 | return filename.startswith("_wfsess-") and not filename.startswith( 711 | self._session_prefix 712 | ) 713 | 714 | self.clear_cache(_is_session_file) 715 | 716 | @property 717 | def obj(self): 718 | """Feedback formatted for JSON serialization. 719 | 720 | Returns: 721 | dict: Data suitable for Alfred 3 feedback. 722 | 723 | """ 724 | items = [] 725 | for item in self._items: 726 | items.append(item.obj) 727 | 728 | o = {"items": items} 729 | if self.variables: 730 | o["variables"] = self.variables 731 | if self.rerun: 732 | o["rerun"] = self.rerun 733 | return o 734 | 735 | def warn_empty(self, title, subtitle="", icon=None): 736 | """Add a warning to feedback if there are no items. 737 | 738 | .. versionadded:: 1.31 739 | 740 | Add a "warning" item to Alfred feedback if no other items 741 | have been added. This is a handy shortcut to prevent Alfred 742 | from showing its fallback searches, which is does if no 743 | items are returned. 744 | 745 | Args: 746 | title (unicode): Title of feedback item. 747 | subtitle (unicode, optional): Subtitle of feedback item. 748 | icon (str, optional): Icon for feedback item. If not 749 | specified, ``ICON_WARNING`` is used. 750 | 751 | Returns: 752 | Item3: Newly-created item. 753 | 754 | """ 755 | if len(self._items): 756 | return 757 | 758 | icon = icon or ICON_WARNING 759 | return self.add_item(title, subtitle, icon=icon) 760 | 761 | def send_feedback(self): 762 | """Print stored items to console/Alfred as JSON.""" 763 | if self.debugging: 764 | json.dump(self.obj, sys.stdout, indent=2, separators=(",", ": ")) 765 | else: 766 | json.dump(self.obj, sys.stdout) 767 | sys.stdout.flush() 768 | --------------------------------------------------------------------------------