├── .gitignore ├── 976D393D-46DE-45CD-B8BF-083333650B0D.png ├── LICENSE.txt ├── README.md ├── getKMdata.py ├── icon.png ├── imgs ├── packal-searchclips.gif ├── searchclips.gif └── searchvars.gif ├── info.plist ├── var_viewer.applescript ├── var_viewer.scptd └── Contents │ ├── Info.plist │ ├── Resources │ ├── Script Libraries │ │ └── Dialog Toolkit Plus.scptd │ │ │ └── Contents │ │ │ ├── Info.plist │ │ │ ├── Resources │ │ │ ├── ScriptingDictionary.sdef │ │ │ ├── Scripts │ │ │ │ └── main.recover.rtf │ │ │ └── description.rtfd │ │ │ │ └── TXT.rtf │ │ │ └── Script Debugger.plist │ ├── Scripts │ │ └── main.recover.rtf │ └── description.rtfd │ │ └── TXT.rtf │ └── Script Debugger.plist └── workflow ├── .alfredversionchecked ├── Notify.tgz ├── __init__.py ├── __init__.pyc ├── background.py ├── background.pyc ├── notify.py ├── update.py ├── update.pyc ├── util.py ├── util.pyc ├── version ├── web.py ├── web.pyc ├── workflow.py ├── workflow.pyc ├── workflow3.py └── workflow3.pyc /.gitignore: -------------------------------------------------------------------------------- 1 | ############# 2 | # Mac Files # 3 | ############# 4 | .DS_Store 5 | .Trashes 6 | .Spotlight-V100 7 | .idea 8 | 9 | *.scpt 10 | -------------------------------------------------------------------------------- /976D393D-46DE-45CD-B8BF-083333650B0D.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevin-funderburg/alfred-search-keyboard-maestro-vars/a996043d8ed6076b10f245a5747d0b921c11dae7/976D393D-46DE-45CD-B8BF-083333650B0D.png -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Kevin Funderburg 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 | 3 |

4 | 5 | # Search Keyboard Maestro Variables/Clipboards 6 | 7 | Search all Keyboard Maestro variables and named clipboards in [Alfred][alfredapp]. 8 | * * * 9 | ![searchvars](imgs/searchvars.gif) 10 | * * * 11 | ![searchclips](imgs/searchclips.gif) 12 | * * * 13 | 14 | ## Features 15 | 16 | - Search all variables to: view, edit, or delete 17 | 18 | - Search and view all named clipboard contents: 19 | 20 | - **When searching variables**: 21 | - Press ↩︎ to open variable in an editor window. 22 | - _Editor window actions_: 23 | - _Edit_ the value of the variable, then click `OK` (or ↩︎ if value is not multiple lines) to update variable in Keyboard Maestro 24 | - _Delete_ variable by clicking `Delete` (or D) 25 | - Press ↩︎ to delete variable from Alfred window. 26 | - _NOTE:_ If the value of the variable is < 100 characters, it will display the value of the variable as the subtitle of search result 27 | - **When searching clipboards**: 28 | - Press ↩︎ to display clipboard in window. 29 | 30 | ## Installation 31 | 32 | Download [the latest release][gh-releases] and double-click the file to install in Alfred. 33 | 34 | ## Usage 35 | 36 | The two main keywords are `kmv` & `kmc`: 37 | 38 | - `kmv []` - Search all variables 39 | - ↩︎ or NUM — Open variable in editor window. 40 | - ↩︎ — Delete variable in Keyboard Maestro 41 | 42 | - `kmc []` — Search all named clipboards in Keyboard Maestro. 43 | - ↩︎ or NUM — Display clipboard in window. 44 | 45 | ## Configuration 46 | 47 | ## Licensing & thanks 48 | 49 | This workflow is released under the [MIT Licence][mit]. 50 | 51 | This workflow uses on the wonderful library [alfred-workflow](https://github.com/deanishe/alfred-workflow) by [@deanishe](https://github.com/deanishe). 52 | 53 | ## Changelog 54 | 55 | - v1.0.0 56 | - First public release 57 | 58 | [alfredapp]: https://www.alfredapp.com/ 59 | [gh-releases]: https://github.com/kevin-funderburg/alfred-search-keyboard-maestro-vars/releases/latest 60 | [mit]: https://raw.githubusercontent.com/kevin-funderburg/alfred-search-keyboard-maestro-vars/master/LICENCE.txt 61 | -------------------------------------------------------------------------------- /getKMdata.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # encoding: utf-8 3 | 4 | from __future__ import unicode_literals 5 | 6 | import os 7 | import argparse 8 | import plistlib 9 | import sys 10 | import sqlite3 11 | from sqlite3 import Error 12 | 13 | from workflow import Workflow3, ICON_INFO, ICON_WARNING, ICON_ERROR 14 | 15 | KM_APP_SUPPORT = os.path.expanduser("~/Library/Application Support/Keyboard Maestro/") 16 | KM_APP_RESOURCES = "/System/Volumes/Data/Applications/Keyboard Maestro.app/Contents/Resources/" 17 | 18 | VARS_DB = KM_APP_SUPPORT + "Keyboard Maestro Variables.sqlite" 19 | CLIPS_PLIST = KM_APP_SUPPORT + "Keyboard Maestro Clipboards.plist" 20 | ICON_KM_VAR = KM_APP_RESOURCES + "Variable.icns" 21 | ICON_KM_CLIP = KM_APP_RESOURCES + "ClipboardIcon.icns" 22 | 23 | wf = None 24 | log = None 25 | 26 | 27 | # noinspection PyProtectedMember 28 | def main(wf): 29 | parser = argparse.ArgumentParser() 30 | parser.add_argument('-v', dest='vars', action='store_true') 31 | parser.add_argument('-c', dest='clips', action='store_true') 32 | parser.add_argument('query', nargs='?', default=None) 33 | args = parser.parse_args(wf.args) 34 | 35 | if args.vars: 36 | sql = "SELECT name, value FROM variables WHERE value IS NOT '%Delete%';" 37 | 38 | # create a database connection 39 | conn = create_connection(VARS_DB) 40 | with conn: 41 | log.info("query: " + sql) 42 | cur = conn.cursor() 43 | cur.execute(sql) 44 | rows = cur.fetchall() 45 | for row in rows: 46 | name = row[0] 47 | value = row[1] 48 | if len(value) < 100: 49 | sub = value 50 | else: 51 | sub = 'press ↩︎ to view in window' 52 | 53 | it = wf.add_item(uid=value, 54 | title=name, 55 | subtitle=sub, 56 | arg=[name,value], 57 | autocomplete=name, 58 | valid=True, 59 | icon=ICON_KM_VAR, 60 | icontype="filepath", 61 | quicklookurl=value) 62 | it.add_modifier('cmd', subtitle="delete '" + name + "'", arg=[name,value], valid=True) 63 | 64 | elif args.clips: 65 | clips_pl = plistlib.readPlist(CLIPS_PLIST) 66 | for clip in clips_pl: 67 | name = clip['Name'] 68 | uid = clip['UID'] 69 | it = wf.add_item(uid=uid, 70 | title=name, 71 | subtitle='press ↩︎ to view', 72 | arg=[name, uid], 73 | autocomplete=name, 74 | valid=True, 75 | icon=ICON_KM_CLIP, 76 | icontype="filepath", 77 | quicklookurl=ICON_KM_CLIP) 78 | 79 | if len(wf._items) == 0: 80 | wf.add_item('No items found', icon=ICON_WARNING) 81 | 82 | wf.send_feedback() 83 | 84 | 85 | def create_connection(db_file): 86 | """ create a database connection to the SQLite database 87 | specified by the db_file 88 | :param db_file: database file 89 | :return: Connection object or None 90 | """ 91 | conn = None 92 | try: 93 | conn = sqlite3.connect(db_file) 94 | except Error as e: 95 | print(e) 96 | 97 | return conn 98 | 99 | 100 | if __name__ == '__main__': 101 | wf = Workflow3() 102 | log = wf.logger 103 | sys.exit(wf.run(main)) 104 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevin-funderburg/alfred-search-keyboard-maestro-vars/a996043d8ed6076b10f245a5747d0b921c11dae7/icon.png -------------------------------------------------------------------------------- /imgs/packal-searchclips.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevin-funderburg/alfred-search-keyboard-maestro-vars/a996043d8ed6076b10f245a5747d0b921c11dae7/imgs/packal-searchclips.gif -------------------------------------------------------------------------------- /imgs/searchclips.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevin-funderburg/alfred-search-keyboard-maestro-vars/a996043d8ed6076b10f245a5747d0b921c11dae7/imgs/searchclips.gif -------------------------------------------------------------------------------- /imgs/searchvars.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevin-funderburg/alfred-search-keyboard-maestro-vars/a996043d8ed6076b10f245a5747d0b921c11dae7/imgs/searchvars.gif -------------------------------------------------------------------------------- /info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | com.kfunderburg.alfred-searchKMvariables 7 | category 8 | Tools 9 | connections 10 | 11 | 976D393D-46DE-45CD-B8BF-083333650B0D 12 | 13 | 14 | destinationuid 15 | 368744CA-9CE8-423A-A6F9-7AE591B10450 16 | modifiers 17 | 0 18 | modifiersubtext 19 | 20 | vitoclose 21 | 22 | 23 | 24 | F465E1CF-1023-4618-98DD-140098F68A71 25 | 26 | 27 | destinationuid 28 | 69E79431-C1D2-4F73-AE71-F8C90760C9B9 29 | modifiers 30 | 0 31 | modifiersubtext 32 | 33 | vitoclose 34 | 35 | 36 | 37 | destinationuid 38 | DF78BC78-5104-4673-80E7-BC04093A2DE9 39 | modifiers 40 | 1048576 41 | modifiersubtext 42 | 43 | vitoclose 44 | 45 | 46 | 47 | 48 | createdby 49 | Kevin Funderburg 50 | description 51 | search and view all the variables stored in Keyboard Maestro 52 | disabled 53 | 54 | name 55 | Keyboard Maestro - Search Variables 56 | objects 57 | 58 | 59 | config 60 | 61 | alfredfiltersresults 62 | 63 | alfredfiltersresultsmatchmode 64 | 0 65 | argumenttreatemptyqueryasnil 66 | 67 | argumenttrimmode 68 | 0 69 | argumenttype 70 | 1 71 | escaping 72 | 102 73 | keyword 74 | kmv 75 | queuedelaycustom 76 | 3 77 | queuedelayimmediatelyinitially 78 | 79 | queuedelaymode 80 | 0 81 | queuemode 82 | 1 83 | runningsubtext 84 | getting variables... 85 | script 86 | /usr/bin/python getKMdata.py -v 87 | scriptargtype 88 | 1 89 | scriptfile 90 | 91 | subtext 92 | type 'kmv' to view variables 93 | title 94 | view value of Keyboard Maestro variables 95 | type 96 | 5 97 | withspace 98 | 99 | 100 | type 101 | alfred.workflow.input.scriptfilter 102 | uid 103 | F465E1CF-1023-4618-98DD-140098F68A71 104 | version 105 | 3 106 | 107 | 108 | config 109 | 110 | concurrently 111 | 112 | escaping 113 | 102 114 | script 115 | osascript var_viewer.scptd "$1" "$2" 116 | scriptargtype 117 | 1 118 | scriptfile 119 | 120 | type 121 | 0 122 | 123 | type 124 | alfred.workflow.action.script 125 | uid 126 | 69E79431-C1D2-4F73-AE71-F8C90760C9B9 127 | version 128 | 2 129 | 130 | 131 | config 132 | 133 | concurrently 134 | 135 | escaping 136 | 68 137 | script 138 | use scripting additions 139 | 140 | on run argv 141 | set {_name, _val} to argv 142 | 143 | tell application "Keyboard Maestro Engine" 144 | setvariable _name to "%Delete%" 145 | end tell 146 | 147 | display notification ("'" & _name & "' deleted") 148 | end run 149 | scriptargtype 150 | 1 151 | scriptfile 152 | 153 | type 154 | 6 155 | 156 | type 157 | alfred.workflow.action.script 158 | uid 159 | DF78BC78-5104-4673-80E7-BC04093A2DE9 160 | version 161 | 2 162 | 163 | 164 | config 165 | 166 | alfredfiltersresults 167 | 168 | alfredfiltersresultsmatchmode 169 | 0 170 | argumenttreatemptyqueryasnil 171 | 172 | argumenttrimmode 173 | 0 174 | argumenttype 175 | 1 176 | escaping 177 | 102 178 | keyword 179 | kmc 180 | queuedelaycustom 181 | 3 182 | queuedelayimmediatelyinitially 183 | 184 | queuedelaymode 185 | 0 186 | queuemode 187 | 1 188 | runningsubtext 189 | getting clipboards.. 190 | script 191 | /usr/bin/python getKMdata.py -c 192 | scriptargtype 193 | 1 194 | scriptfile 195 | 196 | subtext 197 | type 'kmc' to view clipboards 198 | title 199 | view Keyboard Maestro clipboards 200 | type 201 | 5 202 | withspace 203 | 204 | 205 | type 206 | alfred.workflow.input.scriptfilter 207 | uid 208 | 976D393D-46DE-45CD-B8BF-083333650B0D 209 | version 210 | 3 211 | 212 | 213 | config 214 | 215 | concurrently 216 | 217 | escaping 218 | 68 219 | script 220 | on run argv 221 | set {cname, cuid} to argv 222 | log "cname: " & cname & return & "cuid: " & cuid 223 | displayClip(cname, cuid) 224 | end run 225 | 226 | 227 | on displayClip(_name, _id) set scpt to "<dict> 228 | <key>MacroActionType</key> 229 | <string>DisplayImage</string> 230 | <key>TargetNamedClipboardRedundantDisplayName</key> 231 | <string>" & _name & "</string> 232 | <key>TargetNamedClipboardUID</key> 233 | <string>" & _id & "</string> 234 | <key>TargetUseNamedClipboard</key> 235 | <true/> 236 | </dict>" tell application "Keyboard Maestro Engine" to do script scpt end displayClip 237 | scriptargtype 238 | 1 239 | scriptfile 240 | 241 | type 242 | 6 243 | 244 | type 245 | alfred.workflow.action.script 246 | uid 247 | 368744CA-9CE8-423A-A6F9-7AE591B10450 248 | version 249 | 2 250 | 251 | 252 | readme 253 | Search all Keyboard Maestro variables and clipboards. 254 | uidata 255 | 256 | 368744CA-9CE8-423A-A6F9-7AE591B10450 257 | 258 | xpos 259 | 235 260 | ypos 261 | 285 262 | 263 | 69E79431-C1D2-4F73-AE71-F8C90760C9B9 264 | 265 | note 266 | open in editor 267 | xpos 268 | 235 269 | ypos 270 | 35 271 | 272 | 976D393D-46DE-45CD-B8BF-083333650B0D 273 | 274 | colorindex 275 | 9 276 | note 277 | search clipboards 278 | xpos 279 | 10 280 | ypos 281 | 285 282 | 283 | DF78BC78-5104-4673-80E7-BC04093A2DE9 284 | 285 | note 286 | delete var 287 | xpos 288 | 235 289 | ypos 290 | 155 291 | 292 | F465E1CF-1023-4618-98DD-140098F68A71 293 | 294 | colorindex 295 | 1 296 | note 297 | search variables 298 | xpos 299 | 10 300 | ypos 301 | 35 302 | 303 | 304 | variablesdontexport 305 | 306 | version 307 | 1.0.0 308 | webaddress 309 | https://github.com/kevin-funderburg/alfred-search-keyboard-maestro-vars 310 | 311 | 312 | -------------------------------------------------------------------------------- /var_viewer.applescript: -------------------------------------------------------------------------------- 1 | -- 2 | -- Created by: Kevin Funderburg 3 | -- Created on: 9/11/20 4 | -- 5 | -- Copyright © 2020 funderburg, All Rights Reserved 6 | -- 7 | 8 | use AppleScript version "2.4" -- Yosemite (10.10) or later 9 | use framework "Foundation" 10 | use framework "AppKit" 11 | use scripting additions 12 | use script "Dialog Toolkit Plus" 13 | property ICON_KM : "/System/Volumes/Data/Applications/Keyboard Maestro.app/Contents/Resources/Variable.icns" 14 | 15 | on run argv 16 | 17 | if class of argv = script then 18 | set argv to {"_path", "System/Volumes/Data/Applications/Keyboard Maestro.app/Contents/Resources/Variable.icn"} 19 | end if 20 | set {_name, val} to argv 21 | set {width, height} to screensize() 22 | set maxFieldHeight to height * 0.75 23 | set lineCount to count of paragraphs of val 24 | set fieldHeight to 15 * lineCount 25 | if fieldHeight > maxFieldHeight then 26 | set fieldHeight to maxFieldHeight 27 | end if 28 | set maxWidth to 550 29 | set minWidth to 400 30 | set accWidth to getLongestLine(val) 31 | set accWidth to (accWidth / 1.5) * 10 32 | if accWidth > maxWidth then 33 | set accWidth to maxWidth 34 | else if accWidth < minWidth then 35 | set accWidth to minWidth 36 | end if 37 | set accViewWidth to accWidth 38 | set {theButtons, minWidth} to ¬ 39 | create buttons {"Cancel", "Delete", "OK"} ¬ 40 | button keys {"", "d", ""} ¬ 41 | default button 3 ¬ 42 | cancel button 1 43 | if minWidth > accViewWidth then set accViewWidth to minWidth -- make sure buttons fit 44 | set {theField, theTop} to ¬ 45 | create field (val) ¬ 46 | placeholder text ("Enter your text here") ¬ 47 | bottom 0 ¬ 48 | field width accViewWidth ¬ 49 | extra height fieldHeight ¬ 50 | with accepts linebreak and tab 51 | 52 | set {regLabel, theTop} to ¬ 53 | create label ("OK:" & tab & tab & "Dismiss or set variable to new value" & return & ¬ 54 | "Delete:" & tab & "Delete variable from Keyboard Maestro (⌘D)" & return & ¬ 55 | "Cancel:" & tab & "⎋") ¬ 56 | bottom theTop + 10 ¬ 57 | max width accViewWidth ¬ 58 | left inset 75 ¬ 59 | control size regular size ¬ 60 | aligns left aligned 61 | 62 | set {theRule, theTop} to ¬ 63 | create rule (theTop + 12) ¬ 64 | left inset 75 ¬ 65 | rule width accViewWidth - 60 66 | 67 | set {boldLabel, theTop} to ¬ 68 | create label ("View or edit the Keyboard Maestro variable here.") ¬ 69 | bottom theTop + 10 ¬ 70 | max width accViewWidth ¬ 71 | left inset 75 ¬ 72 | control size regular size ¬ 73 | aligns left aligned ¬ 74 | with bold type 75 | set {imgView, theTop} to ¬ 76 | create image view (ICON_KM) ¬ 77 | left inset 10 ¬ 78 | bottom theTop - 50 ¬ 79 | view width 50 ¬ 80 | view height 50 ¬ 81 | scale image scale proportionally ¬ 82 | align image top left aligned 83 | set {buttonName, controlsResults} to ¬ 84 | display enhanced window ("Keyboard Maestro Variable Viewer") ¬ 85 | acc view width accViewWidth ¬ 86 | acc view height theTop + 5 ¬ 87 | acc view controls {theField, regLabel, theRule, boldLabel, imgView} ¬ 88 | buttons theButtons ¬ 89 | active field theField ¬ 90 | initial position {0, 0} ¬ 91 | with align cancel button 92 | 93 | if buttonName = "OK" then 94 | set newVal to item 1 of controlsResults 95 | if newVal ≠ val then 96 | tell application "Keyboard Maestro Engine" 97 | setvariable _name to newVal 98 | end tell 99 | display notification ("'" & _name & "' updated to " & newVal) 100 | end if 101 | end if 102 | end run 103 | 104 | on getLongestLine(txt) 105 | set max to 0 106 | repeat with p in paragraphs of txt 107 | if (count of characters of contents of p) > max then 108 | set max to count of characters of contents of p 109 | end if 110 | end repeat 111 | return max 112 | end getLongestLine 113 | 114 | on screensize() 115 | set theScreen to current application's NSScreen's mainScreen() 116 | set theFrame to theScreen's visibleFrame() 117 | set width to item 1 of item 2 of theFrame 118 | set height to item 2 of item 2 of theFrame 119 | return {width, height} 120 | end screensize 121 | 122 | -------------------------------------------------------------------------------- /var_viewer.scptd/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleIconFile 6 | applet 7 | CFBundleIdentifier 8 | com.kfunderburg.km-var-viewer 9 | CFBundleVersion 10 | 101 11 | NSHumanReadableCopyright 12 | Copyright © 2020 funderburg, All Rights Reserved 13 | 14 | 15 | -------------------------------------------------------------------------------- /var_viewer.scptd/Contents/Resources/Script Libraries/Dialog Toolkit Plus.scptd/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleIconFile 6 | applet 7 | CFBundleIdentifier 8 | au.com.myriad-com.lib.Dialog-Toolkit-Plus 9 | CFBundleShortVersionString 10 | 1.1.2 11 | CFBundleVersion 12 | 5 13 | LSMinimumSystemVersion 14 | 10.10.0 15 | NSHumanReadableCopyright 16 | Copyright © 2014-19 Myriad Communications, All Rights Reserved 17 | OSAAppleScriptObjCEnabled 18 | 19 | OSAScriptingDefinition 20 | ScriptingDictionary 21 | TemplateForCFBundleIdentifier 22 | au.com.myriad-com.lib.Dialog-Toolkit2 23 | WindowState 24 | 25 | bundleDividerCollapsed 26 | 27 | bundlePositionOfDivider 28 | 697 29 | dividerCollapsed 30 | 31 | eventLogLevel 32 | -1 33 | name 34 | ScriptWindowState 35 | positionOfDivider 36 | 609 37 | savedFrame 38 | 186 393 996 860 0 0 2560 1417 39 | selectedTab 40 | log 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /var_viewer.scptd/Contents/Resources/Script Libraries/Dialog Toolkit Plus.scptd/Contents/Resources/ScriptingDictionary.sdef: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | -------------------------------------------------------------------------------- /var_viewer.scptd/Contents/Resources/Script Libraries/Dialog Toolkit Plus.scptd/Contents/Resources/description.rtfd/TXT.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\ansicpg1252\cocoartf1671\cocoasubrtf500 2 | {\fonttbl} 3 | {\colortbl;\red255\green255\blue255;} 4 | {\*\expandedcolortbl;;} 5 | } -------------------------------------------------------------------------------- /var_viewer.scptd/Contents/Resources/Script Libraries/Dialog Toolkit Plus.scptd/Contents/Script Debugger.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SD4Version 6 | 7.0.8 7 | SD5ConversionOffered 8 | 9 | SDAddUsedLibrariesOnExport 10 | 0 11 | SDAutoIncBuildNumber 12 | 13 | SDBreakOnExceptions 14 | 15 | SDBreakpointsEnabled 16 | 17 | SDBundleVersion 18 | 2.0 19 | SDCocoaTermsInProperties 20 | 21 | SDCodesignOnlyOnExport 22 | 23 | SDFolds 24 | YnBsaXN0MDDUAQIDBAUGFBVYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0 25 | b3ASAAGGoKMHCA1VJG51bGzSCQoLDFpOUy5vYmplY3RzViRjbGFzc6CAAtIODxAR 26 | WiRjbGFzc25hbWVYJGNsYXNzZXNeTlNNdXRhYmxlQXJyYXmjEBITV05TQXJyYXlY 27 | TlNPYmplY3RfEA9OU0tleWVkQXJjaGl2ZXLRFhdUcm9vdIABCBEaIy0yNztBRlFY 28 | WVtga3SDh4+Yqq2yAAAAAAAAAQEAAAAAAAAAGAAAAAAAAAAAAAAAAAAAALQ= 29 | SDHideCodeFolding 30 | 31 | SDPersistentGlobals 32 | 33 | SDWindowState 34 | 35 | eventLogWindow 36 | 37 | resultWindow 38 | 39 | traceDelay 40 | 0.80000001192092896 41 | viewControllers 42 | 43 | D475926E-89D8-42B3-826C-C4BAD24DDA3C-75749-0003A038B6B37AC8 44 | 45 | debuggerViewState 46 | 47 | expressionsState 48 | 49 | dividerPos 50 | 150 51 | scope2.changedItems 52 | 53 | scope2.emptyElements 54 | 55 | scope2.filledElements 56 | 57 | scope2.props 58 | 59 | scope2.unchangedItems 60 | 61 | showScopeBar 62 | 63 | 64 | heights4 65 | 66 | positions 67 | 68 | 3 69 | 70 | 463 71 | 586 72 | 231 73 | 74 | 5 75 | 76 | 247 77 | 100 78 | 313 79 | 80 80 | 124 81 | 82 | 83 | 84 | resultState 85 | 86 | explorerState 87 | 88 | dividerPos 89 | 150 90 | scope2.changedItems 91 | 92 | scope2.emptyElements 93 | 94 | scope2.filledElements 95 | 96 | scope2.props 97 | 98 | scope2.unchangedItems 99 | 100 | showScopeBar 101 | 102 | 103 | modes 104 | 105 | mode.best 106 | 107 | positions2 108 | 109 | positions 110 | 111 | 112 | prettyPrint 113 | 114 | 115 | stackVisible 116 | 117 | variablesState 118 | 119 | dividerPos 120 | 150 121 | scope2.changedItems 122 | 123 | scope2.emptyElements 124 | 125 | scope2.filledElements 126 | 127 | scope2.props 128 | 129 | scope2.unchangedItems 130 | 131 | showScopeBar 132 | 133 | 134 | 135 | eventLogHeight 136 | 174.5 137 | inspectorsViewState 138 | 139 | applications 140 | 141 | dividerPos2 142 | 144 143 | searchFlags 144 | 167784446 145 | 146 | clippings 147 | 148 | heights3 149 | 150 | positions 151 | 152 | 3 153 | 154 | 320 155 | 292 156 | 289 157 | 158 | 159 | 160 | tellContext 161 | 162 | explorer 163 | 164 | dividerPos 165 | 291 166 | scope2.changedItems 167 | 168 | scope2.emptyElements 169 | 170 | scope2.filledElements 171 | 172 | scope2.props 173 | 174 | scope2.unchangedItems 175 | 176 | showScopeBar 177 | 178 | 179 | 180 | 181 | mainSplitPosition 182 | 440 183 | modeIdentifier 184 | tab.rsrcs 185 | resourcesViewState 186 | 187 | heights2 188 | 189 | positions 190 | 191 | 2 192 | 193 | 430 194 | 863 195 | 196 | 197 | 198 | resources 199 | 200 | expanded 201 | 202 | selection 203 | 204 | 205 | 206 | scriptViewState 207 | 208 | editorState 209 | 210 | LNSSelection 211 | 212 | 213 | length 214 | 0 215 | location 216 | 3757 217 | 218 | 219 | LNSTextViewShowInvisibles 220 | 221 | LNSTextViewShowSpaces 222 | 223 | LNSTextViewShowTabStops 224 | 225 | LNSTextViewWrapLines 226 | 227 | LNSVisibleRect 228 | {{0, 1311}, {765, 1276}} 229 | ShowLineNumbers 230 | 231 | showCodeCoverage 232 | 233 | 234 | instanceID 235 | 2DEF509D-FD5A-4E64-B753-D60FFF4CB31F-52917-0000901E0F89C2D1 236 | resultBarVisible 237 | 238 | showProgressBar 239 | 240 | 241 | showEventLog 242 | 243 | tabHeights 244 | 245 | tab.bps 246 | 433 247 | tab.debug 248 | 439 249 | tab.rsrcs 250 | 439 251 | 252 | tabsHidden 253 | 254 | windowControllerID 255 | D475926E-89D8-42B3-826C-C4BAD24DDA3C-75749-0003A038B6B37AC8 256 | windowFrame 257 | 1018 53 1271 1364 0 0 2560 1417 258 | 259 | 260 | windowControllers 261 | 262 | 263 | controllerClass 264 | ScriptWindowController 265 | controllerID 266 | D475926E-89D8-42B3-826C-C4BAD24DDA3C-75749-0003A038B6B37AC8 267 | controllerState 268 | 269 | miniaturized 270 | 271 | windowFrame 272 | 1018 53 1271 1364 0 0 2560 1417 273 | 274 | 275 | 276 | 277 | 278 | 279 | -------------------------------------------------------------------------------- /var_viewer.scptd/Contents/Resources/Scripts/main.recover.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\ansicpg1252\cocoartf2513 2 | \cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 FiraCode-Retina;\f1\fnil\fcharset0 FiraCode-Bold;\f2\fnil\fcharset0 FiraCode-Medium; 3 | \f3\fnil\fcharset0 Menlo-Bold;} 4 | {\colortbl;\red255\green255\blue255;\red102\green102\blue102;\red0\green0\blue0;\red251\green2\blue128; 5 | \red0\green128\blue255;\red219\green112\blue9;\red64\green128\blue2;\red0\green0\blue255;\red251\green2\blue7; 6 | \red43\green131\blue159;\red15\green128\blue255;\red128\green0\blue255;\red68\green21\blue176;\red20\green135\blue173; 7 | } 8 | {\*\expandedcolortbl;;\csgenericrgb\c40000\c40000\c40000;\csgenericrgb\c0\c0\c0;\csgenericrgb\c98604\c821\c50192; 9 | \csgenericrgb\c0\c50196\c100000;\csgenericrgb\c86026\c44067\c3627;\csgenericrgb\c25098\c50194\c896;\csgenericrgb\c0\c0\c100000;\csgenericrgb\c98624\c711\c2742; 10 | \csgenericrgb\c16899\c51199\c62499;\csgenericrgb\c5951\c50193\c99845;\csgenericrgb\c50197\c148\c99850;\csgenericrgb\c26552\c8261\c69158;\csgenericrgb\c7950\c52904\c67881; 11 | } 12 | \deftab480 13 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 14 | 15 | \f0\fs26 \cf2 --\cf3 \ 16 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 17 | \cf2 -- Created by: Kevin Funderburg\cf3 \ 18 | \cf2 -- Created on: 9/11/20\cf3 \ 19 | \cf2 --\cf3 \ 20 | \cf2 -- Copyright \'a9 2020 funderburg, All Rights Reserved\cf3 \ 21 | \cf2 --\cf3 \ 22 | \ 23 | 24 | \f1\b use 25 | \f0\b0 26 | \f2 \cf4 AppleScript 27 | \f0 \cf3 \cf5 version\cf3 "2.4" \cf2 -- Yosemite (10.10) or later\cf3 \ 28 | 29 | \f1\b use 30 | \f0\b0 31 | \f2 \cf6 framework 32 | \f0 \cf3 "Foundation"\ 33 | 34 | \f1\b use 35 | \f0\b0 36 | \f2 \cf6 framework 37 | \f0 \cf3 "AppKit"\ 38 | 39 | \f1\b use 40 | \f0\b0 41 | \f2 \cf6 scripting additions 42 | \f0 \cf3 \ 43 | 44 | \f1\b use 45 | \f0\b0 46 | \f2 \cf6 script 47 | \f0 \cf3 "Dialog Toolkit Plus"\ 48 | 49 | \f1\b property 50 | \f0\b0 \cf7 ICON_KM\cf3 : "/System/Volumes/Data/Applications/Keyboard Maestro.app/Contents/Resources/Variable.icns"\ 51 | \ 52 | 53 | \f1\b on 54 | \f0\b0 55 | \f1\b \cf8 run 56 | \f0\b0 \cf3 \cf7 argv\cf3 \ 57 | \ 58 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 59 | \cf3 60 | \f1\b if 61 | \f0\b0 62 | \f2 \cf6 class 63 | \f0 \cf3 64 | \f1\b of 65 | \f0\b0 \cf7 argv\cf3 = 66 | \f2 \cf6 script 67 | \f0 \cf3 68 | \f1\b then 69 | \f0\b0 \cf2 -- for testing\cf3 \ 70 | 71 | \f1\b set 72 | \f0\b0 \cf7 argv\cf3 73 | \f1\b to 74 | \f0\b0 \{"_path", "System/Volumes/Data/Applications/Keyboard Maestro.app/Contents/Resources/Variable.icn"\}\ 75 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 76 | \cf3 77 | \f1\b end 78 | \f0\b0 79 | \f1\b if 80 | \f0\b0 \ 81 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 82 | \cf3 83 | \f1\b set 84 | \f0\b0 \{\cf7 _name\cf3 , \cf7 val\cf3 \} 85 | \f1\b to 86 | \f0\b0 \cf7 argv\cf3 \ 87 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 88 | \cf3 \ 89 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 90 | \cf3 91 | \f1\b set 92 | \f0\b0 \{\cf7 width\cf3 , \cf7 height\cf3 \} 93 | \f1\b to 94 | \f0\b0 \cf9 screensize\cf3 ()\ 95 | 96 | \f1\b set 97 | \f0\b0 \cf7 maxFieldHeight\cf3 98 | \f1\b to 99 | \f0\b0 \cf7 height\cf3 * \cf10 0.75\cf3 \ 100 | 101 | \f1\b set 102 | \f0\b0 \cf7 lineCount\cf3 103 | \f1\b to 104 | \f0\b0 105 | \f1\b \cf8 count 106 | \f0\b0 \cf3 107 | \f1\b of 108 | \f0\b0 109 | \f2 \cf6 paragraphs 110 | \f0 \cf3 111 | \f1\b of 112 | \f0\b0 \cf7 val\cf3 \ 113 | 114 | \f1\b set 115 | \f0\b0 \cf7 fieldHeight\cf3 116 | \f1\b to 117 | \f0\b0 \cf10 15\cf3 * \cf7 lineCount\cf3 \ 118 | 119 | \f1\b if 120 | \f0\b0 \cf7 fieldHeight\cf3 > \cf7 maxFieldHeight\cf3 121 | \f1\b then 122 | \f0\b0 123 | \f1\b set 124 | \f0\b0 \cf7 fieldHeight\cf3 125 | \f1\b to 126 | \f0\b0 \cf7 maxFieldHeight\cf3 \ 127 | 128 | \f1\b set 129 | \f0\b0 \cf7 maxWidth\cf3 130 | \f1\b to 131 | \f0\b0 \cf10 550\cf3 \ 132 | 133 | \f1\b set 134 | \f0\b0 \cf7 minWidth\cf3 135 | \f1\b to 136 | \f0\b0 \cf10 400\cf3 \ 137 | 138 | \f1\b set 139 | \f0\b0 \cf7 accWidth\cf3 140 | \f1\b to 141 | \f0\b0 \cf9 getLongestLine\cf3 (\cf7 val\cf3 )\ 142 | 143 | \f1\b set 144 | \f0\b0 \cf7 accWidth\cf3 145 | \f1\b to 146 | \f0\b0 (\cf7 accWidth\cf3 / \cf10 1.5\cf3 ) * \cf10 10\cf3 \ 147 | 148 | \f1\b if 149 | \f0\b0 \cf7 accWidth\cf3 > \cf7 maxWidth\cf3 150 | \f1\b then 151 | \f0\b0 \ 152 | 153 | \f1\b set 154 | \f0\b0 \cf7 accWidth\cf3 155 | \f1\b to 156 | \f0\b0 \cf7 maxWidth\cf3 \ 157 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 158 | \cf3 159 | \f1\b else 160 | \f0\b0 161 | \f1\b if 162 | \f0\b0 \cf7 accWidth\cf3 < \cf7 minWidth\cf3 163 | \f1\b then 164 | \f0\b0 \ 165 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 166 | \cf3 167 | \f1\b set 168 | \f0\b0 \cf7 accWidth\cf3 169 | \f1\b to 170 | \f0\b0 \cf7 minWidth\cf3 \ 171 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 172 | \cf3 173 | \f1\b end 174 | \f0\b0 175 | \f1\b if 176 | \f0\b0 \ 177 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 178 | \cf3 179 | \f1\b set 180 | \f0\b0 \cf7 accViewWidth\cf3 181 | \f1\b to 182 | \f0\b0 \cf7 accWidth\cf3 \ 183 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 184 | \cf3 \ 185 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 186 | \cf3 187 | \f1\b set 188 | \f0\b0 \{\cf7 theButtons\cf3 , \cf7 minWidth\cf3 \} 189 | \f1\b to 190 | \f0\b0 \'ac\ 191 | 192 | \f2 \cf11 create buttons 193 | \f0 \cf3 \{"Cancel", "Delete", "OK"\} \'ac\ 194 | \cf12 button keys\cf3 \{"", "d", ""\} \'ac\ 195 | \cf12 default button\cf3 \cf10 3\cf3 \'ac\ 196 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 197 | \cf3 \cf12 cancel button\cf3 \cf10 1\cf3 \ 198 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 199 | \cf3 200 | \f1\b if 201 | \f0\b0 \cf7 minWidth\cf3 > \cf7 accViewWidth\cf3 202 | \f1\b then 203 | \f0\b0 204 | \f1\b set 205 | \f0\b0 \cf7 accViewWidth\cf3 206 | \f1\b to 207 | \f0\b0 \cf7 minWidth\cf3 \cf2 -- make sure buttons fit\cf3 \ 208 | 209 | \f1\b if 210 | \f0\b0 \cf7 lineCount\cf3 > \cf10 1\cf3 211 | \f1\b then 212 | \f0\b0 \ 213 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 214 | \cf3 215 | \f1\b set 216 | \f0\b0 \{\cf7 theField\cf3 , \cf7 theTop\cf3 \} 217 | \f1\b to 218 | \f0\b0 \'ac\ 219 | 220 | \f2 \cf11 create field 221 | \f0 \cf3 (\cf7 val\cf3 ) \'ac\ 222 | \cf12 placeholder text\cf3 ("Enter your text here") \'ac\ 223 | \cf12 bottom\cf3 \cf10 0\cf3 \'ac\ 224 | \cf12 field width\cf3 \cf7 accViewWidth\cf3 \'ac\ 225 | \cf12 extra height\cf3 \cf7 fieldHeight\cf3 \'ac\ 226 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 227 | \cf3 228 | \f1\b with 229 | \f0\b0 \cf12 accepts linebreak and tab\cf3 \ 230 | 231 | \f1\b else 232 | \f0\b0 \ 233 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 234 | \cf3 235 | \f1\b set 236 | \f0\b0 \{\cf7 theField\cf3 , \cf7 theTop\cf3 \} 237 | \f1\b to 238 | \f0\b0 \'ac\ 239 | 240 | \f2 \cf11 create field 241 | \f0 \cf3 (\cf7 val\cf3 ) \'ac\ 242 | \cf12 placeholder text\cf3 ("Enter your text here") \'ac\ 243 | \cf12 bottom\cf3 \cf10 0\cf3 \'ac\ 244 | \cf12 field width\cf3 \cf7 accViewWidth\cf3 \'ac\ 245 | \cf12 extra height\cf3 \cf7 fieldHeight\cf3 \'ac\ 246 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 247 | \cf3 248 | \f1\b without 249 | \f0\b0 \cf12 accepts linebreak and tab\cf3 \ 250 | 251 | \f1\b end 252 | \f0\b0 253 | \f1\b if 254 | \f0\b0 \ 255 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 256 | \cf3 257 | \f1\b set 258 | \f0\b0 \{\cf7 regLabel\cf3 , \cf7 theTop\cf3 \} 259 | \f1\b to 260 | \f0\b0 \'ac\ 261 | 262 | \f2 \cf11 create label 263 | \f0 \cf3 ("OK:" & 264 | \f2 \cf4 tab 265 | \f0 \cf3 & 266 | \f2 \cf4 tab 267 | \f0 \cf3 & "Dismiss or set variable to new value" & 268 | \f2 \cf4 return 269 | \f0 \cf3 & \'ac\ 270 | "Delete:" & 271 | \f2 \cf4 tab 272 | \f0 \cf3 & "Delete variable from Keyboard Maestro ( 273 | \f3\b \uc0\u8984 274 | \f0\b0 D)" & 275 | \f2 \cf4 return 276 | \f0 \cf3 & \'ac\ 277 | "Cancel:" & 278 | \f2 \cf4 tab 279 | \f0 \cf3 & " 280 | \f3\b \uc0\u9099 281 | \f0\b0 ") \'ac\ 282 | \cf12 bottom\cf3 \cf7 theTop\cf3 + \cf10 10\cf3 \'ac\ 283 | \cf12 max width\cf3 \cf7 accViewWidth\cf3 \'ac\ 284 | \cf12 left inset\cf3 \cf10 75\cf3 \'ac\ 285 | \cf12 control size\cf3 \cf13 regular size\cf3 \'ac\ 286 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 287 | \cf3 \cf12 aligns\cf3 \cf13 left aligned\cf3 \ 288 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 289 | \cf3 290 | \f1\b set 291 | \f0\b0 \{\cf7 theRule\cf3 , \cf7 theTop\cf3 \} 292 | \f1\b to 293 | \f0\b0 \'ac\ 294 | 295 | \f2 \cf11 create rule 296 | \f0 \cf3 (\cf7 theTop\cf3 + \cf10 12\cf3 ) \'ac\ 297 | \cf12 left inset\cf3 \cf10 75\cf3 \'ac\ 298 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 299 | \cf3 \cf12 rule width\cf3 \cf7 accViewWidth\cf3 - \cf10 60\cf3 \ 300 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 301 | \cf3 302 | \f1\b set 303 | \f0\b0 \{\cf7 boldLabel\cf3 , \cf7 theTop\cf3 \} 304 | \f1\b to 305 | \f0\b0 \'ac\ 306 | 307 | \f2 \cf11 create label 308 | \f0 \cf3 ("View or edit the Keyboard Maestro variable here.") \'ac\ 309 | \cf12 bottom\cf3 \cf7 theTop\cf3 + \cf10 10\cf3 \'ac\ 310 | \cf12 max width\cf3 \cf7 accViewWidth\cf3 \'ac\ 311 | \cf12 left inset\cf3 \cf10 75\cf3 \'ac\ 312 | \cf12 control size\cf3 \cf13 regular size\cf3 \'ac\ 313 | \cf12 aligns\cf3 \cf13 left aligned\cf3 \'ac\ 314 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 315 | \cf3 316 | \f1\b with 317 | \f0\b0 \cf12 bold type\cf3 \ 318 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 319 | \cf3 320 | \f1\b set 321 | \f0\b0 \{\cf7 imgView\cf3 , \cf7 theTop\cf3 \} 322 | \f1\b to 323 | \f0\b0 \'ac\ 324 | 325 | \f2 \cf11 create image view 326 | \f0 \cf3 (\cf7 ICON_KM\cf3 ) \'ac\ 327 | \cf12 left inset\cf3 \cf10 10\cf3 \'ac\ 328 | \cf12 bottom\cf3 \cf7 theTop\cf3 - \cf10 50\cf3 \'ac\ 329 | \cf12 view width\cf3 \cf10 50\cf3 \'ac\ 330 | \cf12 view height\cf3 \cf10 50\cf3 \'ac\ 331 | \cf12 scale image\cf3 \cf13 scale proportionally\cf3 \'ac\ 332 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 333 | \cf3 \cf12 align image\cf3 \cf13 top left aligned\cf3 \ 334 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 335 | \cf3 336 | \f1\b set 337 | \f0\b0 \{\cf7 buttonName\cf3 , \cf7 controlsResults\cf3 \} 338 | \f1\b to 339 | \f0\b0 \'ac\ 340 | 341 | \f2 \cf11 display enhanced window 342 | \f0 \cf3 ("Keyboard Maestro Variable Viewer") \'ac\ 343 | \cf12 acc view width\cf3 \cf7 accViewWidth\cf3 \'ac\ 344 | \cf12 acc view height\cf3 \cf7 theTop\cf3 + \cf10 5\cf3 \'ac\ 345 | \cf12 acc view controls\cf3 \{\cf7 theField\cf3 , \cf7 regLabel\cf3 , \cf7 theRule\cf3 , \cf7 boldLabel\cf3 , \cf7 imgView\cf3 \} \'ac\ 346 | \cf12 buttons\cf3 \cf7 theButtons\cf3 \'ac\ 347 | \cf12 active field\cf3 \cf7 theField\cf3 \'ac\ 348 | \cf12 initial position\cf3 \{\} \'ac\ 349 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 350 | \cf3 351 | \f1\b with 352 | \f0\b0 \cf12 align cancel button\cf3 \ 353 | \ 354 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 355 | \cf3 356 | \f1\b if 357 | \f0\b0 \cf7 buttonName\cf3 = "OK" 358 | \f1\b then 359 | \f0\b0 \ 360 | 361 | \f1\b set 362 | \f0\b0 \cf7 newVal\cf3 363 | \f1\b to 364 | \f0\b0 365 | \f2 \cf6 item 366 | \f0 \cf3 \cf10 1\cf3 367 | \f1\b of 368 | \f0\b0 \cf7 controlsResults\cf3 \ 369 | 370 | \f1\b if 371 | \f0\b0 \cf7 newVal\cf3 \uc0\u8800 \cf7 val\cf3 372 | \f1\b then 373 | \f0\b0 \ 374 | 375 | \f1\b tell 376 | \f0\b0 377 | \f2 \cf6 application 378 | \f0 \cf3 "Keyboard Maestro Engine" 379 | \f1\b to 380 | \f0\b0 381 | \f1\b \cf8 setvariable 382 | \f0\b0 \cf3 \cf7 _name\cf3 \cf5 to\cf3 \cf7 newVal\cf3 \ 383 | 384 | \f2 \cf11 display notification 385 | \f0 \cf3 ("'" & \cf7 _name\cf3 & "' updated to " & \cf7 newVal\cf3 )\ 386 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 387 | \cf3 388 | \f1\b end 389 | \f0\b0 390 | \f1\b if 391 | \f0\b0 \ 392 | 393 | \f1\b end 394 | \f0\b0 395 | \f1\b if 396 | \f0\b0 \ 397 | \ 398 | 399 | \f1\b end 400 | \f0\b0 401 | \f1\b \cf8 run 402 | \f0\b0 \cf3 \ 403 | \ 404 | 405 | \f1\b on 406 | \f0\b0 \cf9 getLongestLine\cf3 (\cf7 txt\cf3 )\ 407 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 408 | \cf3 409 | \f1\b set 410 | \f0\b0 \cf7 max\cf3 411 | \f1\b to 412 | \f0\b0 \cf10 0\cf3 \ 413 | 414 | \f1\b repeat 415 | \f0\b0 416 | \f1\b with 417 | \f0\b0 \cf7 p\cf3 418 | \f1\b in 419 | \f0\b0 420 | \f2 \cf6 paragraphs 421 | \f0 \cf3 422 | \f1\b of 423 | \f0\b0 \cf7 txt\cf3 \ 424 | 425 | \f1\b if 426 | \f0\b0 ( 427 | \f1\b \cf8 count 428 | \f0\b0 \cf3 429 | \f1\b of 430 | \f0\b0 431 | \f2 \cf6 characters 432 | \f0 \cf3 433 | \f1\b of 434 | \f0\b0 435 | \f2 \cf4 contents 436 | \f0 \cf3 437 | \f1\b of 438 | \f0\b0 \cf7 p\cf3 ) > \cf7 max\cf3 439 | \f1\b then 440 | \f0\b0 \ 441 | 442 | \f1\b set 443 | \f0\b0 \cf7 max\cf3 444 | \f1\b to 445 | \f0\b0 446 | \f1\b \cf8 count 447 | \f0\b0 \cf3 448 | \f1\b of 449 | \f0\b0 450 | \f2 \cf6 characters 451 | \f0 \cf3 452 | \f1\b of 453 | \f0\b0 454 | \f2 \cf4 contents 455 | \f0 \cf3 456 | \f1\b of 457 | \f0\b0 \cf7 p\cf3 \ 458 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 459 | \cf3 460 | \f1\b end 461 | \f0\b0 462 | \f1\b if 463 | \f0\b0 \ 464 | 465 | \f1\b end 466 | \f0\b0 467 | \f1\b repeat 468 | \f0\b0 \ 469 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 470 | \cf3 471 | \f1\b return 472 | \f0\b0 \cf7 max\cf3 \ 473 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 474 | 475 | \f1\b \cf3 end 476 | \f0\b0 \cf9 getLongestLine\cf3 \ 477 | \ 478 | 479 | \f1\b on 480 | \f0\b0 \cf9 screensize\cf3 ()\ 481 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 482 | \cf3 483 | \f1\b set 484 | \f0\b0 \cf7 theScreen\cf3 485 | \f1\b to 486 | \f0\b0 \cf14 current application's\cf3 \cf7 NSScreen's\cf3 \cf9 mainScreen\cf3 ()\ 487 | 488 | \f1\b set 489 | \f0\b0 \cf7 theFrame\cf3 490 | \f1\b to 491 | \f0\b0 \cf7 theScreen's\cf3 \cf9 visibleFrame\cf3 ()\ 492 | 493 | \f1\b set 494 | \f0\b0 \cf7 width\cf3 495 | \f1\b to 496 | \f0\b0 497 | \f2 \cf6 item 498 | \f0 \cf3 \cf10 1\cf3 499 | \f1\b of 500 | \f0\b0 501 | \f2 \cf6 item 502 | \f0 \cf3 \cf10 2\cf3 503 | \f1\b of 504 | \f0\b0 \cf7 theFrame\cf3 \ 505 | 506 | \f1\b set 507 | \f0\b0 \cf7 height\cf3 508 | \f1\b to 509 | \f0\b0 510 | \f2 \cf6 item 511 | \f0 \cf3 \cf10 2\cf3 512 | \f1\b of 513 | \f0\b0 514 | \f2 \cf6 item 515 | \f0 \cf3 \cf10 2\cf3 516 | \f1\b of 517 | \f0\b0 \cf7 theFrame\cf3 \ 518 | 519 | \f1\b return 520 | \f0\b0 \{\cf7 width\cf3 , \cf7 height\cf3 \}\ 521 | \pard\pardeftab480\slleading40\pardirnatural\partightenfactor0 522 | 523 | \f1\b \cf3 end 524 | \f0\b0 \cf9 screensize\cf3 \ 525 | } -------------------------------------------------------------------------------- /var_viewer.scptd/Contents/Resources/description.rtfd/TXT.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\ansicpg1252\cocoartf2513 2 | \cocoatextscaling0\cocoaplatform0{\fonttbl} 3 | {\colortbl;\red255\green255\blue255;} 4 | {\*\expandedcolortbl;;} 5 | } -------------------------------------------------------------------------------- /var_viewer.scptd/Contents/Script Debugger.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SD4Version 6 | 7.0.12 7 | SD5ConversionOffered 8 | 9 | SDAddUsedLibrariesOnExport 10 | 0 11 | SDAutoIncBuildNumber 12 | 13 | SDBreakOnExceptions 14 | 15 | SDBreakpointsEnabled 16 | 17 | SDBundleVersion 18 | 2.0 19 | SDCocoaTermsInProperties 20 | 21 | SDCodesignOnlyOnExport 22 | 23 | SDFolds 24 | YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVj 25 | dHMSAAGGoF8QD05TS2V5ZWRBcmNoaXZlctEICVRyb290gAGjCwwRVSRudWxs0g0O 26 | DxBaTlMub2JqZWN0c1YkY2xhc3OggALSEhMUFVokY2xhc3NuYW1lWCRjbGFzc2Vz 27 | Xk5TTXV0YWJsZUFycmF5oxQWF1dOU0FycmF5WE5TT2JqZWN0CBEaJCkyN0lMUVNX 28 | XWJtdHV3fIeQn6OrAAAAAAAAAQEAAAAAAAAAGAAAAAAAAAAAAAAAAAAAALQ= 29 | SDHideCodeFolding 30 | 31 | SDPersistentGlobals 32 | 33 | SDWindowState 34 | 35 | eventLogWindow 36 | 37 | resultWindow 38 | 39 | traceDelay 40 | 0.80000001192092896 41 | viewControllers 42 | 43 | A3630FCB-B1E9-405D-BF78-9101D438F6C6-526-000003905843201B 44 | 45 | debuggerViewState 46 | 47 | expressionsState 48 | 49 | dividerPos 50 | 150 51 | scope2.changedItems 52 | 53 | scope2.emptyElements 54 | 55 | scope2.filledElements 56 | 57 | scope2.props 58 | 59 | scope2.unchangedItems 60 | 61 | showScopeBar 62 | 63 | 64 | heights4 65 | 66 | positions 67 | 68 | 3 69 | 70 | 312 71 | 396 72 | 155 73 | 74 | 5 75 | 76 | 280 77 | 54 78 | 325 79 | 81 80 | 88 81 | 82 | 83 | 84 | resultState 85 | 86 | explorerState 87 | 88 | dividerPos 89 | 150 90 | scope2.changedItems 91 | 92 | scope2.emptyElements 93 | 94 | scope2.filledElements 95 | 96 | scope2.props 97 | 98 | scope2.unchangedItems 99 | 100 | showScopeBar 101 | 102 | 103 | modes 104 | 105 | mode.best 106 | 107 | positions2 108 | 109 | positions 110 | 111 | 112 | prettyPrint 113 | 114 | sourceState 115 | 116 | text 117 | 118 | LNSSelection 119 | 120 | 121 | length 122 | 0 123 | location 124 | 16 125 | 126 | 127 | LNSTextViewShowInvisibles 128 | 129 | LNSTextViewShowSpaces 130 | 131 | LNSTextViewShowTabStops 132 | 133 | LNSTextViewWrapLines 134 | 135 | LNSVisibleRect 136 | {{0, 0}, {424, 250.5}} 137 | 138 | 139 | textState 140 | 141 | text 142 | 143 | LNSSelection 144 | 145 | 146 | length 147 | 0 148 | location 149 | 85 150 | 151 | 152 | LNSTextViewShowInvisibles 153 | 154 | LNSTextViewShowSpaces 155 | 156 | LNSTextViewShowTabStops 157 | 158 | LNSTextViewWrapLines 159 | 160 | LNSVisibleRect 161 | {{0, 0}, {424, 256.5}} 162 | 163 | 164 | 165 | stackVisible 166 | 167 | variablesState 168 | 169 | dividerPos 170 | 150 171 | scope2.changedItems 172 | 173 | scope2.emptyElements 174 | 175 | scope2.filledElements 176 | 177 | scope2.props 178 | 179 | scope2.unchangedItems 180 | 181 | showScopeBar 182 | 183 | 184 | 185 | eventLogHeight 186 | 174.5 187 | eventLogViewState 188 | 189 | format 190 | EventLogScopeBar_Source 191 | resultState 192 | 193 | modes 194 | 195 | mode.best 196 | 197 | positions2 198 | 199 | positions 200 | 201 | 202 | prettyPrint 203 | 204 | 205 | resultViewerWidth 206 | 410 207 | scopeVisible 208 | 209 | showResultViewer 210 | 211 | 212 | inspectorsViewState 213 | 214 | applications 215 | 216 | dividerPos2 217 | 144 218 | searchFlags 219 | 167784446 220 | 221 | clippings 222 | 223 | heights3 224 | 225 | positions 226 | 227 | 3 228 | 229 | 85 230 | 147 231 | 669 232 | 233 | 234 | 235 | tellContext 236 | 237 | explorer 238 | 239 | dividerPos 240 | 291 241 | scope2.changedItems 242 | 243 | scope2.emptyElements 244 | 245 | scope2.filledElements 246 | 247 | scope2.props 248 | 249 | scope2.unchangedItems 250 | 251 | showScopeBar 252 | 253 | 254 | 255 | 256 | mainSplitPosition 257 | 440 258 | modeIdentifier 259 | tab.debug 260 | resourcesViewState 261 | 262 | heights2 263 | 264 | positions 265 | 266 | 2 267 | 268 | 252 269 | 613 270 | 271 | 272 | 273 | resources 274 | 275 | expanded 276 | 277 | /Users/kevinfunderburg/Dropbox/Library/Application Support/Alfred/Alfred.alfredpreferences/workflows/user.workflow.A9B9BCB0-3BB9-4B1C-AC17-14B74DA87BE0/var_viewer.scptd/Contents/Resources/Script Libraries 278 | 279 | selection 280 | 281 | 282 | 283 | scriptViewState 284 | 285 | editorState 286 | 287 | LNSSelection 288 | 289 | 290 | length 291 | 0 292 | location 293 | 907 294 | 295 | 296 | LNSTextViewShowInvisibles 297 | 298 | LNSTextViewShowSpaces 299 | 300 | LNSTextViewShowTabStops 301 | 302 | LNSTextViewWrapLines 303 | 304 | LNSVisibleRect 305 | {{0, 515}, {786, 694.5}} 306 | ShowLineNumbers 307 | 308 | showCodeCoverage 309 | 310 | 311 | instanceID 312 | 4D62FCD7-592D-4ECC-BF06-C698BEA4D19A-2196-00000F39DA4B05C8 313 | resultBarVisible 314 | 315 | showProgressBar 316 | 317 | 318 | showEventLog 319 | 320 | tabHeights 321 | 322 | tab.bps 323 | 433 324 | tab.debug 325 | 439 326 | tab.rsrcs 327 | 433 328 | 329 | tabsHidden 330 | 331 | windowControllerID 332 | A3630FCB-B1E9-405D-BF78-9101D438F6C6-526-000003905843201B 333 | windowFrame 334 | 408 62 1272 965 0 0 1680 1027 335 | 336 | 337 | windowControllers 338 | 339 | 340 | controllerClass 341 | ScriptWindowController 342 | controllerID 343 | A3630FCB-B1E9-405D-BF78-9101D438F6C6-526-000003905843201B 344 | controllerState 345 | 346 | miniaturized 347 | 348 | windowFrame 349 | 408 62 1272 965 0 0 1680 1027 350 | 351 | 352 | 353 | 354 | 355 | 356 | -------------------------------------------------------------------------------- /workflow/.alfredversionchecked: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevin-funderburg/alfred-search-keyboard-maestro-vars/a996043d8ed6076b10f245a5747d0b921c11dae7/workflow/.alfredversionchecked -------------------------------------------------------------------------------- /workflow/Notify.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevin-funderburg/alfred-search-keyboard-maestro-vars/a996043d8ed6076b10f245a5747d0b921c11dae7/workflow/Notify.tgz -------------------------------------------------------------------------------- /workflow/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 Dean Jackson 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2014-02-15 9 | # 10 | 11 | """A helper library for `Alfred `_ workflows.""" 12 | 13 | import os 14 | 15 | # Workflow objects 16 | from .workflow import Workflow, manager 17 | from .workflow3 import Variables, Workflow3 18 | 19 | # Exceptions 20 | from .workflow import PasswordNotFound, KeychainError 21 | 22 | # Icons 23 | from .workflow import ( 24 | ICON_ACCOUNT, 25 | ICON_BURN, 26 | ICON_CLOCK, 27 | ICON_COLOR, 28 | ICON_COLOUR, 29 | ICON_EJECT, 30 | ICON_ERROR, 31 | ICON_FAVORITE, 32 | ICON_FAVOURITE, 33 | ICON_GROUP, 34 | ICON_HELP, 35 | ICON_HOME, 36 | ICON_INFO, 37 | ICON_NETWORK, 38 | ICON_NOTE, 39 | ICON_SETTINGS, 40 | ICON_SWIRL, 41 | ICON_SWITCH, 42 | ICON_SYNC, 43 | ICON_TRASH, 44 | ICON_USER, 45 | ICON_WARNING, 46 | ICON_WEB, 47 | ) 48 | 49 | # Filter matching rules 50 | from .workflow import ( 51 | MATCH_ALL, 52 | MATCH_ALLCHARS, 53 | MATCH_ATOM, 54 | MATCH_CAPITALS, 55 | MATCH_INITIALS, 56 | MATCH_INITIALS_CONTAIN, 57 | MATCH_INITIALS_STARTSWITH, 58 | MATCH_STARTSWITH, 59 | MATCH_SUBSTRING, 60 | ) 61 | 62 | 63 | __title__ = 'Alfred-Workflow' 64 | __version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read() 65 | __author__ = 'Dean Jackson' 66 | __licence__ = 'MIT' 67 | __copyright__ = 'Copyright 2014-2019 Dean Jackson' 68 | 69 | __all__ = [ 70 | 'Variables', 71 | 'Workflow', 72 | 'Workflow3', 73 | 'manager', 74 | 'PasswordNotFound', 75 | 'KeychainError', 76 | 'ICON_ACCOUNT', 77 | 'ICON_BURN', 78 | 'ICON_CLOCK', 79 | 'ICON_COLOR', 80 | 'ICON_COLOUR', 81 | 'ICON_EJECT', 82 | 'ICON_ERROR', 83 | 'ICON_FAVORITE', 84 | 'ICON_FAVOURITE', 85 | 'ICON_GROUP', 86 | 'ICON_HELP', 87 | 'ICON_HOME', 88 | 'ICON_INFO', 89 | 'ICON_NETWORK', 90 | 'ICON_NOTE', 91 | 'ICON_SETTINGS', 92 | 'ICON_SWIRL', 93 | 'ICON_SWITCH', 94 | 'ICON_SYNC', 95 | 'ICON_TRASH', 96 | 'ICON_USER', 97 | 'ICON_WARNING', 98 | 'ICON_WEB', 99 | 'MATCH_ALL', 100 | 'MATCH_ALLCHARS', 101 | 'MATCH_ATOM', 102 | 'MATCH_CAPITALS', 103 | 'MATCH_INITIALS', 104 | 'MATCH_INITIALS_CONTAIN', 105 | 'MATCH_INITIALS_STARTSWITH', 106 | 'MATCH_STARTSWITH', 107 | 'MATCH_SUBSTRING', 108 | ] 109 | -------------------------------------------------------------------------------- /workflow/__init__.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevin-funderburg/alfred-search-keyboard-maestro-vars/a996043d8ed6076b10f245a5747d0b921c11dae7/workflow/__init__.pyc -------------------------------------------------------------------------------- /workflow/background.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 deanishe@deanishe.net 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2014-04-06 9 | # 10 | 11 | """This module provides an API to run commands in background processes. 12 | 13 | Combine with the :ref:`caching API ` to work from cached data 14 | while you fetch fresh data in the background. 15 | 16 | See :ref:`the User Manual ` for more information 17 | and examples. 18 | """ 19 | 20 | from __future__ import print_function, unicode_literals 21 | 22 | import signal 23 | import sys 24 | import os 25 | import subprocess 26 | import pickle 27 | 28 | from workflow import Workflow 29 | 30 | __all__ = ['is_running', 'run_in_background'] 31 | 32 | _wf = None 33 | 34 | 35 | def wf(): 36 | global _wf 37 | if _wf is None: 38 | _wf = Workflow() 39 | return _wf 40 | 41 | 42 | def _log(): 43 | return wf().logger 44 | 45 | 46 | def _arg_cache(name): 47 | """Return path to pickle cache file for arguments. 48 | 49 | :param name: name of task 50 | :type name: ``unicode`` 51 | :returns: Path to cache file 52 | :rtype: ``unicode`` filepath 53 | 54 | """ 55 | return wf().cachefile(name + '.argcache') 56 | 57 | 58 | def _pid_file(name): 59 | """Return path to PID file for ``name``. 60 | 61 | :param name: name of task 62 | :type name: ``unicode`` 63 | :returns: Path to PID file for task 64 | :rtype: ``unicode`` filepath 65 | 66 | """ 67 | return wf().cachefile(name + '.pid') 68 | 69 | 70 | def _process_exists(pid): 71 | """Check if a process with PID ``pid`` exists. 72 | 73 | :param pid: PID to check 74 | :type pid: ``int`` 75 | :returns: ``True`` if process exists, else ``False`` 76 | :rtype: ``Boolean`` 77 | 78 | """ 79 | try: 80 | os.kill(pid, 0) 81 | except OSError: # not running 82 | return False 83 | return True 84 | 85 | 86 | def _job_pid(name): 87 | """Get PID of job or `None` if job does not exist. 88 | 89 | Args: 90 | name (str): Name of job. 91 | 92 | Returns: 93 | int: PID of job process (or `None` if job doesn't exist). 94 | """ 95 | pidfile = _pid_file(name) 96 | if not os.path.exists(pidfile): 97 | return 98 | 99 | with open(pidfile, 'rb') as fp: 100 | pid = int(fp.read()) 101 | 102 | if _process_exists(pid): 103 | return pid 104 | 105 | try: 106 | os.unlink(pidfile) 107 | except Exception: # pragma: no cover 108 | pass 109 | 110 | 111 | def is_running(name): 112 | """Test whether task ``name`` is currently running. 113 | 114 | :param name: name of task 115 | :type name: unicode 116 | :returns: ``True`` if task with name ``name`` is running, else ``False`` 117 | :rtype: bool 118 | 119 | """ 120 | if _job_pid(name) is not None: 121 | return True 122 | 123 | return False 124 | 125 | 126 | def _background(pidfile, stdin='/dev/null', stdout='/dev/null', 127 | stderr='/dev/null'): # pragma: no cover 128 | """Fork the current process into a background daemon. 129 | 130 | :param pidfile: file to write PID of daemon process to. 131 | :type pidfile: filepath 132 | :param stdin: where to read input 133 | :type stdin: filepath 134 | :param stdout: where to write stdout output 135 | :type stdout: filepath 136 | :param stderr: where to write stderr output 137 | :type stderr: filepath 138 | 139 | """ 140 | def _fork_and_exit_parent(errmsg, wait=False, write=False): 141 | try: 142 | pid = os.fork() 143 | if pid > 0: 144 | if write: # write PID of child process to `pidfile` 145 | tmp = pidfile + '.tmp' 146 | with open(tmp, 'wb') as fp: 147 | fp.write(str(pid)) 148 | os.rename(tmp, pidfile) 149 | if wait: # wait for child process to exit 150 | os.waitpid(pid, 0) 151 | os._exit(0) 152 | except OSError as err: 153 | _log().critical('%s: (%d) %s', errmsg, err.errno, err.strerror) 154 | raise err 155 | 156 | # Do first fork and wait for second fork to finish. 157 | _fork_and_exit_parent('fork #1 failed', wait=True) 158 | 159 | # Decouple from parent environment. 160 | os.chdir(wf().workflowdir) 161 | os.setsid() 162 | 163 | # Do second fork and write PID to pidfile. 164 | _fork_and_exit_parent('fork #2 failed', write=True) 165 | 166 | # Now I am a daemon! 167 | # Redirect standard file descriptors. 168 | si = open(stdin, 'r', 0) 169 | so = open(stdout, 'a+', 0) 170 | se = open(stderr, 'a+', 0) 171 | if hasattr(sys.stdin, 'fileno'): 172 | os.dup2(si.fileno(), sys.stdin.fileno()) 173 | if hasattr(sys.stdout, 'fileno'): 174 | os.dup2(so.fileno(), sys.stdout.fileno()) 175 | if hasattr(sys.stderr, 'fileno'): 176 | os.dup2(se.fileno(), sys.stderr.fileno()) 177 | 178 | 179 | def kill(name, sig=signal.SIGTERM): 180 | """Send a signal to job ``name`` via :func:`os.kill`. 181 | 182 | .. versionadded:: 1.29 183 | 184 | Args: 185 | name (str): Name of the job 186 | sig (int, optional): Signal to send (default: SIGTERM) 187 | 188 | Returns: 189 | bool: `False` if job isn't running, `True` if signal was sent. 190 | """ 191 | pid = _job_pid(name) 192 | if pid is None: 193 | return False 194 | 195 | os.kill(pid, sig) 196 | return True 197 | 198 | 199 | def run_in_background(name, args, **kwargs): 200 | r"""Cache arguments then call this script again via :func:`subprocess.call`. 201 | 202 | :param name: name of job 203 | :type name: unicode 204 | :param args: arguments passed as first argument to :func:`subprocess.call` 205 | :param \**kwargs: keyword arguments to :func:`subprocess.call` 206 | :returns: exit code of sub-process 207 | :rtype: int 208 | 209 | When you call this function, it caches its arguments and then calls 210 | ``background.py`` in a subprocess. The Python subprocess will load the 211 | cached arguments, fork into the background, and then run the command you 212 | specified. 213 | 214 | This function will return as soon as the ``background.py`` subprocess has 215 | forked, returning the exit code of *that* process (i.e. not of the command 216 | you're trying to run). 217 | 218 | If that process fails, an error will be written to the log file. 219 | 220 | If a process is already running under the same name, this function will 221 | return immediately and will not run the specified command. 222 | 223 | """ 224 | if is_running(name): 225 | _log().info('[%s] job already running', name) 226 | return 227 | 228 | argcache = _arg_cache(name) 229 | 230 | # Cache arguments 231 | with open(argcache, 'wb') as fp: 232 | pickle.dump({'args': args, 'kwargs': kwargs}, fp) 233 | _log().debug('[%s] command cached: %s', name, argcache) 234 | 235 | # Call this script 236 | cmd = ['/usr/bin/python', __file__, name] 237 | _log().debug('[%s] passing job to background runner: %r', name, cmd) 238 | retcode = subprocess.call(cmd) 239 | 240 | if retcode: # pragma: no cover 241 | _log().error('[%s] background runner failed with %d', name, retcode) 242 | else: 243 | _log().debug('[%s] background job started', name) 244 | 245 | return retcode 246 | 247 | 248 | def main(wf): # pragma: no cover 249 | """Run command in a background process. 250 | 251 | Load cached arguments, fork into background, then call 252 | :meth:`subprocess.call` with cached arguments. 253 | 254 | """ 255 | log = wf.logger 256 | name = wf.args[0] 257 | argcache = _arg_cache(name) 258 | if not os.path.exists(argcache): 259 | msg = '[{0}] command cache not found: {1}'.format(name, argcache) 260 | log.critical(msg) 261 | raise IOError(msg) 262 | 263 | # Fork to background and run command 264 | pidfile = _pid_file(name) 265 | _background(pidfile) 266 | 267 | # Load cached arguments 268 | with open(argcache, 'rb') as fp: 269 | data = pickle.load(fp) 270 | 271 | # Cached arguments 272 | args = data['args'] 273 | kwargs = data['kwargs'] 274 | 275 | # Delete argument cache file 276 | os.unlink(argcache) 277 | 278 | try: 279 | # Run the command 280 | log.debug('[%s] running command: %r', name, args) 281 | 282 | retcode = subprocess.call(args, **kwargs) 283 | 284 | if retcode: 285 | log.error('[%s] command failed with status %d', name, retcode) 286 | finally: 287 | os.unlink(pidfile) 288 | 289 | log.debug('[%s] job complete', name) 290 | 291 | 292 | if __name__ == '__main__': # pragma: no cover 293 | wf().run(main) 294 | -------------------------------------------------------------------------------- /workflow/background.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevin-funderburg/alfred-search-keyboard-maestro-vars/a996043d8ed6076b10f245a5747d0b921c11dae7/workflow/background.pyc -------------------------------------------------------------------------------- /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 | from __future__ import print_function, unicode_literals 27 | 28 | import os 29 | import plistlib 30 | import shutil 31 | import subprocess 32 | import sys 33 | import tarfile 34 | import tempfile 35 | import uuid 36 | 37 | import workflow 38 | 39 | 40 | _wf = None 41 | _log = None 42 | 43 | 44 | #: Available system sounds from System Preferences > Sound > Sound Effects 45 | SOUNDS = ( 46 | 'Basso', 47 | 'Blow', 48 | 'Bottle', 49 | 'Frog', 50 | 'Funk', 51 | 'Glass', 52 | 'Hero', 53 | 'Morse', 54 | 'Ping', 55 | 'Pop', 56 | 'Purr', 57 | 'Sosumi', 58 | 'Submarine', 59 | 'Tink', 60 | ) 61 | 62 | 63 | def wf(): 64 | """Return Workflow object for this module. 65 | 66 | Returns: 67 | workflow.Workflow: Workflow object for current workflow. 68 | """ 69 | global _wf 70 | if _wf is None: 71 | _wf = workflow.Workflow() 72 | return _wf 73 | 74 | 75 | def log(): 76 | """Return logger for this module. 77 | 78 | Returns: 79 | logging.Logger: Logger for this module. 80 | """ 81 | global _log 82 | if _log is None: 83 | _log = wf().logger 84 | return _log 85 | 86 | 87 | def notifier_program(): 88 | """Return path to notifier applet executable. 89 | 90 | Returns: 91 | unicode: Path to Notify.app ``applet`` executable. 92 | """ 93 | return wf().datafile('Notify.app/Contents/MacOS/applet') 94 | 95 | 96 | def notifier_icon_path(): 97 | """Return path to icon file in installed Notify.app. 98 | 99 | Returns: 100 | unicode: Path to ``applet.icns`` within the app bundle. 101 | """ 102 | return wf().datafile('Notify.app/Contents/Resources/applet.icns') 103 | 104 | 105 | def install_notifier(): 106 | """Extract ``Notify.app`` from the workflow to data directory. 107 | 108 | Changes the bundle ID of the installed app and gives it the 109 | workflow's icon. 110 | """ 111 | archive = os.path.join(os.path.dirname(__file__), 'Notify.tgz') 112 | destdir = wf().datadir 113 | app_path = os.path.join(destdir, 'Notify.app') 114 | n = notifier_program() 115 | log().debug('installing Notify.app to %r ...', destdir) 116 | # z = zipfile.ZipFile(archive, 'r') 117 | # z.extractall(destdir) 118 | tgz = tarfile.open(archive, 'r:gz') 119 | tgz.extractall(destdir) 120 | assert os.path.exists(n), \ 121 | 'Notify.app could not be installed in %s' % destdir 122 | 123 | # Replace applet icon 124 | icon = notifier_icon_path() 125 | workflow_icon = wf().workflowfile('icon.png') 126 | if os.path.exists(icon): 127 | os.unlink(icon) 128 | 129 | png_to_icns(workflow_icon, icon) 130 | 131 | # Set file icon 132 | # PyObjC isn't available for 2.6, so this is 2.7 only. Actually, 133 | # none of this code will "work" on pre-10.8 systems. Let it run 134 | # until I figure out a better way of excluding this module 135 | # from coverage in py2.6. 136 | if sys.version_info >= (2, 7): # pragma: no cover 137 | from AppKit import NSWorkspace, NSImage 138 | 139 | ws = NSWorkspace.sharedWorkspace() 140 | img = NSImage.alloc().init() 141 | img.initWithContentsOfFile_(icon) 142 | ws.setIcon_forFile_options_(img, app_path, 0) 143 | 144 | # Change bundle ID of installed app 145 | ip_path = os.path.join(app_path, 'Contents/Info.plist') 146 | bundle_id = '{0}.{1}'.format(wf().bundleid, uuid.uuid4().hex) 147 | data = plistlib.readPlist(ip_path) 148 | log().debug('changing bundle ID to %r', bundle_id) 149 | data['CFBundleIdentifier'] = bundle_id 150 | plistlib.writePlist(data, ip_path) 151 | 152 | 153 | def validate_sound(sound): 154 | """Coerce ``sound`` to valid sound name. 155 | 156 | Returns ``None`` for invalid sounds. Sound names can be found 157 | in ``System Preferences > Sound > Sound Effects``. 158 | 159 | Args: 160 | sound (str): Name of system sound. 161 | 162 | Returns: 163 | str: Proper name of sound or ``None``. 164 | """ 165 | if not sound: 166 | return None 167 | 168 | # Case-insensitive comparison of `sound` 169 | if sound.lower() in [s.lower() for s in SOUNDS]: 170 | # Title-case is correct for all system sounds as of macOS 10.11 171 | return sound.title() 172 | return None 173 | 174 | 175 | def notify(title='', text='', sound=None): 176 | """Post notification via Notify.app helper. 177 | 178 | Args: 179 | title (str, optional): Notification title. 180 | text (str, optional): Notification body text. 181 | sound (str, optional): Name of sound to play. 182 | 183 | Raises: 184 | ValueError: Raised if both ``title`` and ``text`` are empty. 185 | 186 | Returns: 187 | bool: ``True`` if notification was posted, else ``False``. 188 | """ 189 | if title == text == '': 190 | raise ValueError('Empty notification') 191 | 192 | sound = validate_sound(sound) or '' 193 | 194 | n = notifier_program() 195 | 196 | if not os.path.exists(n): 197 | install_notifier() 198 | 199 | env = os.environ.copy() 200 | enc = 'utf-8' 201 | env['NOTIFY_TITLE'] = title.encode(enc) 202 | env['NOTIFY_MESSAGE'] = text.encode(enc) 203 | env['NOTIFY_SOUND'] = sound.encode(enc) 204 | cmd = [n] 205 | retcode = subprocess.call(cmd, env=env) 206 | if retcode == 0: 207 | return True 208 | 209 | log().error('Notify.app exited with status {0}.'.format(retcode)) 210 | return False 211 | 212 | 213 | def convert_image(inpath, outpath, size): 214 | """Convert an image file using ``sips``. 215 | 216 | Args: 217 | inpath (str): Path of source file. 218 | outpath (str): Path to destination file. 219 | size (int): Width and height of destination image in pixels. 220 | 221 | Raises: 222 | RuntimeError: Raised if ``sips`` exits with non-zero status. 223 | """ 224 | cmd = [ 225 | b'sips', 226 | b'-z', str(size), str(size), 227 | inpath, 228 | b'--out', outpath] 229 | # log().debug(cmd) 230 | with open(os.devnull, 'w') as pipe: 231 | retcode = subprocess.call(cmd, stdout=pipe, stderr=subprocess.STDOUT) 232 | 233 | if retcode != 0: 234 | raise RuntimeError('sips exited with %d' % retcode) 235 | 236 | 237 | def png_to_icns(png_path, icns_path): 238 | """Convert PNG file to ICNS using ``iconutil``. 239 | 240 | Create an iconset from the source PNG file. Generate PNG files 241 | in each size required by macOS, then call ``iconutil`` to turn 242 | them into a single ICNS file. 243 | 244 | Args: 245 | png_path (str): Path to source PNG file. 246 | icns_path (str): Path to destination ICNS file. 247 | 248 | Raises: 249 | RuntimeError: Raised if ``iconutil`` or ``sips`` fail. 250 | """ 251 | tempdir = tempfile.mkdtemp(prefix='aw-', dir=wf().datadir) 252 | 253 | try: 254 | iconset = os.path.join(tempdir, 'Icon.iconset') 255 | 256 | assert not os.path.exists(iconset), \ 257 | 'iconset already exists: ' + iconset 258 | os.makedirs(iconset) 259 | 260 | # Copy source icon to icon set and generate all the other 261 | # sizes needed 262 | configs = [] 263 | for i in (16, 32, 128, 256, 512): 264 | configs.append(('icon_{0}x{0}.png'.format(i), i)) 265 | configs.append((('icon_{0}x{0}@2x.png'.format(i), i * 2))) 266 | 267 | shutil.copy(png_path, os.path.join(iconset, 'icon_256x256.png')) 268 | shutil.copy(png_path, os.path.join(iconset, 'icon_128x128@2x.png')) 269 | 270 | for name, size in configs: 271 | outpath = os.path.join(iconset, name) 272 | if os.path.exists(outpath): 273 | continue 274 | convert_image(png_path, outpath, size) 275 | 276 | cmd = [ 277 | b'iconutil', 278 | b'-c', b'icns', 279 | b'-o', icns_path, 280 | iconset] 281 | 282 | retcode = subprocess.call(cmd) 283 | if retcode != 0: 284 | raise RuntimeError('iconset exited with %d' % retcode) 285 | 286 | assert os.path.exists(icns_path), \ 287 | 'generated ICNS file not found: ' + repr(icns_path) 288 | finally: 289 | try: 290 | shutil.rmtree(tempdir) 291 | except OSError: # pragma: no cover 292 | pass 293 | 294 | 295 | if __name__ == '__main__': # pragma: nocover 296 | # Simple command-line script to test module with 297 | # This won't work on 2.6, as `argparse` isn't available 298 | # by default. 299 | import argparse 300 | 301 | from unicodedata import normalize 302 | 303 | def ustr(s): 304 | """Coerce `s` to normalised Unicode.""" 305 | return normalize('NFD', s.decode('utf-8')) 306 | 307 | p = argparse.ArgumentParser() 308 | p.add_argument('-p', '--png', help="PNG image to convert to ICNS.") 309 | p.add_argument('-l', '--list-sounds', help="Show available sounds.", 310 | action='store_true') 311 | p.add_argument('-t', '--title', 312 | help="Notification title.", type=ustr, 313 | default='') 314 | p.add_argument('-s', '--sound', type=ustr, 315 | help="Optional notification sound.", default='') 316 | p.add_argument('text', type=ustr, 317 | help="Notification body text.", default='', nargs='?') 318 | o = p.parse_args() 319 | 320 | # List available sounds 321 | if o.list_sounds: 322 | for sound in SOUNDS: 323 | print(sound) 324 | sys.exit(0) 325 | 326 | # Convert PNG to ICNS 327 | if o.png: 328 | icns = os.path.join( 329 | os.path.dirname(o.png), 330 | os.path.splitext(os.path.basename(o.png))[0] + '.icns') 331 | 332 | print('converting {0!r} to {1!r} ...'.format(o.png, icns), 333 | file=sys.stderr) 334 | 335 | assert not os.path.exists(icns), \ 336 | 'destination file already exists: ' + icns 337 | 338 | png_to_icns(o.png, icns) 339 | sys.exit(0) 340 | 341 | # Post notification 342 | if o.title == o.text == '': 343 | print('ERROR: empty notification.', file=sys.stderr) 344 | sys.exit(1) 345 | else: 346 | notify(o.title, o.text, o.sound) 347 | -------------------------------------------------------------------------------- /workflow/update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 Fabio Niephaus , 5 | # Dean Jackson 6 | # 7 | # MIT Licence. See http://opensource.org/licenses/MIT 8 | # 9 | # Created on 2014-08-16 10 | # 11 | 12 | """Self-updating from GitHub. 13 | 14 | .. versionadded:: 1.9 15 | 16 | .. note:: 17 | 18 | This module is not intended to be used directly. Automatic updates 19 | are controlled by the ``update_settings`` :class:`dict` passed to 20 | :class:`~workflow.workflow.Workflow` objects. 21 | 22 | """ 23 | 24 | from __future__ import print_function, unicode_literals 25 | 26 | from collections import defaultdict 27 | from functools import total_ordering 28 | import json 29 | import os 30 | import tempfile 31 | import re 32 | import subprocess 33 | 34 | import workflow 35 | import web 36 | 37 | # __all__ = [] 38 | 39 | 40 | RELEASES_BASE = 'https://api.github.com/repos/{}/releases' 41 | match_workflow = re.compile(r'\.alfred(\d+)?workflow$').search 42 | 43 | _wf = None 44 | 45 | 46 | def wf(): 47 | """Lazy `Workflow` object.""" 48 | global _wf 49 | if _wf is None: 50 | _wf = workflow.Workflow() 51 | return _wf 52 | 53 | 54 | @total_ordering 55 | class Download(object): 56 | """A workflow file that is available for download. 57 | 58 | .. versionadded: 1.37 59 | 60 | Attributes: 61 | url (str): URL of workflow file. 62 | filename (str): Filename of workflow file. 63 | version (Version): Semantic version of workflow. 64 | prerelease (bool): Whether version is a pre-release. 65 | alfred_version (Version): Minimum compatible version 66 | of Alfred. 67 | 68 | """ 69 | 70 | @classmethod 71 | def from_dict(cls, d): 72 | """Create a `Download` from a `dict`.""" 73 | return cls(url=d['url'], filename=d['filename'], 74 | version=Version(d['version']), 75 | prerelease=d['prerelease']) 76 | 77 | @classmethod 78 | def from_releases(cls, js): 79 | """Extract downloads from GitHub releases. 80 | 81 | Searches releases with semantic tags for assets with 82 | file extension .alfredworkflow or .alfredXworkflow where 83 | X is a number. 84 | 85 | Files are returned sorted by latest version first. Any 86 | releases containing multiple files with the same (workflow) 87 | extension are rejected as ambiguous. 88 | 89 | Args: 90 | js (str): JSON response from GitHub's releases endpoint. 91 | 92 | Returns: 93 | list: Sequence of `Download`. 94 | """ 95 | releases = json.loads(js) 96 | downloads = [] 97 | for release in releases: 98 | tag = release['tag_name'] 99 | dupes = defaultdict(int) 100 | try: 101 | version = Version(tag) 102 | except ValueError as err: 103 | wf().logger.debug('ignored release: bad version "%s": %s', 104 | tag, err) 105 | continue 106 | 107 | dls = [] 108 | for asset in release.get('assets', []): 109 | url = asset.get('browser_download_url') 110 | filename = os.path.basename(url) 111 | m = match_workflow(filename) 112 | if not m: 113 | wf().logger.debug('unwanted file: %s', filename) 114 | continue 115 | 116 | ext = m.group(0) 117 | dupes[ext] = dupes[ext] + 1 118 | dls.append(Download(url, filename, version, 119 | release['prerelease'])) 120 | 121 | valid = True 122 | for ext, n in dupes.items(): 123 | if n > 1: 124 | wf().logger.debug('ignored release "%s": multiple assets ' 125 | 'with extension "%s"', tag, ext) 126 | valid = False 127 | break 128 | 129 | if valid: 130 | downloads.extend(dls) 131 | 132 | downloads.sort(reverse=True) 133 | return downloads 134 | 135 | def __init__(self, url, filename, version, prerelease=False): 136 | """Create a new Download. 137 | 138 | Args: 139 | url (str): URL of workflow file. 140 | filename (str): Filename of workflow file. 141 | version (Version): Version of workflow. 142 | prerelease (bool, optional): Whether version is 143 | pre-release. Defaults to False. 144 | 145 | """ 146 | if isinstance(version, basestring): 147 | version = Version(version) 148 | 149 | self.url = url 150 | self.filename = filename 151 | self.version = version 152 | self.prerelease = prerelease 153 | 154 | @property 155 | def alfred_version(self): 156 | """Minimum Alfred version based on filename extension.""" 157 | m = match_workflow(self.filename) 158 | if not m or not m.group(1): 159 | return Version('0') 160 | return Version(m.group(1)) 161 | 162 | @property 163 | def dict(self): 164 | """Convert `Download` to `dict`.""" 165 | return dict(url=self.url, filename=self.filename, 166 | version=str(self.version), prerelease=self.prerelease) 167 | 168 | def __str__(self): 169 | """Format `Download` for printing.""" 170 | u = ('Download(url={dl.url!r}, ' 171 | 'filename={dl.filename!r}, ' 172 | 'version={dl.version!r}, ' 173 | 'prerelease={dl.prerelease!r})'.format(dl=self)) 174 | 175 | return u.encode('utf-8') 176 | 177 | def __repr__(self): 178 | """Code-like representation of `Download`.""" 179 | return str(self) 180 | 181 | def __eq__(self, other): 182 | """Compare Downloads based on version numbers.""" 183 | if self.url != other.url \ 184 | or self.filename != other.filename \ 185 | or self.version != other.version \ 186 | or self.prerelease != other.prerelease: 187 | return False 188 | return True 189 | 190 | def __ne__(self, other): 191 | """Compare Downloads based on version numbers.""" 192 | return not self.__eq__(other) 193 | 194 | def __lt__(self, other): 195 | """Compare Downloads based on version numbers.""" 196 | if self.version != other.version: 197 | return self.version < other.version 198 | return self.alfred_version < other.alfred_version 199 | 200 | 201 | class Version(object): 202 | """Mostly semantic versioning. 203 | 204 | The main difference to proper :ref:`semantic versioning ` 205 | is that this implementation doesn't require a minor or patch version. 206 | 207 | Version strings may also be prefixed with "v", e.g.: 208 | 209 | >>> v = Version('v1.1.1') 210 | >>> v.tuple 211 | (1, 1, 1, '') 212 | 213 | >>> v = Version('2.0') 214 | >>> v.tuple 215 | (2, 0, 0, '') 216 | 217 | >>> Version('3.1-beta').tuple 218 | (3, 1, 0, 'beta') 219 | 220 | >>> Version('1.0.1') > Version('0.0.1') 221 | True 222 | """ 223 | 224 | #: Match version and pre-release/build information in version strings 225 | match_version = re.compile(r'([0-9\.]+)(.+)?').match 226 | 227 | def __init__(self, vstr): 228 | """Create new `Version` object. 229 | 230 | Args: 231 | vstr (basestring): Semantic version string. 232 | """ 233 | if not vstr: 234 | raise ValueError('invalid version number: {!r}'.format(vstr)) 235 | 236 | self.vstr = vstr 237 | self.major = 0 238 | self.minor = 0 239 | self.patch = 0 240 | self.suffix = '' 241 | self.build = '' 242 | self._parse(vstr) 243 | 244 | def _parse(self, vstr): 245 | if vstr.startswith('v'): 246 | m = self.match_version(vstr[1:]) 247 | else: 248 | m = self.match_version(vstr) 249 | if not m: 250 | raise ValueError('invalid version number: {!r}'.format(vstr)) 251 | 252 | version, suffix = m.groups() 253 | parts = self._parse_dotted_string(version) 254 | self.major = parts.pop(0) 255 | if len(parts): 256 | self.minor = parts.pop(0) 257 | if len(parts): 258 | self.patch = parts.pop(0) 259 | if not len(parts) == 0: 260 | raise ValueError('version number too long: {!r}'.format(vstr)) 261 | 262 | if suffix: 263 | # Build info 264 | idx = suffix.find('+') 265 | if idx > -1: 266 | self.build = suffix[idx+1:] 267 | suffix = suffix[:idx] 268 | if suffix: 269 | if not suffix.startswith('-'): 270 | raise ValueError( 271 | 'suffix must start with - : {0}'.format(suffix)) 272 | self.suffix = suffix[1:] 273 | 274 | # wf().logger.debug('version str `{}` -> {}'.format(vstr, repr(self))) 275 | 276 | def _parse_dotted_string(self, s): 277 | """Parse string ``s`` into list of ints and strings.""" 278 | parsed = [] 279 | parts = s.split('.') 280 | for p in parts: 281 | if p.isdigit(): 282 | p = int(p) 283 | parsed.append(p) 284 | return parsed 285 | 286 | @property 287 | def tuple(self): 288 | """Version number as a tuple of major, minor, patch, pre-release.""" 289 | return (self.major, self.minor, self.patch, self.suffix) 290 | 291 | def __lt__(self, other): 292 | """Implement comparison.""" 293 | if not isinstance(other, Version): 294 | raise ValueError('not a Version instance: {0!r}'.format(other)) 295 | t = self.tuple[:3] 296 | o = other.tuple[:3] 297 | if t < o: 298 | return True 299 | if t == o: # We need to compare suffixes 300 | if self.suffix and not other.suffix: 301 | return True 302 | if other.suffix and not self.suffix: 303 | return False 304 | return self._parse_dotted_string(self.suffix) \ 305 | < self._parse_dotted_string(other.suffix) 306 | # t > o 307 | return False 308 | 309 | def __eq__(self, other): 310 | """Implement comparison.""" 311 | if not isinstance(other, Version): 312 | raise ValueError('not a Version instance: {0!r}'.format(other)) 313 | return self.tuple == other.tuple 314 | 315 | def __ne__(self, other): 316 | """Implement comparison.""" 317 | return not self.__eq__(other) 318 | 319 | def __gt__(self, other): 320 | """Implement comparison.""" 321 | if not isinstance(other, Version): 322 | raise ValueError('not a Version instance: {0!r}'.format(other)) 323 | return other.__lt__(self) 324 | 325 | def __le__(self, other): 326 | """Implement comparison.""" 327 | if not isinstance(other, Version): 328 | raise ValueError('not a Version instance: {0!r}'.format(other)) 329 | return not other.__lt__(self) 330 | 331 | def __ge__(self, other): 332 | """Implement comparison.""" 333 | return not self.__lt__(other) 334 | 335 | def __str__(self): 336 | """Return semantic version string.""" 337 | vstr = '{0}.{1}.{2}'.format(self.major, self.minor, self.patch) 338 | if self.suffix: 339 | vstr = '{0}-{1}'.format(vstr, self.suffix) 340 | if self.build: 341 | vstr = '{0}+{1}'.format(vstr, self.build) 342 | return vstr 343 | 344 | def __repr__(self): 345 | """Return 'code' representation of `Version`.""" 346 | return "Version('{0}')".format(str(self)) 347 | 348 | 349 | def retrieve_download(dl): 350 | """Saves a download to a temporary file and returns path. 351 | 352 | .. versionadded: 1.37 353 | 354 | Args: 355 | url (unicode): URL to .alfredworkflow file in GitHub repo 356 | 357 | Returns: 358 | unicode: path to downloaded file 359 | 360 | """ 361 | if not match_workflow(dl.filename): 362 | raise ValueError('attachment not a workflow: ' + dl.filename) 363 | 364 | path = os.path.join(tempfile.gettempdir(), dl.filename) 365 | wf().logger.debug('downloading update from ' 366 | '%r to %r ...', dl.url, path) 367 | 368 | r = web.get(dl.url) 369 | r.raise_for_status() 370 | 371 | r.save_to_path(path) 372 | 373 | return path 374 | 375 | 376 | def build_api_url(repo): 377 | """Generate releases URL from GitHub repo. 378 | 379 | Args: 380 | repo (unicode): Repo name in form ``username/repo`` 381 | 382 | Returns: 383 | unicode: URL to the API endpoint for the repo's releases 384 | 385 | """ 386 | if len(repo.split('/')) != 2: 387 | raise ValueError('invalid GitHub repo: {!r}'.format(repo)) 388 | 389 | return RELEASES_BASE.format(repo) 390 | 391 | 392 | def get_downloads(repo): 393 | """Load available ``Download``s for GitHub repo. 394 | 395 | .. versionadded: 1.37 396 | 397 | Args: 398 | repo (unicode): GitHub repo to load releases for. 399 | 400 | Returns: 401 | list: Sequence of `Download` contained in GitHub releases. 402 | """ 403 | url = build_api_url(repo) 404 | 405 | def _fetch(): 406 | wf().logger.info('retrieving releases for %r ...', repo) 407 | r = web.get(url) 408 | r.raise_for_status() 409 | return r.content 410 | 411 | key = 'github-releases-' + repo.replace('/', '-') 412 | js = wf().cached_data(key, _fetch, max_age=60) 413 | 414 | return Download.from_releases(js) 415 | 416 | 417 | def latest_download(dls, alfred_version=None, prereleases=False): 418 | """Return newest `Download`.""" 419 | alfred_version = alfred_version or os.getenv('alfred_version') 420 | version = None 421 | if alfred_version: 422 | version = Version(alfred_version) 423 | 424 | dls.sort(reverse=True) 425 | for dl in dls: 426 | if dl.prerelease and not prereleases: 427 | wf().logger.debug('ignored prerelease: %s', dl.version) 428 | continue 429 | if version and dl.alfred_version > version: 430 | wf().logger.debug('ignored incompatible (%s > %s): %s', 431 | dl.alfred_version, version, dl.filename) 432 | continue 433 | 434 | wf().logger.debug('latest version: %s (%s)', dl.version, dl.filename) 435 | return dl 436 | 437 | return None 438 | 439 | 440 | def check_update(repo, current_version, prereleases=False, 441 | alfred_version=None): 442 | """Check whether a newer release is available on GitHub. 443 | 444 | Args: 445 | repo (unicode): ``username/repo`` for workflow's GitHub repo 446 | current_version (unicode): the currently installed version of the 447 | workflow. :ref:`Semantic versioning ` is required. 448 | prereleases (bool): Whether to include pre-releases. 449 | alfred_version (unicode): version of currently-running Alfred. 450 | if empty, defaults to ``$alfred_version`` environment variable. 451 | 452 | Returns: 453 | bool: ``True`` if an update is available, else ``False`` 454 | 455 | If an update is available, its version number and download URL will 456 | be cached. 457 | 458 | """ 459 | key = '__workflow_latest_version' 460 | # data stored when no update is available 461 | no_update = { 462 | 'available': False, 463 | 'download': None, 464 | 'version': None, 465 | } 466 | current = Version(current_version) 467 | 468 | dls = get_downloads(repo) 469 | if not len(dls): 470 | wf().logger.warning('no valid downloads for %s', repo) 471 | wf().cache_data(key, no_update) 472 | return False 473 | 474 | wf().logger.info('%d download(s) for %s', len(dls), repo) 475 | 476 | dl = latest_download(dls, alfred_version, prereleases) 477 | 478 | if not dl: 479 | wf().logger.warning('no compatible downloads for %s', repo) 480 | wf().cache_data(key, no_update) 481 | return False 482 | 483 | wf().logger.debug('latest=%r, installed=%r', dl.version, current) 484 | 485 | if dl.version > current: 486 | wf().cache_data(key, { 487 | 'version': str(dl.version), 488 | 'download': dl.dict, 489 | 'available': True, 490 | }) 491 | return True 492 | 493 | wf().cache_data(key, no_update) 494 | return False 495 | 496 | 497 | def install_update(): 498 | """If a newer release is available, download and install it. 499 | 500 | :returns: ``True`` if an update is installed, else ``False`` 501 | 502 | """ 503 | key = '__workflow_latest_version' 504 | # data stored when no update is available 505 | no_update = { 506 | 'available': False, 507 | 'download': None, 508 | 'version': None, 509 | } 510 | status = wf().cached_data(key, max_age=0) 511 | 512 | if not status or not status.get('available'): 513 | wf().logger.info('no update available') 514 | return False 515 | 516 | dl = status.get('download') 517 | if not dl: 518 | wf().logger.info('no download information') 519 | return False 520 | 521 | path = retrieve_download(Download.from_dict(dl)) 522 | 523 | wf().logger.info('installing updated workflow ...') 524 | subprocess.call(['open', path]) 525 | 526 | wf().cache_data(key, no_update) 527 | return True 528 | 529 | 530 | if __name__ == '__main__': # pragma: nocover 531 | import sys 532 | 533 | prereleases = False 534 | 535 | def show_help(status=0): 536 | """Print help message.""" 537 | print('usage: update.py (check|install) ' 538 | '[--prereleases] ') 539 | sys.exit(status) 540 | 541 | argv = sys.argv[:] 542 | if '-h' in argv or '--help' in argv: 543 | show_help() 544 | 545 | if '--prereleases' in argv: 546 | argv.remove('--prereleases') 547 | prereleases = True 548 | 549 | if len(argv) != 4: 550 | show_help(1) 551 | 552 | action = argv[1] 553 | repo = argv[2] 554 | version = argv[3] 555 | 556 | try: 557 | 558 | if action == 'check': 559 | check_update(repo, version, prereleases) 560 | elif action == 'install': 561 | install_update() 562 | else: 563 | show_help(1) 564 | 565 | except Exception as err: # ensure traceback is in log file 566 | wf().logger.exception(err) 567 | raise err 568 | -------------------------------------------------------------------------------- /workflow/update.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevin-funderburg/alfred-search-keyboard-maestro-vars/a996043d8ed6076b10f245a5747d0b921c11dae7/workflow/update.pyc -------------------------------------------------------------------------------- /workflow/util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2017 Dean Jackson 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2017-12-17 9 | # 10 | 11 | """A selection of helper functions useful for building workflows.""" 12 | 13 | from __future__ import print_function, absolute_import 14 | 15 | import atexit 16 | from collections import namedtuple 17 | from contextlib import contextmanager 18 | import errno 19 | import fcntl 20 | import functools 21 | import json 22 | import os 23 | import signal 24 | import subprocess 25 | import sys 26 | from threading import Event 27 | import time 28 | 29 | # JXA scripts to call Alfred's API via the Scripting Bridge 30 | # {app} is automatically replaced with "Alfred 3" or 31 | # "com.runningwithcrayons.Alfred" depending on version. 32 | # 33 | # Open Alfred in search (regular) mode 34 | JXA_SEARCH = "Application({app}).search({arg});" 35 | # Open Alfred's File Actions on an argument 36 | JXA_ACTION = "Application({app}).action({arg});" 37 | # Open Alfred's navigation mode at path 38 | JXA_BROWSE = "Application({app}).browse({arg});" 39 | # Set the specified theme 40 | JXA_SET_THEME = "Application({app}).setTheme({arg});" 41 | # Call an External Trigger 42 | JXA_TRIGGER = "Application({app}).runTrigger({arg}, {opts});" 43 | # Save a variable to the workflow configuration sheet/info.plist 44 | JXA_SET_CONFIG = "Application({app}).setConfiguration({arg}, {opts});" 45 | # Delete a variable from the workflow configuration sheet/info.plist 46 | JXA_UNSET_CONFIG = "Application({app}).removeConfiguration({arg}, {opts});" 47 | 48 | 49 | class AcquisitionError(Exception): 50 | """Raised if a lock cannot be acquired.""" 51 | 52 | 53 | AppInfo = namedtuple('AppInfo', ['name', 'path', 'bundleid']) 54 | """Information about an installed application. 55 | 56 | Returned by :func:`appinfo`. All attributes are Unicode. 57 | 58 | .. py:attribute:: name 59 | 60 | Name of the application, e.g. ``u'Safari'``. 61 | 62 | .. py:attribute:: path 63 | 64 | Path to the application bundle, e.g. ``u'/Applications/Safari.app'``. 65 | 66 | .. py:attribute:: bundleid 67 | 68 | Application's bundle ID, e.g. ``u'com.apple.Safari'``. 69 | 70 | """ 71 | 72 | 73 | def jxa_app_name(): 74 | """Return name of application to call currently running Alfred. 75 | 76 | .. versionadded: 1.37 77 | 78 | Returns 'Alfred 3' or 'com.runningwithcrayons.Alfred' depending 79 | on which version of Alfred is running. 80 | 81 | This name is suitable for use with ``Application(name)`` in JXA. 82 | 83 | Returns: 84 | unicode: Application name or ID. 85 | 86 | """ 87 | if os.getenv('alfred_version', '').startswith('3'): 88 | # Alfred 3 89 | return u'Alfred 3' 90 | # Alfred 4+ 91 | return u'com.runningwithcrayons.Alfred' 92 | 93 | 94 | def unicodify(s, encoding='utf-8', norm=None): 95 | """Ensure string is Unicode. 96 | 97 | .. versionadded:: 1.31 98 | 99 | Decode encoded strings using ``encoding`` and normalise Unicode 100 | to form ``norm`` if specified. 101 | 102 | Args: 103 | s (str): String to decode. May also be Unicode. 104 | encoding (str, optional): Encoding to use on bytestrings. 105 | norm (None, optional): Normalisation form to apply to Unicode string. 106 | 107 | Returns: 108 | unicode: Decoded, optionally normalised, Unicode string. 109 | 110 | """ 111 | if not isinstance(s, unicode): 112 | s = unicode(s, encoding) 113 | 114 | if norm: 115 | from unicodedata import normalize 116 | s = normalize(norm, s) 117 | 118 | return s 119 | 120 | 121 | def utf8ify(s): 122 | """Ensure string is a bytestring. 123 | 124 | .. versionadded:: 1.31 125 | 126 | Returns `str` objects unchanced, encodes `unicode` objects to 127 | UTF-8, and calls :func:`str` on anything else. 128 | 129 | Args: 130 | s (object): A Python object 131 | 132 | Returns: 133 | str: UTF-8 string or string representation of s. 134 | 135 | """ 136 | if isinstance(s, str): 137 | return s 138 | 139 | if isinstance(s, unicode): 140 | return s.encode('utf-8') 141 | 142 | return str(s) 143 | 144 | 145 | def applescriptify(s): 146 | """Escape string for insertion into an AppleScript string. 147 | 148 | .. versionadded:: 1.31 149 | 150 | Replaces ``"`` with `"& quote &"`. Use this function if you want 151 | 152 | to insert a string into an AppleScript script: 153 | >>> query = 'g "python" test' 154 | >>> applescriptify(query) 155 | 'g " & quote & "python" & quote & "test' 156 | 157 | Args: 158 | s (unicode): Unicode string to escape. 159 | 160 | Returns: 161 | unicode: Escaped string 162 | 163 | """ 164 | return s.replace(u'"', u'" & quote & "') 165 | 166 | 167 | def run_command(cmd, **kwargs): 168 | """Run a command and return the output. 169 | 170 | .. versionadded:: 1.31 171 | 172 | A thin wrapper around :func:`subprocess.check_output` that ensures 173 | all arguments are encoded to UTF-8 first. 174 | 175 | Args: 176 | cmd (list): Command arguments to pass to ``check_output``. 177 | **kwargs: Keyword arguments to pass to ``check_output``. 178 | 179 | Returns: 180 | str: Output returned by ``check_output``. 181 | 182 | """ 183 | cmd = [utf8ify(s) for s in cmd] 184 | return subprocess.check_output(cmd, **kwargs) 185 | 186 | 187 | def run_applescript(script, *args, **kwargs): 188 | """Execute an AppleScript script and return its output. 189 | 190 | .. versionadded:: 1.31 191 | 192 | Run AppleScript either by filepath or code. If ``script`` is a valid 193 | filepath, that script will be run, otherwise ``script`` is treated 194 | as code. 195 | 196 | Args: 197 | script (str, optional): Filepath of script or code to run. 198 | *args: Optional command-line arguments to pass to the script. 199 | **kwargs: Pass ``lang`` to run a language other than AppleScript. 200 | 201 | Returns: 202 | str: Output of run command. 203 | 204 | """ 205 | lang = 'AppleScript' 206 | if 'lang' in kwargs: 207 | lang = kwargs['lang'] 208 | del kwargs['lang'] 209 | 210 | cmd = ['/usr/bin/osascript', '-l', lang] 211 | 212 | if os.path.exists(script): 213 | cmd += [script] 214 | else: 215 | cmd += ['-e', script] 216 | 217 | cmd.extend(args) 218 | 219 | return run_command(cmd, **kwargs) 220 | 221 | 222 | def run_jxa(script, *args): 223 | """Execute a JXA script and return its output. 224 | 225 | .. versionadded:: 1.31 226 | 227 | Wrapper around :func:`run_applescript` that passes ``lang=JavaScript``. 228 | 229 | Args: 230 | script (str): Filepath of script or code to run. 231 | *args: Optional command-line arguments to pass to script. 232 | 233 | Returns: 234 | str: Output of script. 235 | 236 | """ 237 | return run_applescript(script, *args, lang='JavaScript') 238 | 239 | 240 | def run_trigger(name, bundleid=None, arg=None): 241 | """Call an Alfred External Trigger. 242 | 243 | .. versionadded:: 1.31 244 | 245 | If ``bundleid`` is not specified, reads the bundle ID of the current 246 | workflow from Alfred's environment variables. 247 | 248 | Args: 249 | name (str): Name of External Trigger to call. 250 | bundleid (str, optional): Bundle ID of workflow trigger belongs to. 251 | arg (str, optional): Argument to pass to trigger. 252 | 253 | """ 254 | bundleid = bundleid or os.getenv('alfred_workflow_bundleid') 255 | appname = jxa_app_name() 256 | opts = {'inWorkflow': bundleid} 257 | if arg: 258 | opts['withArgument'] = arg 259 | 260 | script = JXA_TRIGGER.format(app=json.dumps(appname), 261 | arg=json.dumps(name), 262 | opts=json.dumps(opts, sort_keys=True)) 263 | 264 | run_applescript(script, lang='JavaScript') 265 | 266 | 267 | def set_config(name, value, bundleid=None, exportable=False): 268 | """Set a workflow variable in ``info.plist``. 269 | 270 | .. versionadded:: 1.33 271 | 272 | Args: 273 | name (str): Name of variable to set. 274 | value (str): Value to set variable to. 275 | bundleid (str, optional): Bundle ID of workflow variable belongs to. 276 | exportable (bool, optional): Whether variable should be marked 277 | as exportable (Don't Export checkbox). 278 | 279 | """ 280 | bundleid = bundleid or os.getenv('alfred_workflow_bundleid') 281 | appname = jxa_app_name() 282 | opts = { 283 | 'toValue': value, 284 | 'inWorkflow': bundleid, 285 | 'exportable': exportable, 286 | } 287 | 288 | script = JXA_SET_CONFIG.format(app=json.dumps(appname), 289 | arg=json.dumps(name), 290 | opts=json.dumps(opts, sort_keys=True)) 291 | 292 | run_applescript(script, lang='JavaScript') 293 | 294 | 295 | def unset_config(name, bundleid=None): 296 | """Delete a workflow variable from ``info.plist``. 297 | 298 | .. versionadded:: 1.33 299 | 300 | Args: 301 | name (str): Name of variable to delete. 302 | bundleid (str, optional): Bundle ID of workflow variable belongs to. 303 | 304 | """ 305 | bundleid = bundleid or os.getenv('alfred_workflow_bundleid') 306 | appname = jxa_app_name() 307 | opts = {'inWorkflow': bundleid} 308 | 309 | script = JXA_UNSET_CONFIG.format(app=json.dumps(appname), 310 | arg=json.dumps(name), 311 | opts=json.dumps(opts, sort_keys=True)) 312 | 313 | run_applescript(script, lang='JavaScript') 314 | 315 | 316 | def appinfo(name): 317 | """Get information about an installed application. 318 | 319 | .. versionadded:: 1.31 320 | 321 | Args: 322 | name (str): Name of application to look up. 323 | 324 | Returns: 325 | AppInfo: :class:`AppInfo` tuple or ``None`` if app isn't found. 326 | 327 | """ 328 | cmd = ['mdfind', '-onlyin', '/Applications', 329 | '-onlyin', os.path.expanduser('~/Applications'), 330 | '(kMDItemContentTypeTree == com.apple.application &&' 331 | '(kMDItemDisplayName == "{0}" || kMDItemFSName == "{0}.app"))' 332 | .format(name)] 333 | 334 | output = run_command(cmd).strip() 335 | if not output: 336 | return None 337 | 338 | path = output.split('\n')[0] 339 | 340 | cmd = ['mdls', '-raw', '-name', 'kMDItemCFBundleIdentifier', path] 341 | bid = run_command(cmd).strip() 342 | if not bid: # pragma: no cover 343 | return None 344 | 345 | return AppInfo(unicodify(name), unicodify(path), unicodify(bid)) 346 | 347 | 348 | @contextmanager 349 | def atomic_writer(fpath, mode): 350 | """Atomic file writer. 351 | 352 | .. versionadded:: 1.12 353 | 354 | Context manager that ensures the file is only written if the write 355 | succeeds. The data is first written to a temporary file. 356 | 357 | :param fpath: path of file to write to. 358 | :type fpath: ``unicode`` 359 | :param mode: sames as for :func:`open` 360 | :type mode: string 361 | 362 | """ 363 | suffix = '.{}.tmp'.format(os.getpid()) 364 | temppath = fpath + suffix 365 | with open(temppath, mode) as fp: 366 | try: 367 | yield fp 368 | os.rename(temppath, fpath) 369 | finally: 370 | try: 371 | os.remove(temppath) 372 | except (OSError, IOError): 373 | pass 374 | 375 | 376 | class LockFile(object): 377 | """Context manager to protect filepaths with lockfiles. 378 | 379 | .. versionadded:: 1.13 380 | 381 | Creates a lockfile alongside ``protected_path``. Other ``LockFile`` 382 | instances will refuse to lock the same path. 383 | 384 | >>> path = '/path/to/file' 385 | >>> with LockFile(path): 386 | >>> with open(path, 'wb') as fp: 387 | >>> fp.write(data) 388 | 389 | Args: 390 | protected_path (unicode): File to protect with a lockfile 391 | timeout (float, optional): Raises an :class:`AcquisitionError` 392 | if lock cannot be acquired within this number of seconds. 393 | If ``timeout`` is 0 (the default), wait forever. 394 | delay (float, optional): How often to check (in seconds) if 395 | lock has been released. 396 | 397 | Attributes: 398 | delay (float): How often to check (in seconds) whether the lock 399 | can be acquired. 400 | lockfile (unicode): Path of the lockfile. 401 | timeout (float): How long to wait to acquire the lock. 402 | 403 | """ 404 | 405 | def __init__(self, protected_path, timeout=0.0, delay=0.05): 406 | """Create new :class:`LockFile` object.""" 407 | self.lockfile = protected_path + '.lock' 408 | self._lockfile = None 409 | self.timeout = timeout 410 | self.delay = delay 411 | self._lock = Event() 412 | atexit.register(self.release) 413 | 414 | @property 415 | def locked(self): 416 | """``True`` if file is locked by this instance.""" 417 | return self._lock.is_set() 418 | 419 | def acquire(self, blocking=True): 420 | """Acquire the lock if possible. 421 | 422 | If the lock is in use and ``blocking`` is ``False``, return 423 | ``False``. 424 | 425 | Otherwise, check every :attr:`delay` seconds until it acquires 426 | lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`. 427 | 428 | """ 429 | if self.locked and not blocking: 430 | return False 431 | 432 | start = time.time() 433 | while True: 434 | # Raise error if we've been waiting too long to acquire the lock 435 | if self.timeout and (time.time() - start) >= self.timeout: 436 | raise AcquisitionError('lock acquisition timed out') 437 | 438 | # If already locked, wait then try again 439 | if self.locked: 440 | time.sleep(self.delay) 441 | continue 442 | 443 | # Create in append mode so we don't lose any contents 444 | if self._lockfile is None: 445 | self._lockfile = open(self.lockfile, 'a') 446 | 447 | # Try to acquire the lock 448 | try: 449 | fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB) 450 | self._lock.set() 451 | break 452 | except IOError as err: # pragma: no cover 453 | if err.errno not in (errno.EACCES, errno.EAGAIN): 454 | raise 455 | 456 | # Don't try again 457 | if not blocking: # pragma: no cover 458 | return False 459 | 460 | # Wait, then try again 461 | time.sleep(self.delay) 462 | 463 | return True 464 | 465 | def release(self): 466 | """Release the lock by deleting `self.lockfile`.""" 467 | if not self._lock.is_set(): 468 | return False 469 | 470 | try: 471 | fcntl.lockf(self._lockfile, fcntl.LOCK_UN) 472 | except IOError: # pragma: no cover 473 | pass 474 | finally: 475 | self._lock.clear() 476 | self._lockfile = None 477 | try: 478 | os.unlink(self.lockfile) 479 | except (IOError, OSError): # pragma: no cover 480 | pass 481 | 482 | return True 483 | 484 | def __enter__(self): 485 | """Acquire lock.""" 486 | self.acquire() 487 | return self 488 | 489 | def __exit__(self, typ, value, traceback): 490 | """Release lock.""" 491 | self.release() 492 | 493 | def __del__(self): 494 | """Clear up `self.lockfile`.""" 495 | self.release() # pragma: no cover 496 | 497 | 498 | class uninterruptible(object): 499 | """Decorator that postpones SIGTERM until wrapped function returns. 500 | 501 | .. versionadded:: 1.12 502 | 503 | .. important:: This decorator is NOT thread-safe. 504 | 505 | As of version 2.7, Alfred allows Script Filters to be killed. If 506 | your workflow is killed in the middle of critical code (e.g. 507 | writing data to disk), this may corrupt your workflow's data. 508 | 509 | Use this decorator to wrap critical functions that *must* complete. 510 | If the script is killed while a wrapped function is executing, 511 | the SIGTERM will be caught and handled after your function has 512 | finished executing. 513 | 514 | Alfred-Workflow uses this internally to ensure its settings, data 515 | and cache writes complete. 516 | 517 | """ 518 | 519 | def __init__(self, func, class_name=''): 520 | """Decorate `func`.""" 521 | self.func = func 522 | functools.update_wrapper(self, func) 523 | self._caught_signal = None 524 | 525 | def signal_handler(self, signum, frame): 526 | """Called when process receives SIGTERM.""" 527 | self._caught_signal = (signum, frame) 528 | 529 | def __call__(self, *args, **kwargs): 530 | """Trap ``SIGTERM`` and call wrapped function.""" 531 | self._caught_signal = None 532 | # Register handler for SIGTERM, then call `self.func` 533 | self.old_signal_handler = signal.getsignal(signal.SIGTERM) 534 | signal.signal(signal.SIGTERM, self.signal_handler) 535 | 536 | self.func(*args, **kwargs) 537 | 538 | # Restore old signal handler 539 | signal.signal(signal.SIGTERM, self.old_signal_handler) 540 | 541 | # Handle any signal caught during execution 542 | if self._caught_signal is not None: 543 | signum, frame = self._caught_signal 544 | if callable(self.old_signal_handler): 545 | self.old_signal_handler(signum, frame) 546 | elif self.old_signal_handler == signal.SIG_DFL: 547 | sys.exit(0) 548 | 549 | def __get__(self, obj=None, klass=None): 550 | """Decorator API.""" 551 | return self.__class__(self.func.__get__(obj, klass), 552 | klass.__name__) 553 | -------------------------------------------------------------------------------- /workflow/util.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevin-funderburg/alfred-search-keyboard-maestro-vars/a996043d8ed6076b10f245a5747d0b921c11dae7/workflow/util.pyc -------------------------------------------------------------------------------- /workflow/version: -------------------------------------------------------------------------------- 1 | 1.37.1 -------------------------------------------------------------------------------- /workflow/web.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2014 Dean Jackson 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2014-02-15 8 | # 9 | 10 | """Lightweight HTTP library with a requests-like interface.""" 11 | 12 | import codecs 13 | import json 14 | import mimetypes 15 | import os 16 | import random 17 | import re 18 | import socket 19 | import string 20 | import unicodedata 21 | import urllib 22 | import urllib2 23 | import urlparse 24 | import zlib 25 | 26 | 27 | USER_AGENT = u'Alfred-Workflow/1.36 (+http://www.deanishe.net/alfred-workflow)' 28 | 29 | # Valid characters for multipart form data boundaries 30 | BOUNDARY_CHARS = string.digits + string.ascii_letters 31 | 32 | # HTTP response codes 33 | RESPONSES = { 34 | 100: 'Continue', 35 | 101: 'Switching Protocols', 36 | 200: 'OK', 37 | 201: 'Created', 38 | 202: 'Accepted', 39 | 203: 'Non-Authoritative Information', 40 | 204: 'No Content', 41 | 205: 'Reset Content', 42 | 206: 'Partial Content', 43 | 300: 'Multiple Choices', 44 | 301: 'Moved Permanently', 45 | 302: 'Found', 46 | 303: 'See Other', 47 | 304: 'Not Modified', 48 | 305: 'Use Proxy', 49 | 307: 'Temporary Redirect', 50 | 400: 'Bad Request', 51 | 401: 'Unauthorized', 52 | 402: 'Payment Required', 53 | 403: 'Forbidden', 54 | 404: 'Not Found', 55 | 405: 'Method Not Allowed', 56 | 406: 'Not Acceptable', 57 | 407: 'Proxy Authentication Required', 58 | 408: 'Request Timeout', 59 | 409: 'Conflict', 60 | 410: 'Gone', 61 | 411: 'Length Required', 62 | 412: 'Precondition Failed', 63 | 413: 'Request Entity Too Large', 64 | 414: 'Request-URI Too Long', 65 | 415: 'Unsupported Media Type', 66 | 416: 'Requested Range Not Satisfiable', 67 | 417: 'Expectation Failed', 68 | 500: 'Internal Server Error', 69 | 501: 'Not Implemented', 70 | 502: 'Bad Gateway', 71 | 503: 'Service Unavailable', 72 | 504: 'Gateway Timeout', 73 | 505: 'HTTP Version Not Supported' 74 | } 75 | 76 | 77 | def str_dict(dic): 78 | """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`. 79 | 80 | :param dic: Mapping of Unicode strings 81 | :type dic: dict 82 | :returns: Dictionary containing only UTF-8 strings 83 | :rtype: dict 84 | 85 | """ 86 | if isinstance(dic, CaseInsensitiveDictionary): 87 | dic2 = CaseInsensitiveDictionary() 88 | else: 89 | dic2 = {} 90 | for k, v in dic.items(): 91 | if isinstance(k, unicode): 92 | k = k.encode('utf-8') 93 | if isinstance(v, unicode): 94 | v = v.encode('utf-8') 95 | dic2[k] = v 96 | return dic2 97 | 98 | 99 | class NoRedirectHandler(urllib2.HTTPRedirectHandler): 100 | """Prevent redirections.""" 101 | 102 | def redirect_request(self, *args): 103 | """Ignore redirect.""" 104 | return None 105 | 106 | 107 | # Adapted from https://gist.github.com/babakness/3901174 108 | class CaseInsensitiveDictionary(dict): 109 | """Dictionary with caseless key search. 110 | 111 | Enables case insensitive searching while preserving case sensitivity 112 | when keys are listed, ie, via keys() or items() methods. 113 | 114 | Works by storing a lowercase version of the key as the new key and 115 | stores the original key-value pair as the key's value 116 | (values become dictionaries). 117 | 118 | """ 119 | 120 | def __init__(self, initval=None): 121 | """Create new case-insensitive dictionary.""" 122 | if isinstance(initval, dict): 123 | for key, value in initval.iteritems(): 124 | self.__setitem__(key, value) 125 | 126 | elif isinstance(initval, list): 127 | for (key, value) in initval: 128 | self.__setitem__(key, value) 129 | 130 | def __contains__(self, key): 131 | return dict.__contains__(self, key.lower()) 132 | 133 | def __getitem__(self, key): 134 | return dict.__getitem__(self, key.lower())['val'] 135 | 136 | def __setitem__(self, key, value): 137 | return dict.__setitem__(self, key.lower(), {'key': key, 'val': value}) 138 | 139 | def get(self, key, default=None): 140 | """Return value for case-insensitive key or default.""" 141 | try: 142 | v = dict.__getitem__(self, key.lower()) 143 | except KeyError: 144 | return default 145 | else: 146 | return v['val'] 147 | 148 | def update(self, other): 149 | """Update values from other ``dict``.""" 150 | for k, v in other.items(): 151 | self[k] = v 152 | 153 | def items(self): 154 | """Return ``(key, value)`` pairs.""" 155 | return [(v['key'], v['val']) for v in dict.itervalues(self)] 156 | 157 | def keys(self): 158 | """Return original keys.""" 159 | return [v['key'] for v in dict.itervalues(self)] 160 | 161 | def values(self): 162 | """Return all values.""" 163 | return [v['val'] for v in dict.itervalues(self)] 164 | 165 | def iteritems(self): 166 | """Iterate over ``(key, value)`` pairs.""" 167 | for v in dict.itervalues(self): 168 | yield v['key'], v['val'] 169 | 170 | def iterkeys(self): 171 | """Iterate over original keys.""" 172 | for v in dict.itervalues(self): 173 | yield v['key'] 174 | 175 | def itervalues(self): 176 | """Interate over values.""" 177 | for v in dict.itervalues(self): 178 | yield v['val'] 179 | 180 | 181 | class Response(object): 182 | """ 183 | Returned by :func:`request` / :func:`get` / :func:`post` functions. 184 | 185 | Simplified version of the ``Response`` object in the ``requests`` library. 186 | 187 | >>> r = request('http://www.google.com') 188 | >>> r.status_code 189 | 200 190 | >>> r.encoding 191 | ISO-8859-1 192 | >>> r.content # bytes 193 | ... 194 | >>> r.text # unicode, decoded according to charset in HTTP header/meta tag 195 | u' ...' 196 | >>> r.json() # content parsed as JSON 197 | 198 | """ 199 | 200 | def __init__(self, request, stream=False): 201 | """Call `request` with :mod:`urllib2` and process results. 202 | 203 | :param request: :class:`urllib2.Request` instance 204 | :param stream: Whether to stream response or retrieve it all at once 205 | :type stream: bool 206 | 207 | """ 208 | self.request = request 209 | self._stream = stream 210 | self.url = None 211 | self.raw = None 212 | self._encoding = None 213 | self.error = None 214 | self.status_code = None 215 | self.reason = None 216 | self.headers = CaseInsensitiveDictionary() 217 | self._content = None 218 | self._content_loaded = False 219 | self._gzipped = False 220 | 221 | # Execute query 222 | try: 223 | self.raw = urllib2.urlopen(request) 224 | except urllib2.HTTPError as err: 225 | self.error = err 226 | try: 227 | self.url = err.geturl() 228 | # sometimes (e.g. when authentication fails) 229 | # urllib can't get a URL from an HTTPError 230 | # This behaviour changes across Python versions, 231 | # so no test cover (it isn't important). 232 | except AttributeError: # pragma: no cover 233 | pass 234 | self.status_code = err.code 235 | else: 236 | self.status_code = self.raw.getcode() 237 | self.url = self.raw.geturl() 238 | self.reason = RESPONSES.get(self.status_code) 239 | 240 | # Parse additional info if request succeeded 241 | if not self.error: 242 | headers = self.raw.info() 243 | self.transfer_encoding = headers.getencoding() 244 | self.mimetype = headers.gettype() 245 | for key in headers.keys(): 246 | self.headers[key.lower()] = headers.get(key) 247 | 248 | # Is content gzipped? 249 | # Transfer-Encoding appears to not be used in the wild 250 | # (contrary to the HTTP standard), but no harm in testing 251 | # for it 252 | if 'gzip' in headers.get('content-encoding', '') or \ 253 | 'gzip' in headers.get('transfer-encoding', ''): 254 | self._gzipped = True 255 | 256 | @property 257 | def stream(self): 258 | """Whether response is streamed. 259 | 260 | Returns: 261 | bool: `True` if response is streamed. 262 | 263 | """ 264 | return self._stream 265 | 266 | @stream.setter 267 | def stream(self, value): 268 | if self._content_loaded: 269 | raise RuntimeError("`content` has already been read from " 270 | "this Response.") 271 | 272 | self._stream = value 273 | 274 | def json(self): 275 | """Decode response contents as JSON. 276 | 277 | :returns: object decoded from JSON 278 | :rtype: list, dict or unicode 279 | 280 | """ 281 | return json.loads(self.content, self.encoding or 'utf-8') 282 | 283 | @property 284 | def encoding(self): 285 | """Text encoding of document or ``None``. 286 | 287 | :returns: Text encoding if found. 288 | :rtype: str or ``None`` 289 | 290 | """ 291 | if not self._encoding: 292 | self._encoding = self._get_encoding() 293 | 294 | return self._encoding 295 | 296 | @property 297 | def content(self): 298 | """Raw content of response (i.e. bytes). 299 | 300 | :returns: Body of HTTP response 301 | :rtype: str 302 | 303 | """ 304 | if not self._content: 305 | 306 | # Decompress gzipped content 307 | if self._gzipped: 308 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) 309 | self._content = decoder.decompress(self.raw.read()) 310 | 311 | else: 312 | self._content = self.raw.read() 313 | 314 | self._content_loaded = True 315 | 316 | return self._content 317 | 318 | @property 319 | def text(self): 320 | """Unicode-decoded content of response body. 321 | 322 | If no encoding can be determined from HTTP headers or the content 323 | itself, the encoded response body will be returned instead. 324 | 325 | :returns: Body of HTTP response 326 | :rtype: unicode or str 327 | 328 | """ 329 | if self.encoding: 330 | return unicodedata.normalize('NFC', unicode(self.content, 331 | self.encoding)) 332 | return self.content 333 | 334 | def iter_content(self, chunk_size=4096, decode_unicode=False): 335 | """Iterate over response data. 336 | 337 | .. versionadded:: 1.6 338 | 339 | :param chunk_size: Number of bytes to read into memory 340 | :type chunk_size: int 341 | :param decode_unicode: Decode to Unicode using detected encoding 342 | :type decode_unicode: bool 343 | :returns: iterator 344 | 345 | """ 346 | if not self.stream: 347 | raise RuntimeError("You cannot call `iter_content` on a " 348 | "Response unless you passed `stream=True`" 349 | " to `get()`/`post()`/`request()`.") 350 | 351 | if self._content_loaded: 352 | raise RuntimeError( 353 | "`content` has already been read from this Response.") 354 | 355 | def decode_stream(iterator, r): 356 | dec = codecs.getincrementaldecoder(r.encoding)(errors='replace') 357 | 358 | for chunk in iterator: 359 | data = dec.decode(chunk) 360 | if data: 361 | yield data 362 | 363 | data = dec.decode(b'', final=True) 364 | if data: # pragma: no cover 365 | yield data 366 | 367 | def generate(): 368 | if self._gzipped: 369 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) 370 | 371 | while True: 372 | chunk = self.raw.read(chunk_size) 373 | if not chunk: 374 | break 375 | 376 | if self._gzipped: 377 | chunk = decoder.decompress(chunk) 378 | 379 | yield chunk 380 | 381 | chunks = generate() 382 | 383 | if decode_unicode and self.encoding: 384 | chunks = decode_stream(chunks, self) 385 | 386 | return chunks 387 | 388 | def save_to_path(self, filepath): 389 | """Save retrieved data to file at ``filepath``. 390 | 391 | .. versionadded: 1.9.6 392 | 393 | :param filepath: Path to save retrieved data. 394 | 395 | """ 396 | filepath = os.path.abspath(filepath) 397 | dirname = os.path.dirname(filepath) 398 | if not os.path.exists(dirname): 399 | os.makedirs(dirname) 400 | 401 | self.stream = True 402 | 403 | with open(filepath, 'wb') as fileobj: 404 | for data in self.iter_content(): 405 | fileobj.write(data) 406 | 407 | def raise_for_status(self): 408 | """Raise stored error if one occurred. 409 | 410 | error will be instance of :class:`urllib2.HTTPError` 411 | """ 412 | if self.error is not None: 413 | raise self.error 414 | return 415 | 416 | def _get_encoding(self): 417 | """Get encoding from HTTP headers or content. 418 | 419 | :returns: encoding or `None` 420 | :rtype: unicode or ``None`` 421 | 422 | """ 423 | headers = self.raw.info() 424 | encoding = None 425 | 426 | if headers.getparam('charset'): 427 | encoding = headers.getparam('charset') 428 | 429 | # HTTP Content-Type header 430 | for param in headers.getplist(): 431 | if param.startswith('charset='): 432 | encoding = param[8:] 433 | break 434 | 435 | if not self.stream: # Try sniffing response content 436 | # Encoding declared in document should override HTTP headers 437 | if self.mimetype == 'text/html': # sniff HTML headers 438 | m = re.search(r"""""", 439 | self.content) 440 | if m: 441 | encoding = m.group(1) 442 | 443 | elif ((self.mimetype.startswith('application/') 444 | or self.mimetype.startswith('text/')) 445 | and 'xml' in self.mimetype): 446 | m = re.search(r"""]*\?>""", 447 | self.content) 448 | if m: 449 | encoding = m.group(1) 450 | 451 | # Format defaults 452 | if self.mimetype == 'application/json' and not encoding: 453 | # The default encoding for JSON 454 | encoding = 'utf-8' 455 | 456 | elif self.mimetype == 'application/xml' and not encoding: 457 | # The default for 'application/xml' 458 | encoding = 'utf-8' 459 | 460 | if encoding: 461 | encoding = encoding.lower() 462 | 463 | return encoding 464 | 465 | 466 | def request(method, url, params=None, data=None, headers=None, cookies=None, 467 | files=None, auth=None, timeout=60, allow_redirects=False, 468 | stream=False): 469 | """Initiate an HTTP(S) request. Returns :class:`Response` object. 470 | 471 | :param method: 'GET' or 'POST' 472 | :type method: unicode 473 | :param url: URL to open 474 | :type url: unicode 475 | :param params: mapping of URL parameters 476 | :type params: dict 477 | :param data: mapping of form data ``{'field_name': 'value'}`` or 478 | :class:`str` 479 | :type data: dict or str 480 | :param headers: HTTP headers 481 | :type headers: dict 482 | :param cookies: cookies to send to server 483 | :type cookies: dict 484 | :param files: files to upload (see below). 485 | :type files: dict 486 | :param auth: username, password 487 | :type auth: tuple 488 | :param timeout: connection timeout limit in seconds 489 | :type timeout: int 490 | :param allow_redirects: follow redirections 491 | :type allow_redirects: bool 492 | :param stream: Stream content instead of fetching it all at once. 493 | :type stream: bool 494 | :returns: Response object 495 | :rtype: :class:`Response` 496 | 497 | 498 | The ``files`` argument is a dictionary:: 499 | 500 | {'fieldname' : { 'filename': 'blah.txt', 501 | 'content': '', 502 | 'mimetype': 'text/plain'} 503 | } 504 | 505 | * ``fieldname`` is the name of the field in the HTML form. 506 | * ``mimetype`` is optional. If not provided, :mod:`mimetypes` will 507 | be used to guess the mimetype, or ``application/octet-stream`` 508 | will be used. 509 | 510 | """ 511 | # TODO: cookies 512 | socket.setdefaulttimeout(timeout) 513 | 514 | # Default handlers 515 | openers = [] 516 | 517 | if not allow_redirects: 518 | openers.append(NoRedirectHandler()) 519 | 520 | if auth is not None: # Add authorisation handler 521 | username, password = auth 522 | password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm() 523 | password_manager.add_password(None, url, username, password) 524 | auth_manager = urllib2.HTTPBasicAuthHandler(password_manager) 525 | openers.append(auth_manager) 526 | 527 | # Install our custom chain of openers 528 | opener = urllib2.build_opener(*openers) 529 | urllib2.install_opener(opener) 530 | 531 | if not headers: 532 | headers = CaseInsensitiveDictionary() 533 | else: 534 | headers = CaseInsensitiveDictionary(headers) 535 | 536 | if 'user-agent' not in headers: 537 | headers['user-agent'] = USER_AGENT 538 | 539 | # Accept gzip-encoded content 540 | encodings = [s.strip() for s in 541 | headers.get('accept-encoding', '').split(',')] 542 | if 'gzip' not in encodings: 543 | encodings.append('gzip') 544 | 545 | headers['accept-encoding'] = ', '.join(encodings) 546 | 547 | # Force POST by providing an empty data string 548 | if method == 'POST' and not data: 549 | data = '' 550 | 551 | if files: 552 | if not data: 553 | data = {} 554 | new_headers, data = encode_multipart_formdata(data, files) 555 | headers.update(new_headers) 556 | elif data and isinstance(data, dict): 557 | data = urllib.urlencode(str_dict(data)) 558 | 559 | # Make sure everything is encoded text 560 | headers = str_dict(headers) 561 | 562 | if isinstance(url, unicode): 563 | url = url.encode('utf-8') 564 | 565 | if params: # GET args (POST args are handled in encode_multipart_formdata) 566 | 567 | scheme, netloc, path, query, fragment = urlparse.urlsplit(url) 568 | 569 | if query: # Combine query string and `params` 570 | url_params = urlparse.parse_qs(query) 571 | # `params` take precedence over URL query string 572 | url_params.update(params) 573 | params = url_params 574 | 575 | query = urllib.urlencode(str_dict(params), doseq=True) 576 | url = urlparse.urlunsplit((scheme, netloc, path, query, fragment)) 577 | 578 | req = urllib2.Request(url, data, headers) 579 | return Response(req, stream) 580 | 581 | 582 | def get(url, params=None, headers=None, cookies=None, auth=None, 583 | timeout=60, allow_redirects=True, stream=False): 584 | """Initiate a GET request. Arguments as for :func:`request`. 585 | 586 | :returns: :class:`Response` instance 587 | 588 | """ 589 | return request('GET', url, params, headers=headers, cookies=cookies, 590 | auth=auth, timeout=timeout, allow_redirects=allow_redirects, 591 | stream=stream) 592 | 593 | 594 | def post(url, params=None, data=None, headers=None, cookies=None, files=None, 595 | auth=None, timeout=60, allow_redirects=False, stream=False): 596 | """Initiate a POST request. Arguments as for :func:`request`. 597 | 598 | :returns: :class:`Response` instance 599 | 600 | """ 601 | return request('POST', url, params, data, headers, cookies, files, auth, 602 | timeout, allow_redirects, stream) 603 | 604 | 605 | def encode_multipart_formdata(fields, files): 606 | """Encode form data (``fields``) and ``files`` for POST request. 607 | 608 | :param fields: mapping of ``{name : value}`` pairs for normal form fields. 609 | :type fields: dict 610 | :param files: dictionary of fieldnames/files elements for file data. 611 | See below for details. 612 | :type files: dict of :class:`dict` 613 | :returns: ``(headers, body)`` ``headers`` is a 614 | :class:`dict` of HTTP headers 615 | :rtype: 2-tuple ``(dict, str)`` 616 | 617 | The ``files`` argument is a dictionary:: 618 | 619 | {'fieldname' : { 'filename': 'blah.txt', 620 | 'content': '', 621 | 'mimetype': 'text/plain'} 622 | } 623 | 624 | - ``fieldname`` is the name of the field in the HTML form. 625 | - ``mimetype`` is optional. If not provided, :mod:`mimetypes` will 626 | be used to guess the mimetype, or ``application/octet-stream`` 627 | will be used. 628 | 629 | """ 630 | def get_content_type(filename): 631 | """Return or guess mimetype of ``filename``. 632 | 633 | :param filename: filename of file 634 | :type filename: unicode/str 635 | :returns: mime-type, e.g. ``text/html`` 636 | :rtype: str 637 | 638 | """ 639 | return mimetypes.guess_type(filename)[0] or 'application/octet-stream' 640 | 641 | boundary = '-----' + ''.join(random.choice(BOUNDARY_CHARS) 642 | for i in range(30)) 643 | CRLF = '\r\n' 644 | output = [] 645 | 646 | # Normal form fields 647 | for (name, value) in fields.items(): 648 | if isinstance(name, unicode): 649 | name = name.encode('utf-8') 650 | if isinstance(value, unicode): 651 | value = value.encode('utf-8') 652 | output.append('--' + boundary) 653 | output.append('Content-Disposition: form-data; name="%s"' % name) 654 | output.append('') 655 | output.append(value) 656 | 657 | # Files to upload 658 | for name, d in files.items(): 659 | filename = d[u'filename'] 660 | content = d[u'content'] 661 | if u'mimetype' in d: 662 | mimetype = d[u'mimetype'] 663 | else: 664 | mimetype = get_content_type(filename) 665 | if isinstance(name, unicode): 666 | name = name.encode('utf-8') 667 | if isinstance(filename, unicode): 668 | filename = filename.encode('utf-8') 669 | if isinstance(mimetype, unicode): 670 | mimetype = mimetype.encode('utf-8') 671 | output.append('--' + boundary) 672 | output.append('Content-Disposition: form-data; ' 673 | 'name="%s"; filename="%s"' % (name, filename)) 674 | output.append('Content-Type: %s' % mimetype) 675 | output.append('') 676 | output.append(content) 677 | 678 | output.append('--' + boundary + '--') 679 | output.append('') 680 | body = CRLF.join(output) 681 | headers = { 682 | 'Content-Type': 'multipart/form-data; boundary=%s' % boundary, 683 | 'Content-Length': str(len(body)), 684 | } 685 | return (headers, body) 686 | -------------------------------------------------------------------------------- /workflow/web.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevin-funderburg/alfred-search-keyboard-maestro-vars/a996043d8ed6076b10f245a5747d0b921c11dae7/workflow/web.pyc -------------------------------------------------------------------------------- /workflow/workflow.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevin-funderburg/alfred-search-keyboard-maestro-vars/a996043d8ed6076b10f245a5747d0b921c11dae7/workflow/workflow.pyc -------------------------------------------------------------------------------- /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 | from __future__ import print_function, unicode_literals, absolute_import 27 | 28 | import json 29 | import os 30 | import sys 31 | 32 | from .workflow import ICON_WARNING, Workflow 33 | 34 | 35 | class Variables(dict): 36 | """Workflow variables for Run Script actions. 37 | 38 | .. versionadded: 1.26 39 | 40 | This class allows you to set workflow variables from 41 | Run Script actions. 42 | 43 | It is a subclass of :class:`dict`. 44 | 45 | >>> v = Variables(username='deanishe', password='hunter2') 46 | >>> v.arg = u'output value' 47 | >>> print(v) 48 | 49 | See :ref:`variables-run-script` in the User Guide for more 50 | information. 51 | 52 | Args: 53 | arg (unicode, optional): Main output/``{query}``. 54 | **variables: Workflow variables to set. 55 | 56 | 57 | Attributes: 58 | arg (unicode): Output value (``{query}``). 59 | config (dict): Configuration for downstream workflow element. 60 | 61 | """ 62 | 63 | def __init__(self, arg=None, **variables): 64 | """Create a new `Variables` object.""" 65 | self.arg = arg 66 | self.config = {} 67 | super(Variables, self).__init__(**variables) 68 | 69 | @property 70 | def obj(self): 71 | """Return ``alfredworkflow`` `dict`.""" 72 | o = {} 73 | if self: 74 | d2 = {} 75 | for k, v in self.items(): 76 | d2[k] = v 77 | o['variables'] = d2 78 | 79 | if self.config: 80 | o['config'] = self.config 81 | 82 | if self.arg is not None: 83 | o['arg'] = self.arg 84 | 85 | return {'alfredworkflow': o} 86 | 87 | def __unicode__(self): 88 | """Convert to ``alfredworkflow`` JSON object. 89 | 90 | Returns: 91 | unicode: ``alfredworkflow`` JSON object 92 | 93 | """ 94 | if not self and not self.config: 95 | if self.arg: 96 | return self.arg 97 | else: 98 | return u'' 99 | 100 | return json.dumps(self.obj) 101 | 102 | def __str__(self): 103 | """Convert to ``alfredworkflow`` JSON object. 104 | 105 | Returns: 106 | str: UTF-8 encoded ``alfredworkflow`` JSON object 107 | 108 | """ 109 | return unicode(self).encode('utf-8') 110 | 111 | 112 | class Modifier(object): 113 | """Modify :class:`Item3` arg/icon/variables when modifier key is pressed. 114 | 115 | Don't use this class directly (as it won't be associated with any 116 | :class:`Item3`), but rather use :meth:`Item3.add_modifier()` 117 | to add modifiers to results. 118 | 119 | >>> it = wf.add_item('Title', 'Subtitle', valid=True) 120 | >>> it.setvar('name', 'default') 121 | >>> m = it.add_modifier('cmd') 122 | >>> m.setvar('name', 'alternate') 123 | 124 | See :ref:`workflow-variables` in the User Guide for more information 125 | and :ref:`example usage `. 126 | 127 | Args: 128 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. 129 | subtitle (unicode, optional): Override default subtitle. 130 | arg (unicode, optional): Argument to pass for this modifier. 131 | valid (bool, optional): Override item's validity. 132 | icon (unicode, optional): Filepath/UTI of icon to use 133 | icontype (unicode, optional): Type of icon. See 134 | :meth:`Workflow.add_item() ` 135 | for valid values. 136 | 137 | Attributes: 138 | arg (unicode): Arg to pass to following action. 139 | config (dict): Configuration for a downstream element, such as 140 | a File Filter. 141 | icon (unicode): Filepath/UTI of icon. 142 | icontype (unicode): Type of icon. See 143 | :meth:`Workflow.add_item() ` 144 | for valid values. 145 | key (unicode): Modifier key (see above). 146 | subtitle (unicode): Override item subtitle. 147 | valid (bool): Override item validity. 148 | variables (dict): Workflow variables set by this modifier. 149 | 150 | """ 151 | 152 | def __init__(self, key, subtitle=None, arg=None, valid=None, icon=None, 153 | icontype=None): 154 | """Create a new :class:`Modifier`. 155 | 156 | Don't use this class directly (as it won't be associated with any 157 | :class:`Item3`), but rather use :meth:`Item3.add_modifier()` 158 | to add modifiers to results. 159 | 160 | Args: 161 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. 162 | subtitle (unicode, optional): Override default subtitle. 163 | arg (unicode, optional): Argument to pass for this modifier. 164 | valid (bool, optional): Override item's validity. 165 | icon (unicode, optional): Filepath/UTI of icon to use 166 | icontype (unicode, optional): Type of icon. See 167 | :meth:`Workflow.add_item() ` 168 | for valid values. 169 | 170 | """ 171 | self.key = key 172 | self.subtitle = subtitle 173 | self.arg = arg 174 | self.valid = valid 175 | self.icon = icon 176 | self.icontype = icontype 177 | 178 | self.config = {} 179 | self.variables = {} 180 | 181 | def setvar(self, name, value): 182 | """Set a workflow variable for this Item. 183 | 184 | Args: 185 | name (unicode): Name of variable. 186 | value (unicode): Value of variable. 187 | 188 | """ 189 | self.variables[name] = value 190 | 191 | def getvar(self, name, default=None): 192 | """Return value of workflow variable for ``name`` or ``default``. 193 | 194 | Args: 195 | name (unicode): Variable name. 196 | default (None, optional): Value to return if variable is unset. 197 | 198 | Returns: 199 | unicode or ``default``: Value of variable if set or ``default``. 200 | 201 | """ 202 | return self.variables.get(name, default) 203 | 204 | @property 205 | def obj(self): 206 | """Modifier formatted for JSON serialization for Alfred 3. 207 | 208 | Returns: 209 | dict: Modifier for serializing to JSON. 210 | 211 | """ 212 | o = {} 213 | 214 | if self.subtitle is not None: 215 | o['subtitle'] = self.subtitle 216 | 217 | if self.arg is not None: 218 | o['arg'] = self.arg 219 | 220 | if self.valid is not None: 221 | o['valid'] = self.valid 222 | 223 | if self.variables: 224 | o['variables'] = self.variables 225 | 226 | if self.config: 227 | o['config'] = self.config 228 | 229 | icon = self._icon() 230 | if icon: 231 | o['icon'] = icon 232 | 233 | return o 234 | 235 | def _icon(self): 236 | """Return `icon` object for item. 237 | 238 | Returns: 239 | dict: Mapping for item `icon` (may be empty). 240 | 241 | """ 242 | icon = {} 243 | if self.icon is not None: 244 | icon['path'] = self.icon 245 | 246 | if self.icontype is not None: 247 | icon['type'] = self.icontype 248 | 249 | return icon 250 | 251 | 252 | class Item3(object): 253 | """Represents a feedback item for Alfred 3+. 254 | 255 | Generates Alfred-compliant JSON for a single item. 256 | 257 | Don't use this class directly (as it then won't be associated with 258 | any :class:`Workflow3 ` object), but rather use 259 | :meth:`Workflow3.add_item() `. 260 | See :meth:`~workflow.Workflow3.add_item` for details of arguments. 261 | 262 | """ 263 | 264 | def __init__(self, title, subtitle='', arg=None, autocomplete=None, 265 | match=None, valid=False, uid=None, icon=None, icontype=None, 266 | type=None, largetext=None, copytext=None, quicklookurl=None): 267 | """Create a new :class:`Item3` object. 268 | 269 | Use same arguments as for 270 | :class:`Workflow.Item `. 271 | 272 | Argument ``subtitle_modifiers`` is not supported. 273 | 274 | """ 275 | self.title = title 276 | self.subtitle = subtitle 277 | self.arg = arg 278 | self.autocomplete = autocomplete 279 | self.match = match 280 | self.valid = valid 281 | self.uid = uid 282 | self.icon = icon 283 | self.icontype = icontype 284 | self.type = type 285 | self.quicklookurl = quicklookurl 286 | self.largetext = largetext 287 | self.copytext = copytext 288 | 289 | self.modifiers = {} 290 | 291 | self.config = {} 292 | self.variables = {} 293 | 294 | def setvar(self, name, value): 295 | """Set a workflow variable for this Item. 296 | 297 | Args: 298 | name (unicode): Name of variable. 299 | value (unicode): Value of variable. 300 | 301 | """ 302 | self.variables[name] = value 303 | 304 | def getvar(self, name, default=None): 305 | """Return value of workflow variable for ``name`` or ``default``. 306 | 307 | Args: 308 | name (unicode): Variable name. 309 | default (None, optional): Value to return if variable is unset. 310 | 311 | Returns: 312 | unicode or ``default``: Value of variable if set or ``default``. 313 | 314 | """ 315 | return self.variables.get(name, default) 316 | 317 | def add_modifier(self, key, subtitle=None, arg=None, valid=None, icon=None, 318 | icontype=None): 319 | """Add alternative values for a modifier key. 320 | 321 | Args: 322 | key (unicode): Modifier key, e.g. ``"cmd"`` or ``"alt"`` 323 | subtitle (unicode, optional): Override item subtitle. 324 | arg (unicode, optional): Input for following action. 325 | valid (bool, optional): Override item validity. 326 | icon (unicode, optional): Filepath/UTI of icon. 327 | icontype (unicode, optional): Type of icon. See 328 | :meth:`Workflow.add_item() ` 329 | for valid values. 330 | 331 | Returns: 332 | Modifier: Configured :class:`Modifier`. 333 | 334 | """ 335 | mod = Modifier(key, subtitle, arg, valid, icon, icontype) 336 | 337 | # Add Item variables to Modifier 338 | mod.variables.update(self.variables) 339 | 340 | self.modifiers[key] = mod 341 | 342 | return mod 343 | 344 | @property 345 | def obj(self): 346 | """Item formatted for JSON serialization. 347 | 348 | Returns: 349 | dict: Data suitable for Alfred 3 feedback. 350 | 351 | """ 352 | # Required values 353 | o = { 354 | 'title': self.title, 355 | 'subtitle': self.subtitle, 356 | 'valid': self.valid, 357 | } 358 | 359 | # Optional values 360 | if self.arg is not None: 361 | o['arg'] = self.arg 362 | 363 | if self.autocomplete is not None: 364 | o['autocomplete'] = self.autocomplete 365 | 366 | if self.match is not None: 367 | o['match'] = self.match 368 | 369 | if self.uid is not None: 370 | o['uid'] = self.uid 371 | 372 | if self.type is not None: 373 | o['type'] = self.type 374 | 375 | if self.quicklookurl is not None: 376 | o['quicklookurl'] = self.quicklookurl 377 | 378 | if self.variables: 379 | o['variables'] = self.variables 380 | 381 | if self.config: 382 | o['config'] = self.config 383 | 384 | # Largetype and copytext 385 | text = self._text() 386 | if text: 387 | o['text'] = text 388 | 389 | icon = self._icon() 390 | if icon: 391 | o['icon'] = icon 392 | 393 | # Modifiers 394 | mods = self._modifiers() 395 | if mods: 396 | o['mods'] = mods 397 | 398 | return o 399 | 400 | def _icon(self): 401 | """Return `icon` object for item. 402 | 403 | Returns: 404 | dict: Mapping for item `icon` (may be empty). 405 | 406 | """ 407 | icon = {} 408 | if self.icon is not None: 409 | icon['path'] = self.icon 410 | 411 | if self.icontype is not None: 412 | icon['type'] = self.icontype 413 | 414 | return icon 415 | 416 | def _text(self): 417 | """Return `largetext` and `copytext` object for item. 418 | 419 | Returns: 420 | dict: `text` mapping (may be empty) 421 | 422 | """ 423 | text = {} 424 | if self.largetext is not None: 425 | text['largetype'] = self.largetext 426 | 427 | if self.copytext is not None: 428 | text['copy'] = self.copytext 429 | 430 | return text 431 | 432 | def _modifiers(self): 433 | """Build `mods` dictionary for JSON feedback. 434 | 435 | Returns: 436 | dict: Modifier mapping or `None`. 437 | 438 | """ 439 | if self.modifiers: 440 | mods = {} 441 | for k, mod in self.modifiers.items(): 442 | mods[k] = mod.obj 443 | 444 | return mods 445 | 446 | return None 447 | 448 | 449 | class Workflow3(Workflow): 450 | """Workflow class that generates Alfred 3+ feedback. 451 | 452 | It is a subclass of :class:`~workflow.Workflow` and most of its 453 | methods are documented there. 454 | 455 | Attributes: 456 | item_class (class): Class used to generate feedback items. 457 | variables (dict): Top level workflow variables. 458 | 459 | """ 460 | 461 | item_class = Item3 462 | 463 | def __init__(self, **kwargs): 464 | """Create a new :class:`Workflow3` object. 465 | 466 | See :class:`~workflow.Workflow` for documentation. 467 | 468 | """ 469 | Workflow.__init__(self, **kwargs) 470 | self.variables = {} 471 | self._rerun = 0 472 | # Get session ID from environment if present 473 | self._session_id = os.getenv('_WF_SESSION_ID') or None 474 | if self._session_id: 475 | self.setvar('_WF_SESSION_ID', self._session_id) 476 | 477 | @property 478 | def _default_cachedir(self): 479 | """Alfred 4's default cache directory.""" 480 | return os.path.join( 481 | os.path.expanduser( 482 | '~/Library/Caches/com.runningwithcrayons.Alfred/' 483 | 'Workflow Data/'), 484 | self.bundleid) 485 | 486 | @property 487 | def _default_datadir(self): 488 | """Alfred 4's default data directory.""" 489 | return os.path.join(os.path.expanduser( 490 | '~/Library/Application Support/Alfred/Workflow Data/'), 491 | self.bundleid) 492 | 493 | @property 494 | def rerun(self): 495 | """How often (in seconds) Alfred should re-run the Script Filter.""" 496 | return self._rerun 497 | 498 | @rerun.setter 499 | def rerun(self, seconds): 500 | """Interval at which Alfred should re-run the Script Filter. 501 | 502 | Args: 503 | seconds (int): Interval between runs. 504 | """ 505 | self._rerun = seconds 506 | 507 | @property 508 | def session_id(self): 509 | """A unique session ID every time the user uses the workflow. 510 | 511 | .. versionadded:: 1.25 512 | 513 | The session ID persists while the user is using this workflow. 514 | It expires when the user runs a different workflow or closes 515 | Alfred. 516 | 517 | """ 518 | if not self._session_id: 519 | from uuid import uuid4 520 | self._session_id = uuid4().hex 521 | self.setvar('_WF_SESSION_ID', self._session_id) 522 | 523 | return self._session_id 524 | 525 | def setvar(self, name, value, persist=False): 526 | """Set a "global" workflow variable. 527 | 528 | .. versionchanged:: 1.33 529 | 530 | These variables are always passed to downstream workflow objects. 531 | 532 | If you have set :attr:`rerun`, these variables are also passed 533 | back to the script when Alfred runs it again. 534 | 535 | Args: 536 | name (unicode): Name of variable. 537 | value (unicode): Value of variable. 538 | persist (bool, optional): Also save variable to ``info.plist``? 539 | 540 | """ 541 | self.variables[name] = value 542 | if persist: 543 | from .util import set_config 544 | set_config(name, value, self.bundleid) 545 | self.logger.debug('saved variable %r with value %r to info.plist', 546 | name, value) 547 | 548 | def getvar(self, name, default=None): 549 | """Return value of workflow variable for ``name`` or ``default``. 550 | 551 | Args: 552 | name (unicode): Variable name. 553 | default (None, optional): Value to return if variable is unset. 554 | 555 | Returns: 556 | unicode or ``default``: Value of variable if set or ``default``. 557 | 558 | """ 559 | return self.variables.get(name, default) 560 | 561 | def add_item(self, title, subtitle='', arg=None, autocomplete=None, 562 | valid=False, uid=None, icon=None, icontype=None, type=None, 563 | largetext=None, copytext=None, quicklookurl=None, match=None): 564 | """Add an item to be output to Alfred. 565 | 566 | Args: 567 | match (unicode, optional): If you have "Alfred filters results" 568 | turned on for your Script Filter, Alfred (version 3.5 and 569 | above) will filter against this field, not ``title``. 570 | 571 | See :meth:`Workflow.add_item() ` for 572 | the main documentation and other parameters. 573 | 574 | The key difference is that this method does not support the 575 | ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()` 576 | method instead on the returned item instead. 577 | 578 | Returns: 579 | Item3: Alfred feedback item. 580 | 581 | """ 582 | item = self.item_class(title, subtitle, arg, autocomplete, 583 | match, valid, uid, icon, icontype, type, 584 | largetext, copytext, quicklookurl) 585 | 586 | # Add variables to child item 587 | item.variables.update(self.variables) 588 | 589 | self._items.append(item) 590 | return item 591 | 592 | @property 593 | def _session_prefix(self): 594 | """Filename prefix for current session.""" 595 | return '_wfsess-{0}-'.format(self.session_id) 596 | 597 | def _mk_session_name(self, name): 598 | """New cache name/key based on session ID.""" 599 | return self._session_prefix + name 600 | 601 | def cache_data(self, name, data, session=False): 602 | """Cache API with session-scoped expiry. 603 | 604 | .. versionadded:: 1.25 605 | 606 | Args: 607 | name (str): Cache key 608 | data (object): Data to cache 609 | session (bool, optional): Whether to scope the cache 610 | to the current session. 611 | 612 | ``name`` and ``data`` are the same as for the 613 | :meth:`~workflow.Workflow.cache_data` method on 614 | :class:`~workflow.Workflow`. 615 | 616 | If ``session`` is ``True``, then ``name`` is prefixed 617 | with :attr:`session_id`. 618 | 619 | """ 620 | if session: 621 | name = self._mk_session_name(name) 622 | 623 | return super(Workflow3, self).cache_data(name, data) 624 | 625 | def cached_data(self, name, data_func=None, max_age=60, session=False): 626 | """Cache API with session-scoped expiry. 627 | 628 | .. versionadded:: 1.25 629 | 630 | Args: 631 | name (str): Cache key 632 | data_func (callable): Callable that returns fresh data. It 633 | is called if the cache has expired or doesn't exist. 634 | max_age (int): Maximum allowable age of cache in seconds. 635 | session (bool, optional): Whether to scope the cache 636 | to the current session. 637 | 638 | ``name``, ``data_func`` and ``max_age`` are the same as for the 639 | :meth:`~workflow.Workflow.cached_data` method on 640 | :class:`~workflow.Workflow`. 641 | 642 | If ``session`` is ``True``, then ``name`` is prefixed 643 | with :attr:`session_id`. 644 | 645 | """ 646 | if session: 647 | name = self._mk_session_name(name) 648 | 649 | return super(Workflow3, self).cached_data(name, data_func, max_age) 650 | 651 | def clear_session_cache(self, current=False): 652 | """Remove session data from the cache. 653 | 654 | .. versionadded:: 1.25 655 | .. versionchanged:: 1.27 656 | 657 | By default, data belonging to the current session won't be 658 | deleted. Set ``current=True`` to also clear current session. 659 | 660 | Args: 661 | current (bool, optional): If ``True``, also remove data for 662 | current session. 663 | 664 | """ 665 | def _is_session_file(filename): 666 | if current: 667 | return filename.startswith('_wfsess-') 668 | return filename.startswith('_wfsess-') \ 669 | and not filename.startswith(self._session_prefix) 670 | 671 | self.clear_cache(_is_session_file) 672 | 673 | @property 674 | def obj(self): 675 | """Feedback formatted for JSON serialization. 676 | 677 | Returns: 678 | dict: Data suitable for Alfred 3 feedback. 679 | 680 | """ 681 | items = [] 682 | for item in self._items: 683 | items.append(item.obj) 684 | 685 | o = {'items': items} 686 | if self.variables: 687 | o['variables'] = self.variables 688 | if self.rerun: 689 | o['rerun'] = self.rerun 690 | return o 691 | 692 | def warn_empty(self, title, subtitle=u'', icon=None): 693 | """Add a warning to feedback if there are no items. 694 | 695 | .. versionadded:: 1.31 696 | 697 | Add a "warning" item to Alfred feedback if no other items 698 | have been added. This is a handy shortcut to prevent Alfred 699 | from showing its fallback searches, which is does if no 700 | items are returned. 701 | 702 | Args: 703 | title (unicode): Title of feedback item. 704 | subtitle (unicode, optional): Subtitle of feedback item. 705 | icon (str, optional): Icon for feedback item. If not 706 | specified, ``ICON_WARNING`` is used. 707 | 708 | Returns: 709 | Item3: Newly-created item. 710 | 711 | """ 712 | if len(self._items): 713 | return 714 | 715 | icon = icon or ICON_WARNING 716 | return self.add_item(title, subtitle, icon=icon) 717 | 718 | def send_feedback(self): 719 | """Print stored items to console/Alfred as JSON.""" 720 | json.dump(self.obj, sys.stdout) 721 | sys.stdout.flush() 722 | -------------------------------------------------------------------------------- /workflow/workflow3.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevin-funderburg/alfred-search-keyboard-maestro-vars/a996043d8ed6076b10f245a5747d0b921c11dae7/workflow/workflow3.pyc --------------------------------------------------------------------------------