├── .gitignore ├── LICENSE ├── Linggle.alfredworkflow ├── README.md ├── snapshot1.png ├── snapshot2.png └── src ├── 0B3C3531-021F-4008-9760-04C866481BE1.png ├── Alfred_Workflow-1.40.0.dist-info ├── INSTALLER ├── METADATA ├── RECORD ├── REQUESTED ├── WHEEL └── top_level.txt ├── icon.png ├── info.plist ├── linggle.py ├── linggle_example.py └── workflow ├── .alfredversionchecked ├── Notify.tgz ├── __init__.py ├── background.py ├── notify.py ├── update.py ├── util.py ├── version ├── web.py ├── workflow.py └── workflow3.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /Linggle.alfredworkflow: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SXKDZ/linggle_alfred/8d3ad8664c92c010be39a4b6d2f34c0707318b65/Linggle.alfredworkflow -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Linggle for Alfred 2 | 3 | An Alfred 3 workflow for seaching collocations in [Linggle](https://linggle.com)! 4 | 5 | ## Requirements 6 | 7 | - Alfred >= 3.8 8 | - Python 2.x 9 | - `requests` 10 | 11 | ## Quick Start 12 | 13 | - Toggle by `lin `. 14 | - Press `↩` to copy the results. 15 | 16 | ## Snapshots 17 | 18 | ![](snapshot1.png) 19 | 20 | ![](snapshot2.png) -------------------------------------------------------------------------------- /snapshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SXKDZ/linggle_alfred/8d3ad8664c92c010be39a4b6d2f34c0707318b65/snapshot1.png -------------------------------------------------------------------------------- /snapshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SXKDZ/linggle_alfred/8d3ad8664c92c010be39a4b6d2f34c0707318b65/snapshot2.png -------------------------------------------------------------------------------- /src/0B3C3531-021F-4008-9760-04C866481BE1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SXKDZ/linggle_alfred/8d3ad8664c92c010be39a4b6d2f34c0707318b65/src/0B3C3531-021F-4008-9760-04C866481BE1.png -------------------------------------------------------------------------------- /src/Alfred_Workflow-1.40.0.dist-info/INSTALLER: -------------------------------------------------------------------------------- 1 | pip 2 | -------------------------------------------------------------------------------- /src/Alfred_Workflow-1.40.0.dist-info/METADATA: -------------------------------------------------------------------------------- 1 | Metadata-Version: 2.1 2 | Name: Alfred-Workflow 3 | Version: 1.40.0 4 | Summary: Full-featured helper library for writing Alfred 2/3/4 workflows 5 | Home-page: http://www.deanishe.net/alfred-workflow/ 6 | Author: Dean Jackson 7 | Author-email: deanishe@deanishe.net 8 | License: UNKNOWN 9 | Keywords: alfred workflow alfred4 10 | Platform: UNKNOWN 11 | Classifier: Development Status :: 5 - Production/Stable 12 | Classifier: License :: OSI Approved :: MIT License 13 | Classifier: Operating System :: MacOS :: MacOS X 14 | Classifier: Intended Audience :: Developers 15 | Classifier: Natural Language :: English 16 | Classifier: Programming Language :: Python :: 2.7 17 | Classifier: Topic :: Software Development :: Libraries 18 | Classifier: Topic :: Software Development :: Libraries :: Application Frameworks 19 | 20 | A helper library for writing `Alfred 2, 3 and 4`_ workflows. 21 | 22 | Supports macOS 10.7+ and Python 2.7 (Alfred 3 is 10.9+/2.7 only). 23 | 24 | Alfred-Workflow is designed to take the grunt work out of writing a workflow. 25 | 26 | It gives you the tools to create a fast and featureful Alfred workflow from an 27 | API, application or library in minutes. 28 | 29 | http://www.deanishe.net/alfred-workflow/ 30 | 31 | 32 | Features 33 | ======== 34 | 35 | * Catches and logs workflow errors for easier development and support 36 | * "Magic" arguments to help development/debugging 37 | * Auto-saves settings 38 | * Super-simple data caching 39 | * Fuzzy, Alfred-like search/filtering with diacritic folding 40 | * Keychain support for secure storage (and syncing) of passwords, API keys etc. 41 | * Simple generation of Alfred feedback (XML output) 42 | * Input/output decoding for handling non-ASCII text 43 | * Lightweight web API with modelled on `requests`_ 44 | * Pre-configured logging 45 | * Painlessly add directories to ``sys.path`` 46 | * Easily launch background tasks (daemons) to keep your workflow responsive 47 | * Check for new versions and update workflows hosted on GitHub. 48 | * Post notifications via Notification Center. 49 | 50 | 51 | Alfred 3-only features 52 | ---------------------- 53 | 54 | * Set `workflow variables`_ from code 55 | * Advanced modifiers 56 | * Alfred 3-only updates (won't break Alfred 2 installs) 57 | * Re-running Script Filters 58 | 59 | 60 | Quick Example 61 | ============= 62 | 63 | Here's how to show recent `Pinboard.in `_ posts 64 | in Alfred. 65 | 66 | Create a new workflow in Alfred's preferences. Add a **Script Filter** with 67 | Language ``/usr/bin/python`` and paste the following into the **Script** 68 | field (changing ``API_KEY``): 69 | 70 | 71 | .. code-block:: python 72 | 73 | import sys 74 | from workflow import Workflow, ICON_WEB, web 75 | 76 | API_KEY = 'your-pinboard-api-key' 77 | 78 | def main(wf): 79 | url = 'https://api.pinboard.in/v1/posts/recent' 80 | params = dict(auth_token=API_KEY, count=20, format='json') 81 | r = web.get(url, params) 82 | r.raise_for_status() 83 | for post in r.json()['posts']: 84 | wf.add_item(post['description'], post['href'], arg=post['href'], 85 | uid=post['hash'], valid=True, icon=ICON_WEB) 86 | wf.send_feedback() 87 | 88 | 89 | if __name__ == u"__main__": 90 | wf = Workflow() 91 | sys.exit(wf.run(main)) 92 | 93 | 94 | Add an **Open URL** action to your workflow with ``{query}`` as the **URL**, 95 | connect your **Script Filter** to it, and you can now hit **ENTER** on a 96 | Pinboard item in Alfred to open it in your browser. 97 | 98 | 99 | Installation 100 | ============ 101 | 102 | **Note**: If you intend to distribute your workflow to other users, you 103 | should include Alfred-Workflow (and other Python libraries your workflow 104 | requires) within your workflow's directory as described below. **Do not** 105 | ask users to install anything into their system Python. Python installations 106 | cannot support multiple versions of the same library, so if you rely on 107 | globally-installed libraries, the chances are very good that your workflow 108 | will sooner or later break—or be broken by—some other software doing the 109 | same naughty thing. 110 | 111 | 112 | With pip 113 | -------- 114 | 115 | You can install Alfred-Workflow directly into your workflow with:: 116 | 117 | # from within your workflow directory 118 | pip install --target=. Alfred-Workflow 119 | 120 | You can install any other library available on the `Cheese Shop`_ the 121 | same way. See the `pip documentation`_ for more information. 122 | 123 | 124 | From source 125 | ----------- 126 | 127 | Download the ``alfred-workflow-X.X.X.zip`` file from the `GitHub releases`_ 128 | page and extract the ZIP to the root directory of your workflow (where 129 | ``info.plist`` is). 130 | 131 | Alternatively, you can download `the source code`_ from the 132 | `GitHub repository`_ and copy the ``workflow`` subfolder to the root 133 | directory of your workflow. 134 | 135 | Your workflow directory should look something like this (where 136 | ``yourscript.py`` contains your workflow code and ``info.plist`` is 137 | the workflow information file generated by Alfred):: 138 | 139 | Your Workflow/ 140 | info.plist 141 | icon.png 142 | workflow/ 143 | __init__.py 144 | background.py 145 | notify.py 146 | Notify.tgz 147 | update.py 148 | version 149 | web.py 150 | workflow.py 151 | yourscript.py 152 | etc. 153 | 154 | 155 | Documentation 156 | ============= 157 | 158 | Detailed documentation, including a tutorial, is available at 159 | http://www.deanishe.net/alfred-workflow/. 160 | 161 | .. _v2 branch: https://github.com/deanishe/alfred-workflow/tree/v2 162 | .. _requests: http://docs.python-requests.org/en/latest/ 163 | .. _Alfred 2, 3 and 4: http://www.alfredapp.com/ 164 | .. _GitHub releases: https://github.com/deanishe/alfred-workflow/releases 165 | .. _the source code: https://github.com/deanishe/alfred-workflow/archive/master.zip 166 | .. _GitHub repository: https://github.com/deanishe/alfred-workflow 167 | .. _Cheese Shop: https://pypi.python.org/pypi 168 | .. _pip documentation: https://pip.pypa.io/en/latest/ 169 | .. _workflow variables: http://www.deanishe.net/alfred-workflow/user-manual/workflow-variables.html 170 | 171 | 172 | -------------------------------------------------------------------------------- /src/Alfred_Workflow-1.40.0.dist-info/RECORD: -------------------------------------------------------------------------------- 1 | Alfred_Workflow-1.40.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 2 | Alfred_Workflow-1.40.0.dist-info/METADATA,sha256=DOq1DTBb8GmWrOOo5OJf_G1IqswdOEM0hCjlhi7ZxQ0,5609 3 | Alfred_Workflow-1.40.0.dist-info/RECORD,, 4 | Alfred_Workflow-1.40.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 5 | Alfred_Workflow-1.40.0.dist-info/WHEEL,sha256=pqI-DBMA-Z6OTNov1nVxs7mwm6Yj2kHZGNp_6krVn1E,92 6 | Alfred_Workflow-1.40.0.dist-info/top_level.txt,sha256=jT-znOUjxvwdr-w5ECrvROWZ9y_Doiz0yVYSI0VxpXA,9 7 | workflow/Notify.tgz,sha256=dfcN09jNo0maLZLIZDSsBDouynsjgtDMSnSL3UfFcRE,35556 8 | workflow/__init__.py,sha256=Ae2f8xQxpZE3ijEYgSNir8h-XW04_sNUxkY3vmplOcQ,2068 9 | workflow/__init__.pyc,, 10 | workflow/background.py,sha256=DxSQ3NSJADuW94BWiykKJbcWywhWKdyjSfT4HczhiCw,7532 11 | workflow/background.pyc,, 12 | workflow/notify.py,sha256=OuD5wDd0qwxcH2hLD6RoqFZyG4I-yrMvoaUmtrkQ-to,9670 13 | workflow/notify.pyc,, 14 | workflow/update.py,sha256=0n4Yvfiin4AMQuP2orzDZ31SB0ZGJ2l0CA2DISdnt4k,16133 15 | workflow/update.pyc,, 16 | workflow/util.py,sha256=QE3MJOj8Cj7LzB2gHXNZI2HqQ13vivU4wY_FkHpk3sc,18256 17 | workflow/util.pyc,, 18 | workflow/version,sha256=_sWfmyrEjikcQfQVyndvj90fs4KQPeqGIb8e85nzj1c,6 19 | workflow/web.py,sha256=TG_Sv0RJYBlyTRLG9BNjnExg2YeQMnz8JwFteNq2ksU,22093 20 | workflow/web.pyc,, 21 | workflow/workflow.py,sha256=oFxsLKK0E9L6ZM9RRgPa-86zVgBy2HXuPcWZffTM6JY,92565 22 | workflow/workflow.pyc,, 23 | workflow/workflow3.py,sha256=_Gm3IjLDp82YRJiIq1mDnXNT4lWPvAX02_h9wARzJ-8,21854 24 | workflow/workflow3.pyc,, 25 | -------------------------------------------------------------------------------- /src/Alfred_Workflow-1.40.0.dist-info/REQUESTED: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SXKDZ/linggle_alfred/8d3ad8664c92c010be39a4b6d2f34c0707318b65/src/Alfred_Workflow-1.40.0.dist-info/REQUESTED -------------------------------------------------------------------------------- /src/Alfred_Workflow-1.40.0.dist-info/WHEEL: -------------------------------------------------------------------------------- 1 | Wheel-Version: 1.0 2 | Generator: bdist_wheel (0.33.1) 3 | Root-Is-Purelib: true 4 | Tag: py2-none-any 5 | 6 | -------------------------------------------------------------------------------- /src/Alfred_Workflow-1.40.0.dist-info/top_level.txt: -------------------------------------------------------------------------------- 1 | workflow 2 | -------------------------------------------------------------------------------- /src/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SXKDZ/linggle_alfred/8d3ad8664c92c010be39a4b6d2f34c0707318b65/src/icon.png -------------------------------------------------------------------------------- /src/info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | 7 | category 8 | Tools 9 | connections 10 | 11 | 0B3C3531-021F-4008-9760-04C866481BE1 12 | 13 | 14 | destinationuid 15 | 4C7BCA08-E83D-46CD-A019-6B547E9E188D 16 | modifiers 17 | 0 18 | modifiersubtext 19 | 20 | vitoclose 21 | 22 | 23 | 24 | 4C7BCA08-E83D-46CD-A019-6B547E9E188D 25 | 26 | 27 | destinationuid 28 | A3349239-1B3D-4251-B06B-FA08DC93D821 29 | modifiers 30 | 0 31 | modifiersubtext 32 | 33 | sourceoutputuid 34 | BE62F4C5-342A-401E-BC91-755F2084CE3C 35 | vitoclose 36 | 37 | 38 | 39 | destinationuid 40 | E9359454-1A8F-475D-AF7B-66EAB5CBAAB0 41 | modifiers 42 | 0 43 | modifiersubtext 44 | 45 | vitoclose 46 | 47 | 48 | 49 | 5E1FACD5-59E8-4FB2-9969-7DC53462AF90 50 | 51 | E9359454-1A8F-475D-AF7B-66EAB5CBAAB0 52 | 53 | 54 | destinationuid 55 | 5E1FACD5-59E8-4FB2-9969-7DC53462AF90 56 | modifiers 57 | 0 58 | modifiersubtext 59 | 60 | vitoclose 61 | 62 | 63 | 64 | 65 | createdby 66 | SXKDZ 67 | description 68 | 69 | disabled 70 | 71 | name 72 | Linggle 73 | objects 74 | 75 | 76 | config 77 | 78 | browser 79 | 80 | spaces 81 | 82 | url 83 | {query} 84 | utf8 85 | 86 | 87 | type 88 | alfred.workflow.action.openurl 89 | uid 90 | A3349239-1B3D-4251-B06B-FA08DC93D821 91 | version 92 | 1 93 | 94 | 95 | config 96 | 97 | alfredfiltersresults 98 | 99 | alfredfiltersresultsmatchmode 100 | 0 101 | argumenttreatemptyqueryasnil 102 | 103 | argumenttrimmode 104 | 0 105 | argumenttype 106 | 0 107 | escaping 108 | 102 109 | keyword 110 | lin 111 | queuedelaycustom 112 | 3 113 | queuedelayimmediatelyinitially 114 | 115 | queuedelaymode 116 | 0 117 | queuemode 118 | 1 119 | runningsubtext 120 | Searching... 121 | script 122 | python linggle.py "{query}" 123 | scriptargtype 124 | 0 125 | scriptfile 126 | 127 | subtext 128 | * word | _ words | ? necessity | / substitution | n. etc. POS 129 | title 130 | Search Collocations in Linggle 10^12 131 | type 132 | 0 133 | withspace 134 | 135 | 136 | type 137 | alfred.workflow.input.scriptfilter 138 | uid 139 | 0B3C3531-021F-4008-9760-04C866481BE1 140 | version 141 | 3 142 | 143 | 144 | config 145 | 146 | conditions 147 | 148 | 149 | inputstring 150 | 151 | matchcasesensitive 152 | 153 | matchmode 154 | 4 155 | matchstring 156 | ^https:\/\/linggle\.com\/\?q=.+ 157 | outputlabel 158 | 159 | uid 160 | BE62F4C5-342A-401E-BC91-755F2084CE3C 161 | 162 | 163 | elselabel 164 | else 165 | 166 | type 167 | alfred.workflow.utility.conditional 168 | uid 169 | 4C7BCA08-E83D-46CD-A019-6B547E9E188D 170 | version 171 | 1 172 | 173 | 174 | config 175 | 176 | lastpathcomponent 177 | 178 | onlyshowifquerypopulated 179 | 180 | removeextension 181 | 182 | text 183 | {query} 184 | title 185 | Copied to Clipboard 186 | 187 | type 188 | alfred.workflow.output.notification 189 | uid 190 | 5E1FACD5-59E8-4FB2-9969-7DC53462AF90 191 | version 192 | 1 193 | 194 | 195 | config 196 | 197 | autopaste 198 | 199 | clipboardtext 200 | {query} 201 | ignoredynamicplaceholders 202 | 203 | transient 204 | 205 | 206 | type 207 | alfred.workflow.output.clipboard 208 | uid 209 | E9359454-1A8F-475D-AF7B-66EAB5CBAAB0 210 | version 211 | 3 212 | 213 | 214 | readme 215 | 216 | uidata 217 | 218 | 0B3C3531-021F-4008-9760-04C866481BE1 219 | 220 | xpos 221 | 35 222 | ypos 223 | 150 224 | 225 | 4C7BCA08-E83D-46CD-A019-6B547E9E188D 226 | 227 | xpos 228 | 240 229 | ypos 230 | 170 231 | 232 | 5E1FACD5-59E8-4FB2-9969-7DC53462AF90 233 | 234 | xpos 235 | 565 236 | ypos 237 | 290 238 | 239 | A3349239-1B3D-4251-B06B-FA08DC93D821 240 | 241 | xpos 242 | 370 243 | ypos 244 | 10 245 | 246 | E9359454-1A8F-475D-AF7B-66EAB5CBAAB0 247 | 248 | xpos 249 | 370 250 | ypos 251 | 290 252 | 253 | 254 | variablesdontexport 255 | 256 | version 257 | 258 | webaddress 259 | sxkdz.github.io 260 | 261 | 262 | -------------------------------------------------------------------------------- /src/linggle.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import json 3 | import time 4 | import requests 5 | from workflow import Workflow, ICON_WEB 6 | 7 | def main(wf): 8 | s = requests.Session() 9 | 10 | query = wf.args[0] 11 | query_load = requests.utils.quote(query) 12 | try: 13 | answer = s.get('https://search.linggle.com/api/ngram/{}'.format(query_load)).json() 14 | if len(answer['ngrams']) == 0: 15 | wf.add_item( 16 | title='No Results', 17 | subtitle='Modify your search', 18 | valid=False, 19 | icon='icon.png' 20 | ) 21 | else: 22 | total = 0 23 | for item in answer['ngrams']: 24 | total += item[1] 25 | 26 | for item in answer['ngrams'][:20]: 27 | phrase = item[0] 28 | subtitle = '{:.2f}% | {}'.format(float(item[1]) * 100 / total, item[1]) 29 | wf.add_item( 30 | title=phrase, 31 | subtitle=subtitle, 32 | arg=phrase, 33 | valid=True, 34 | icon='icon.png' 35 | ) 36 | except: 37 | wf.add_item( 38 | title='Inquiry Error', 39 | subtitle='Modify your search', 40 | valid=False, 41 | icon='icon.png' 42 | ) 43 | wf.add_item( 44 | title='Visit Linggle', 45 | subtitle='Open browser for Linggle', 46 | icon=ICON_WEB, 47 | valid=True, 48 | arg='https://search.linggle.com/?q={}'.format(query_load) 49 | ) 50 | wf.send_feedback() 51 | 52 | if __name__ == '__main__': 53 | wf = Workflow() 54 | sys.exit(wf.run(main)) 55 | -------------------------------------------------------------------------------- /src/linggle_example.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | import requests 4 | from workflow import Workflow, ICON_WEB 5 | 6 | 7 | def strip_html(html): 8 | p = re.compile(r'<.*?>') 9 | return p.sub('', html) 10 | 11 | 12 | def main(wf): 13 | s = requests.Session() 14 | 15 | query = wf.args[0] 16 | query_load = requests.utils.quote(query) 17 | payload = {'q': query, 'maxResults': 20} 18 | 19 | proxies = { 20 | 'http': 'http://127.0.0.1:1087', 21 | 'https': 'http://127.0.0.1:1087', 22 | } 23 | 24 | try: 25 | answer = s.get('https://www.googleapis.com/books/v1/volumes', params=payload, proxies=proxies).json() 26 | if int(answer['totalItems']) == 0: 27 | wf.add_item( 28 | title='No Examples', 29 | subtitle='Try another search', 30 | valid=False, 31 | icon='icon.png' 32 | ) 33 | else: 34 | for item in answer['items']: 35 | try: 36 | phrase = item['searchInfo']['textSnippet'] 37 | phrase = strip_html(phrase) 38 | wf.add_item( 39 | title=phrase, 40 | arg=phrase, 41 | valid=True, 42 | icon='icon.png' 43 | ) 44 | except KeyError: 45 | pass 46 | 47 | except Exception as e: 48 | wf.add_item( 49 | title='Inquiry Error: {}'.format(e), 50 | subtitle='Modify your search', 51 | valid=False, 52 | icon='icon.png' 53 | ) 54 | wf.add_item( 55 | title='Visit Linggle', 56 | subtitle='Open browser for Linggle', 57 | icon=ICON_WEB, 58 | valid=True, 59 | arg='https://linggle.com/?q={}'.format(query_load) 60 | ) 61 | wf.send_feedback() 62 | 63 | if __name__ == '__main__': 64 | wf = Workflow() 65 | sys.exit(wf.run(main)) 66 | -------------------------------------------------------------------------------- /src/workflow/.alfredversionchecked: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SXKDZ/linggle_alfred/8d3ad8664c92c010be39a4b6d2f34c0707318b65/src/workflow/.alfredversionchecked -------------------------------------------------------------------------------- /src/workflow/Notify.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SXKDZ/linggle_alfred/8d3ad8664c92c010be39a4b6d2f34c0707318b65/src/workflow/Notify.tgz -------------------------------------------------------------------------------- /src/workflow/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 Dean Jackson 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2014-02-15 9 | # 10 | 11 | """A helper library for `Alfred `_ workflows.""" 12 | 13 | import os 14 | 15 | # Workflow objects 16 | from .workflow import Workflow, manager 17 | from .workflow3 import Variables, Workflow3 18 | 19 | # Exceptions 20 | from .workflow import PasswordNotFound, KeychainError 21 | 22 | # Icons 23 | from .workflow import ( 24 | ICON_ACCOUNT, 25 | ICON_BURN, 26 | ICON_CLOCK, 27 | ICON_COLOR, 28 | ICON_COLOUR, 29 | ICON_EJECT, 30 | ICON_ERROR, 31 | ICON_FAVORITE, 32 | ICON_FAVOURITE, 33 | ICON_GROUP, 34 | ICON_HELP, 35 | ICON_HOME, 36 | ICON_INFO, 37 | ICON_NETWORK, 38 | ICON_NOTE, 39 | ICON_SETTINGS, 40 | ICON_SWIRL, 41 | ICON_SWITCH, 42 | ICON_SYNC, 43 | ICON_TRASH, 44 | ICON_USER, 45 | ICON_WARNING, 46 | ICON_WEB, 47 | ) 48 | 49 | # Filter matching rules 50 | from .workflow import ( 51 | MATCH_ALL, 52 | MATCH_ALLCHARS, 53 | MATCH_ATOM, 54 | MATCH_CAPITALS, 55 | MATCH_INITIALS, 56 | MATCH_INITIALS_CONTAIN, 57 | MATCH_INITIALS_STARTSWITH, 58 | MATCH_STARTSWITH, 59 | MATCH_SUBSTRING, 60 | ) 61 | 62 | 63 | __title__ = 'Alfred-Workflow' 64 | __version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read() 65 | __author__ = 'Dean Jackson' 66 | __licence__ = 'MIT' 67 | __copyright__ = 'Copyright 2014-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 | -------------------------------------------------------------------------------- /src/workflow/background.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 deanishe@deanishe.net 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2014-04-06 9 | # 10 | 11 | """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 | os.unlink(pidfile) 106 | 107 | 108 | def is_running(name): 109 | """Test whether task ``name`` is currently running. 110 | 111 | :param name: name of task 112 | :type name: unicode 113 | :returns: ``True`` if task with name ``name`` is running, else ``False`` 114 | :rtype: bool 115 | 116 | """ 117 | if _job_pid(name) is not None: 118 | return True 119 | 120 | return False 121 | 122 | 123 | def _background(pidfile, stdin='/dev/null', stdout='/dev/null', 124 | stderr='/dev/null'): # pragma: no cover 125 | """Fork the current process into a background daemon. 126 | 127 | :param pidfile: file to write PID of daemon process to. 128 | :type pidfile: filepath 129 | :param stdin: where to read input 130 | :type stdin: filepath 131 | :param stdout: where to write stdout output 132 | :type stdout: filepath 133 | :param stderr: where to write stderr output 134 | :type stderr: filepath 135 | 136 | """ 137 | def _fork_and_exit_parent(errmsg, wait=False, write=False): 138 | try: 139 | pid = os.fork() 140 | if pid > 0: 141 | if write: # write PID of child process to `pidfile` 142 | tmp = pidfile + '.tmp' 143 | with open(tmp, 'wb') as fp: 144 | fp.write(str(pid)) 145 | os.rename(tmp, pidfile) 146 | if wait: # wait for child process to exit 147 | os.waitpid(pid, 0) 148 | os._exit(0) 149 | except OSError as err: 150 | _log().critical('%s: (%d) %s', errmsg, err.errno, err.strerror) 151 | raise err 152 | 153 | # Do first fork and wait for second fork to finish. 154 | _fork_and_exit_parent('fork #1 failed', wait=True) 155 | 156 | # Decouple from parent environment. 157 | os.chdir(wf().workflowdir) 158 | os.setsid() 159 | 160 | # Do second fork and write PID to pidfile. 161 | _fork_and_exit_parent('fork #2 failed', write=True) 162 | 163 | # Now I am a daemon! 164 | # Redirect standard file descriptors. 165 | si = open(stdin, 'r', 0) 166 | so = open(stdout, 'a+', 0) 167 | se = open(stderr, 'a+', 0) 168 | if hasattr(sys.stdin, 'fileno'): 169 | os.dup2(si.fileno(), sys.stdin.fileno()) 170 | if hasattr(sys.stdout, 'fileno'): 171 | os.dup2(so.fileno(), sys.stdout.fileno()) 172 | if hasattr(sys.stderr, 'fileno'): 173 | os.dup2(se.fileno(), sys.stderr.fileno()) 174 | 175 | 176 | def kill(name, sig=signal.SIGTERM): 177 | """Send a signal to job ``name`` via :func:`os.kill`. 178 | 179 | .. versionadded:: 1.29 180 | 181 | Args: 182 | name (str): Name of the job 183 | sig (int, optional): Signal to send (default: SIGTERM) 184 | 185 | Returns: 186 | bool: `False` if job isn't running, `True` if signal was sent. 187 | """ 188 | pid = _job_pid(name) 189 | if pid is None: 190 | return False 191 | 192 | os.kill(pid, sig) 193 | return True 194 | 195 | 196 | def run_in_background(name, args, **kwargs): 197 | r"""Cache arguments then call this script again via :func:`subprocess.call`. 198 | 199 | :param name: name of job 200 | :type name: unicode 201 | :param args: arguments passed as first argument to :func:`subprocess.call` 202 | :param \**kwargs: keyword arguments to :func:`subprocess.call` 203 | :returns: exit code of sub-process 204 | :rtype: int 205 | 206 | When you call this function, it caches its arguments and then calls 207 | ``background.py`` in a subprocess. The Python subprocess will load the 208 | cached arguments, fork into the background, and then run the command you 209 | specified. 210 | 211 | This function will return as soon as the ``background.py`` subprocess has 212 | forked, returning the exit code of *that* process (i.e. not of the command 213 | you're trying to run). 214 | 215 | If that process fails, an error will be written to the log file. 216 | 217 | If a process is already running under the same name, this function will 218 | return immediately and will not run the specified command. 219 | 220 | """ 221 | if is_running(name): 222 | _log().info('[%s] job already running', name) 223 | return 224 | 225 | argcache = _arg_cache(name) 226 | 227 | # Cache arguments 228 | with open(argcache, 'wb') as fp: 229 | pickle.dump({'args': args, 'kwargs': kwargs}, fp) 230 | _log().debug('[%s] command cached: %s', name, argcache) 231 | 232 | # Call this script 233 | cmd = ['/usr/bin/python', __file__, name] 234 | _log().debug('[%s] passing job to background runner: %r', name, cmd) 235 | retcode = subprocess.call(cmd) 236 | 237 | if retcode: # pragma: no cover 238 | _log().error('[%s] background runner failed with %d', name, retcode) 239 | else: 240 | _log().debug('[%s] background job started', name) 241 | 242 | return retcode 243 | 244 | 245 | def main(wf): # pragma: no cover 246 | """Run command in a background process. 247 | 248 | Load cached arguments, fork into background, then call 249 | :meth:`subprocess.call` with cached arguments. 250 | 251 | """ 252 | log = wf.logger 253 | name = wf.args[0] 254 | argcache = _arg_cache(name) 255 | if not os.path.exists(argcache): 256 | msg = '[{0}] command cache not found: {1}'.format(name, argcache) 257 | log.critical(msg) 258 | raise IOError(msg) 259 | 260 | # Fork to background and run command 261 | pidfile = _pid_file(name) 262 | _background(pidfile) 263 | 264 | # Load cached arguments 265 | with open(argcache, 'rb') as fp: 266 | data = pickle.load(fp) 267 | 268 | # Cached arguments 269 | args = data['args'] 270 | kwargs = data['kwargs'] 271 | 272 | # Delete argument cache file 273 | os.unlink(argcache) 274 | 275 | try: 276 | # Run the command 277 | log.debug('[%s] running command: %r', name, args) 278 | 279 | retcode = subprocess.call(args, **kwargs) 280 | 281 | if retcode: 282 | log.error('[%s] command failed with status %d', name, retcode) 283 | finally: 284 | os.unlink(pidfile) 285 | 286 | log.debug('[%s] job complete', name) 287 | 288 | 289 | if __name__ == '__main__': # pragma: no cover 290 | wf().run(main) 291 | -------------------------------------------------------------------------------- /src/workflow/notify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2015 deanishe@deanishe.net 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2015-11-26 9 | # 10 | 11 | # TODO: Exclude this module from test and code coverage in py2.6 12 | 13 | """ 14 | Post notifications via the macOS Notification Center. 15 | 16 | This feature is only available on Mountain Lion (10.8) and later. 17 | It will silently fail on older systems. 18 | 19 | The main API is a single function, :func:`~workflow.notify.notify`. 20 | 21 | It works by copying a simple application to your workflow's data 22 | directory. It replaces the application's icon with your workflow's 23 | icon and then calls the application to post notifications. 24 | """ 25 | 26 | 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 | if not os.path.exists(n): # pragma: nocover 121 | raise RuntimeError('Notify.app could not be installed in ' + 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 | if os.path.exists(iconset): # pragma: nocover 257 | raise RuntimeError('iconset already exists: ' + iconset) 258 | 259 | os.makedirs(iconset) 260 | 261 | # Copy source icon to icon set and generate all the other 262 | # sizes needed 263 | configs = [] 264 | for i in (16, 32, 128, 256, 512): 265 | configs.append(('icon_{0}x{0}.png'.format(i), i)) 266 | configs.append((('icon_{0}x{0}@2x.png'.format(i), i * 2))) 267 | 268 | shutil.copy(png_path, os.path.join(iconset, 'icon_256x256.png')) 269 | shutil.copy(png_path, os.path.join(iconset, 'icon_128x128@2x.png')) 270 | 271 | for name, size in configs: 272 | outpath = os.path.join(iconset, name) 273 | if os.path.exists(outpath): 274 | continue 275 | convert_image(png_path, outpath, size) 276 | 277 | cmd = [ 278 | b'iconutil', 279 | b'-c', b'icns', 280 | b'-o', icns_path, 281 | iconset] 282 | 283 | retcode = subprocess.call(cmd) 284 | if retcode != 0: 285 | raise RuntimeError('iconset exited with %d' % retcode) 286 | 287 | if not os.path.exists(icns_path): # pragma: nocover 288 | raise ValueError( 289 | 'generated ICNS file not found: ' + repr(icns_path)) 290 | finally: 291 | try: 292 | shutil.rmtree(tempdir) 293 | except OSError: # pragma: no cover 294 | pass 295 | 296 | 297 | if __name__ == '__main__': # pragma: nocover 298 | # Simple command-line script to test module with 299 | # This won't work on 2.6, as `argparse` isn't available 300 | # by default. 301 | import argparse 302 | 303 | from unicodedata import normalize 304 | 305 | def ustr(s): 306 | """Coerce `s` to normalised Unicode.""" 307 | return normalize('NFD', s.decode('utf-8')) 308 | 309 | p = argparse.ArgumentParser() 310 | p.add_argument('-p', '--png', help="PNG image to convert to ICNS.") 311 | p.add_argument('-l', '--list-sounds', help="Show available sounds.", 312 | action='store_true') 313 | p.add_argument('-t', '--title', 314 | help="Notification title.", type=ustr, 315 | default='') 316 | p.add_argument('-s', '--sound', type=ustr, 317 | help="Optional notification sound.", default='') 318 | p.add_argument('text', type=ustr, 319 | help="Notification body text.", default='', nargs='?') 320 | o = p.parse_args() 321 | 322 | # List available sounds 323 | if o.list_sounds: 324 | for sound in SOUNDS: 325 | print(sound) 326 | sys.exit(0) 327 | 328 | # Convert PNG to ICNS 329 | if o.png: 330 | icns = os.path.join( 331 | os.path.dirname(o.png), 332 | os.path.splitext(os.path.basename(o.png))[0] + '.icns') 333 | 334 | print('converting {0!r} to {1!r} ...'.format(o.png, icns), 335 | file=sys.stderr) 336 | 337 | if os.path.exists(icns): 338 | raise ValueError('destination file already exists: ' + icns) 339 | 340 | png_to_icns(o.png, icns) 341 | sys.exit(0) 342 | 343 | # Post notification 344 | if o.title == o.text == '': 345 | print('ERROR: empty notification.', file=sys.stderr) 346 | sys.exit(1) 347 | else: 348 | notify(o.title, o.text, o.sound) 349 | -------------------------------------------------------------------------------- /src/workflow/update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2014 Fabio Niephaus , 5 | # Dean Jackson 6 | # 7 | # MIT Licence. See http://opensource.org/licenses/MIT 8 | # 9 | # Created on 2014-08-16 10 | # 11 | 12 | """Self-updating from GitHub. 13 | 14 | .. versionadded:: 1.9 15 | 16 | .. note:: 17 | 18 | This module is not intended to be used directly. Automatic updates 19 | are controlled by the ``update_settings`` :class:`dict` passed to 20 | :class:`~workflow.workflow.Workflow` objects. 21 | 22 | """ 23 | 24 | from __future__ import print_function, unicode_literals 25 | 26 | 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][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: ' + 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: ' + 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 - : ' + suffix) 272 | self.suffix = suffix[1:] 273 | 274 | def _parse_dotted_string(self, s): 275 | """Parse string ``s`` into list of ints and strings.""" 276 | parsed = [] 277 | parts = s.split('.') 278 | for p in parts: 279 | if p.isdigit(): 280 | p = int(p) 281 | parsed.append(p) 282 | return parsed 283 | 284 | @property 285 | def tuple(self): 286 | """Version number as a tuple of major, minor, patch, pre-release.""" 287 | return (self.major, self.minor, self.patch, self.suffix) 288 | 289 | def __lt__(self, other): 290 | """Implement comparison.""" 291 | if not isinstance(other, Version): 292 | raise ValueError('not a Version instance: {0!r}'.format(other)) 293 | t = self.tuple[:3] 294 | o = other.tuple[:3] 295 | if t < o: 296 | return True 297 | if t == o: # We need to compare suffixes 298 | if self.suffix and not other.suffix: 299 | return True 300 | if other.suffix and not self.suffix: 301 | return False 302 | return self._parse_dotted_string(self.suffix) \ 303 | < self._parse_dotted_string(other.suffix) 304 | # t > o 305 | return False 306 | 307 | def __eq__(self, other): 308 | """Implement comparison.""" 309 | if not isinstance(other, Version): 310 | raise ValueError('not a Version instance: {0!r}'.format(other)) 311 | return self.tuple == other.tuple 312 | 313 | def __ne__(self, other): 314 | """Implement comparison.""" 315 | return not self.__eq__(other) 316 | 317 | def __gt__(self, other): 318 | """Implement comparison.""" 319 | if not isinstance(other, Version): 320 | raise ValueError('not a Version instance: {0!r}'.format(other)) 321 | return other.__lt__(self) 322 | 323 | def __le__(self, other): 324 | """Implement comparison.""" 325 | if not isinstance(other, Version): 326 | raise ValueError('not a Version instance: {0!r}'.format(other)) 327 | return not other.__lt__(self) 328 | 329 | def __ge__(self, other): 330 | """Implement comparison.""" 331 | return not self.__lt__(other) 332 | 333 | def __str__(self): 334 | """Return semantic version string.""" 335 | vstr = '{0}.{1}.{2}'.format(self.major, self.minor, self.patch) 336 | if self.suffix: 337 | vstr = '{0}-{1}'.format(vstr, self.suffix) 338 | if self.build: 339 | vstr = '{0}+{1}'.format(vstr, self.build) 340 | return vstr 341 | 342 | def __repr__(self): 343 | """Return 'code' representation of `Version`.""" 344 | return "Version('{0}')".format(str(self)) 345 | 346 | 347 | def retrieve_download(dl): 348 | """Saves a download to a temporary file and returns path. 349 | 350 | .. versionadded: 1.37 351 | 352 | Args: 353 | url (unicode): URL to .alfredworkflow file in GitHub repo 354 | 355 | Returns: 356 | unicode: path to downloaded file 357 | 358 | """ 359 | if not match_workflow(dl.filename): 360 | raise ValueError('attachment not a workflow: ' + dl.filename) 361 | 362 | path = os.path.join(tempfile.gettempdir(), dl.filename) 363 | wf().logger.debug('downloading update from ' 364 | '%r to %r ...', dl.url, path) 365 | 366 | r = web.get(dl.url) 367 | r.raise_for_status() 368 | 369 | r.save_to_path(path) 370 | 371 | return path 372 | 373 | 374 | def build_api_url(repo): 375 | """Generate releases URL from GitHub repo. 376 | 377 | Args: 378 | repo (unicode): Repo name in form ``username/repo`` 379 | 380 | Returns: 381 | unicode: URL to the API endpoint for the repo's releases 382 | 383 | """ 384 | if len(repo.split('/')) != 2: 385 | raise ValueError('invalid GitHub repo: {!r}'.format(repo)) 386 | 387 | return RELEASES_BASE.format(repo) 388 | 389 | 390 | def get_downloads(repo): 391 | """Load available ``Download``s for GitHub repo. 392 | 393 | .. versionadded: 1.37 394 | 395 | Args: 396 | repo (unicode): GitHub repo to load releases for. 397 | 398 | Returns: 399 | list: Sequence of `Download` contained in GitHub releases. 400 | """ 401 | url = build_api_url(repo) 402 | 403 | def _fetch(): 404 | wf().logger.info('retrieving releases for %r ...', repo) 405 | r = web.get(url) 406 | r.raise_for_status() 407 | return r.content 408 | 409 | key = 'github-releases-' + repo.replace('/', '-') 410 | js = wf().cached_data(key, _fetch, max_age=60) 411 | 412 | return Download.from_releases(js) 413 | 414 | 415 | def latest_download(dls, alfred_version=None, prereleases=False): 416 | """Return newest `Download`.""" 417 | alfred_version = alfred_version or os.getenv('alfred_version') 418 | version = None 419 | if alfred_version: 420 | version = Version(alfred_version) 421 | 422 | dls.sort(reverse=True) 423 | for dl in dls: 424 | if dl.prerelease and not prereleases: 425 | wf().logger.debug('ignored prerelease: %s', dl.version) 426 | continue 427 | if version and dl.alfred_version > version: 428 | wf().logger.debug('ignored incompatible (%s > %s): %s', 429 | dl.alfred_version, version, dl.filename) 430 | continue 431 | 432 | wf().logger.debug('latest version: %s (%s)', dl.version, dl.filename) 433 | return dl 434 | 435 | return None 436 | 437 | 438 | def check_update(repo, current_version, prereleases=False, 439 | alfred_version=None): 440 | """Check whether a newer release is available on GitHub. 441 | 442 | Args: 443 | repo (unicode): ``username/repo`` for workflow's GitHub repo 444 | current_version (unicode): the currently installed version of the 445 | workflow. :ref:`Semantic versioning ` is required. 446 | prereleases (bool): Whether to include pre-releases. 447 | alfred_version (unicode): version of currently-running Alfred. 448 | if empty, defaults to ``$alfred_version`` environment variable. 449 | 450 | Returns: 451 | bool: ``True`` if an update is available, else ``False`` 452 | 453 | If an update is available, its version number and download URL will 454 | be cached. 455 | 456 | """ 457 | key = '__workflow_latest_version' 458 | # data stored when no update is available 459 | no_update = { 460 | 'available': False, 461 | 'download': None, 462 | 'version': None, 463 | } 464 | current = Version(current_version) 465 | 466 | dls = get_downloads(repo) 467 | if not len(dls): 468 | wf().logger.warning('no valid downloads for %s', repo) 469 | wf().cache_data(key, no_update) 470 | return False 471 | 472 | wf().logger.info('%d download(s) for %s', len(dls), repo) 473 | 474 | dl = latest_download(dls, alfred_version, prereleases) 475 | 476 | if not dl: 477 | wf().logger.warning('no compatible downloads for %s', repo) 478 | wf().cache_data(key, no_update) 479 | return False 480 | 481 | wf().logger.debug('latest=%r, installed=%r', dl.version, current) 482 | 483 | if dl.version > current: 484 | wf().cache_data(key, { 485 | 'version': str(dl.version), 486 | 'download': dl.dict, 487 | 'available': True, 488 | }) 489 | return True 490 | 491 | wf().cache_data(key, no_update) 492 | return False 493 | 494 | 495 | def install_update(): 496 | """If a newer release is available, download and install it. 497 | 498 | :returns: ``True`` if an update is installed, else ``False`` 499 | 500 | """ 501 | key = '__workflow_latest_version' 502 | # data stored when no update is available 503 | no_update = { 504 | 'available': False, 505 | 'download': None, 506 | 'version': None, 507 | } 508 | status = wf().cached_data(key, max_age=0) 509 | 510 | if not status or not status.get('available'): 511 | wf().logger.info('no update available') 512 | return False 513 | 514 | dl = status.get('download') 515 | if not dl: 516 | wf().logger.info('no download information') 517 | return False 518 | 519 | path = retrieve_download(Download.from_dict(dl)) 520 | 521 | wf().logger.info('installing updated workflow ...') 522 | subprocess.call(['open', path]) # nosec 523 | 524 | wf().cache_data(key, no_update) 525 | return True 526 | 527 | 528 | if __name__ == '__main__': # pragma: nocover 529 | import sys 530 | 531 | prereleases = False 532 | 533 | def show_help(status=0): 534 | """Print help message.""" 535 | print('usage: update.py (check|install) ' 536 | '[--prereleases] ') 537 | sys.exit(status) 538 | 539 | argv = sys.argv[:] 540 | if '-h' in argv or '--help' in argv: 541 | show_help() 542 | 543 | if '--prereleases' in argv: 544 | argv.remove('--prereleases') 545 | prereleases = True 546 | 547 | if len(argv) != 4: 548 | show_help(1) 549 | 550 | action = argv[1] 551 | repo = argv[2] 552 | version = argv[3] 553 | 554 | try: 555 | 556 | if action == 'check': 557 | check_update(repo, version, prereleases) 558 | elif action == 'install': 559 | install_update() 560 | else: 561 | show_help(1) 562 | 563 | except Exception as err: # ensure traceback is in log file 564 | wf().logger.exception(err) 565 | raise err 566 | -------------------------------------------------------------------------------- /src/workflow/util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | # 4 | # Copyright (c) 2017 Dean Jackson 5 | # 6 | # MIT Licence. See http://opensource.org/licenses/MIT 7 | # 8 | # Created on 2017-12-17 9 | # 10 | 11 | """A selection of helper functions useful for building workflows.""" 12 | 13 | from __future__ import print_function, absolute_import 14 | 15 | import atexit 16 | from collections import namedtuple 17 | from contextlib import contextmanager 18 | import errno 19 | import fcntl 20 | import functools 21 | import 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 | # Tell Alfred to reload a workflow from disk 48 | JXA_RELOAD_WORKFLOW = 'Application({app}).reloadWorkflow({arg});' 49 | 50 | 51 | class AcquisitionError(Exception): 52 | """Raised if a lock cannot be acquired.""" 53 | 54 | 55 | AppInfo = namedtuple('AppInfo', ['name', 'path', 'bundleid']) 56 | """Information about an installed application. 57 | 58 | Returned by :func:`appinfo`. All attributes are Unicode. 59 | 60 | .. py:attribute:: name 61 | 62 | Name of the application, e.g. ``u'Safari'``. 63 | 64 | .. py:attribute:: path 65 | 66 | Path to the application bundle, e.g. ``u'/Applications/Safari.app'``. 67 | 68 | .. py:attribute:: bundleid 69 | 70 | Application's bundle ID, e.g. ``u'com.apple.Safari'``. 71 | 72 | """ 73 | 74 | 75 | def jxa_app_name(): 76 | """Return name of application to call currently running Alfred. 77 | 78 | .. versionadded: 1.37 79 | 80 | Returns 'Alfred 3' or 'com.runningwithcrayons.Alfred' depending 81 | on which version of Alfred is running. 82 | 83 | This name is suitable for use with ``Application(name)`` in JXA. 84 | 85 | Returns: 86 | unicode: Application name or ID. 87 | 88 | """ 89 | if os.getenv('alfred_version', '').startswith('3'): 90 | # Alfred 3 91 | return u'Alfred 3' 92 | # Alfred 4+ 93 | return u'com.runningwithcrayons.Alfred' 94 | 95 | 96 | def unicodify(s, encoding='utf-8', norm=None): 97 | """Ensure string is Unicode. 98 | 99 | .. versionadded:: 1.31 100 | 101 | Decode encoded strings using ``encoding`` and normalise Unicode 102 | to form ``norm`` if specified. 103 | 104 | Args: 105 | s (str): String to decode. May also be Unicode. 106 | encoding (str, optional): Encoding to use on bytestrings. 107 | norm (None, optional): Normalisation form to apply to Unicode string. 108 | 109 | Returns: 110 | unicode: Decoded, optionally normalised, Unicode string. 111 | 112 | """ 113 | if not isinstance(s, unicode): 114 | s = unicode(s, encoding) 115 | 116 | if norm: 117 | from unicodedata import normalize 118 | s = normalize(norm, s) 119 | 120 | return s 121 | 122 | 123 | def utf8ify(s): 124 | """Ensure string is a bytestring. 125 | 126 | .. versionadded:: 1.31 127 | 128 | Returns `str` objects unchanced, encodes `unicode` objects to 129 | UTF-8, and calls :func:`str` on anything else. 130 | 131 | Args: 132 | s (object): A Python object 133 | 134 | Returns: 135 | str: UTF-8 string or string representation of s. 136 | 137 | """ 138 | if isinstance(s, str): 139 | return s 140 | 141 | if isinstance(s, unicode): 142 | return s.encode('utf-8') 143 | 144 | return str(s) 145 | 146 | 147 | def applescriptify(s): 148 | """Escape string for insertion into an AppleScript string. 149 | 150 | .. versionadded:: 1.31 151 | 152 | Replaces ``"`` with `"& quote &"`. Use this function if you want 153 | to insert a string into an AppleScript script: 154 | 155 | >>> applescriptify('g "python" test') 156 | 'g " & quote & "python" & quote & "test' 157 | 158 | Args: 159 | s (unicode): Unicode string to escape. 160 | 161 | Returns: 162 | unicode: Escaped string. 163 | 164 | """ 165 | return s.replace(u'"', u'" & quote & "') 166 | 167 | 168 | def run_command(cmd, **kwargs): 169 | """Run a command and return the output. 170 | 171 | .. versionadded:: 1.31 172 | 173 | A thin wrapper around :func:`subprocess.check_output` that ensures 174 | all arguments are encoded to UTF-8 first. 175 | 176 | Args: 177 | cmd (list): Command arguments to pass to :func:`~subprocess.check_output`. 178 | **kwargs: Keyword arguments to pass to :func:`~subprocess.check_output`. 179 | 180 | Returns: 181 | str: Output returned by :func:`~subprocess.check_output`. 182 | 183 | """ 184 | cmd = [utf8ify(s) for s in cmd] 185 | return subprocess.check_output(cmd, **kwargs) 186 | 187 | 188 | def run_applescript(script, *args, **kwargs): 189 | """Execute an AppleScript script and return its output. 190 | 191 | .. versionadded:: 1.31 192 | 193 | Run AppleScript either by filepath or code. If ``script`` is a valid 194 | filepath, that script will be run, otherwise ``script`` is treated 195 | as code. 196 | 197 | Args: 198 | script (str, optional): Filepath of script or code to run. 199 | *args: Optional command-line arguments to pass to the script. 200 | **kwargs: Pass ``lang`` to run a language other than AppleScript. 201 | Any other keyword arguments are passed to :func:`run_command`. 202 | 203 | Returns: 204 | str: Output of run command. 205 | 206 | """ 207 | lang = 'AppleScript' 208 | if 'lang' in kwargs: 209 | lang = kwargs['lang'] 210 | del kwargs['lang'] 211 | 212 | cmd = ['/usr/bin/osascript', '-l', lang] 213 | 214 | if os.path.exists(script): 215 | cmd += [script] 216 | else: 217 | cmd += ['-e', script] 218 | 219 | cmd.extend(args) 220 | 221 | return run_command(cmd, **kwargs) 222 | 223 | 224 | def run_jxa(script, *args): 225 | """Execute a JXA script and return its output. 226 | 227 | .. versionadded:: 1.31 228 | 229 | Wrapper around :func:`run_applescript` that passes ``lang=JavaScript``. 230 | 231 | Args: 232 | script (str): Filepath of script or code to run. 233 | *args: Optional command-line arguments to pass to script. 234 | 235 | Returns: 236 | str: Output of script. 237 | 238 | """ 239 | return run_applescript(script, *args, lang='JavaScript') 240 | 241 | 242 | def run_trigger(name, bundleid=None, arg=None): 243 | """Call an Alfred External Trigger. 244 | 245 | .. versionadded:: 1.31 246 | 247 | If ``bundleid`` is not specified, the bundle ID of the calling 248 | workflow is used. 249 | 250 | Args: 251 | name (str): Name of External Trigger to call. 252 | bundleid (str, optional): Bundle ID of workflow trigger belongs to. 253 | arg (str, optional): Argument to pass to trigger. 254 | 255 | """ 256 | bundleid = bundleid or os.getenv('alfred_workflow_bundleid') 257 | appname = jxa_app_name() 258 | opts = {'inWorkflow': bundleid} 259 | if arg: 260 | opts['withArgument'] = arg 261 | 262 | script = JXA_TRIGGER.format(app=json.dumps(appname), 263 | arg=json.dumps(name), 264 | opts=json.dumps(opts, sort_keys=True)) 265 | 266 | run_applescript(script, lang='JavaScript') 267 | 268 | 269 | def set_theme(theme_name): 270 | """Change Alfred's theme. 271 | 272 | .. versionadded:: 1.39.0 273 | 274 | Args: 275 | theme_name (unicode): Name of theme Alfred should use. 276 | 277 | """ 278 | appname = jxa_app_name() 279 | script = JXA_SET_THEME.format(app=json.dumps(appname), 280 | arg=json.dumps(theme_name)) 281 | run_applescript(script, lang='JavaScript') 282 | 283 | 284 | def set_config(name, value, bundleid=None, exportable=False): 285 | """Set a workflow variable in ``info.plist``. 286 | 287 | .. versionadded:: 1.33 288 | 289 | If ``bundleid`` is not specified, the bundle ID of the calling 290 | workflow is used. 291 | 292 | Args: 293 | name (str): Name of variable to set. 294 | value (str): Value to set variable to. 295 | bundleid (str, optional): Bundle ID of workflow variable belongs to. 296 | exportable (bool, optional): Whether variable should be marked 297 | as exportable (Don't Export checkbox). 298 | 299 | """ 300 | bundleid = bundleid or os.getenv('alfred_workflow_bundleid') 301 | appname = jxa_app_name() 302 | opts = { 303 | 'toValue': value, 304 | 'inWorkflow': bundleid, 305 | 'exportable': exportable, 306 | } 307 | 308 | script = JXA_SET_CONFIG.format(app=json.dumps(appname), 309 | arg=json.dumps(name), 310 | opts=json.dumps(opts, sort_keys=True)) 311 | 312 | run_applescript(script, lang='JavaScript') 313 | 314 | 315 | def unset_config(name, bundleid=None): 316 | """Delete a workflow variable from ``info.plist``. 317 | 318 | .. versionadded:: 1.33 319 | 320 | If ``bundleid`` is not specified, the bundle ID of the calling 321 | workflow is used. 322 | 323 | Args: 324 | name (str): Name of variable to delete. 325 | bundleid (str, optional): Bundle ID of workflow variable belongs to. 326 | 327 | """ 328 | bundleid = bundleid or os.getenv('alfred_workflow_bundleid') 329 | appname = jxa_app_name() 330 | opts = {'inWorkflow': bundleid} 331 | 332 | script = JXA_UNSET_CONFIG.format(app=json.dumps(appname), 333 | arg=json.dumps(name), 334 | opts=json.dumps(opts, sort_keys=True)) 335 | 336 | run_applescript(script, lang='JavaScript') 337 | 338 | 339 | def search_in_alfred(query=None): 340 | """Open Alfred with given search query. 341 | 342 | .. versionadded:: 1.39.0 343 | 344 | Omit ``query`` to simply open Alfred's main window. 345 | 346 | Args: 347 | query (unicode, optional): Search query. 348 | 349 | """ 350 | query = query or u'' 351 | appname = jxa_app_name() 352 | script = JXA_SEARCH.format(app=json.dumps(appname), arg=json.dumps(query)) 353 | run_applescript(script, lang='JavaScript') 354 | 355 | 356 | def browse_in_alfred(path): 357 | """Open Alfred's filesystem navigation mode at ``path``. 358 | 359 | .. versionadded:: 1.39.0 360 | 361 | Args: 362 | path (unicode): File or directory path. 363 | 364 | """ 365 | appname = jxa_app_name() 366 | script = JXA_BROWSE.format(app=json.dumps(appname), arg=json.dumps(path)) 367 | run_applescript(script, lang='JavaScript') 368 | 369 | 370 | def action_in_alfred(paths): 371 | """Action the give filepaths in Alfred. 372 | 373 | .. versionadded:: 1.39.0 374 | 375 | Args: 376 | paths (list): Unicode paths to files/directories to action. 377 | 378 | """ 379 | appname = jxa_app_name() 380 | script = JXA_ACTION.format(app=json.dumps(appname), arg=json.dumps(paths)) 381 | run_applescript(script, lang='JavaScript') 382 | 383 | 384 | def reload_workflow(bundleid=None): 385 | """Tell Alfred to reload a workflow from disk. 386 | 387 | .. versionadded:: 1.39.0 388 | 389 | If ``bundleid`` is not specified, the bundle ID of the calling 390 | workflow is used. 391 | 392 | Args: 393 | bundleid (unicode, optional): Bundle ID of workflow to reload. 394 | 395 | """ 396 | bundleid = bundleid or os.getenv('alfred_workflow_bundleid') 397 | appname = jxa_app_name() 398 | script = JXA_RELOAD_WORKFLOW.format(app=json.dumps(appname), 399 | arg=json.dumps(bundleid)) 400 | 401 | run_applescript(script, lang='JavaScript') 402 | 403 | 404 | def appinfo(name): 405 | """Get information about an installed application. 406 | 407 | .. versionadded:: 1.31 408 | 409 | Args: 410 | name (str): Name of application to look up. 411 | 412 | Returns: 413 | AppInfo: :class:`AppInfo` tuple or ``None`` if app isn't found. 414 | 415 | """ 416 | cmd = [ 417 | 'mdfind', 418 | '-onlyin', '/Applications', 419 | '-onlyin', '/System/Applications', 420 | '-onlyin', os.path.expanduser('~/Applications'), 421 | '(kMDItemContentTypeTree == com.apple.application &&' 422 | '(kMDItemDisplayName == "{0}" || kMDItemFSName == "{0}.app"))' 423 | .format(name) 424 | ] 425 | 426 | output = run_command(cmd).strip() 427 | if not output: 428 | return None 429 | 430 | path = output.split('\n')[0] 431 | 432 | cmd = ['mdls', '-raw', '-name', 'kMDItemCFBundleIdentifier', path] 433 | bid = run_command(cmd).strip() 434 | if not bid: # pragma: no cover 435 | return None 436 | 437 | return AppInfo(unicodify(name), unicodify(path), unicodify(bid)) 438 | 439 | 440 | @contextmanager 441 | def atomic_writer(fpath, mode): 442 | """Atomic file writer. 443 | 444 | .. versionadded:: 1.12 445 | 446 | Context manager that ensures the file is only written if the write 447 | succeeds. The data is first written to a temporary file. 448 | 449 | :param fpath: path of file to write to. 450 | :type fpath: ``unicode`` 451 | :param mode: sames as for :func:`open` 452 | :type mode: string 453 | 454 | """ 455 | suffix = '.{}.tmp'.format(os.getpid()) 456 | temppath = fpath + suffix 457 | with open(temppath, mode) as fp: 458 | try: 459 | yield fp 460 | os.rename(temppath, fpath) 461 | finally: 462 | try: 463 | os.remove(temppath) 464 | except (OSError, IOError): 465 | pass 466 | 467 | 468 | class LockFile(object): 469 | """Context manager to protect filepaths with lockfiles. 470 | 471 | .. versionadded:: 1.13 472 | 473 | Creates a lockfile alongside ``protected_path``. Other ``LockFile`` 474 | instances will refuse to lock the same path. 475 | 476 | >>> path = '/path/to/file' 477 | >>> with LockFile(path): 478 | >>> with open(path, 'wb') as fp: 479 | >>> fp.write(data) 480 | 481 | Args: 482 | protected_path (unicode): File to protect with a lockfile 483 | timeout (float, optional): Raises an :class:`AcquisitionError` 484 | if lock cannot be acquired within this number of seconds. 485 | If ``timeout`` is 0 (the default), wait forever. 486 | delay (float, optional): How often to check (in seconds) if 487 | lock has been released. 488 | 489 | Attributes: 490 | delay (float): How often to check (in seconds) whether the lock 491 | can be acquired. 492 | lockfile (unicode): Path of the lockfile. 493 | timeout (float): How long to wait to acquire the lock. 494 | 495 | """ 496 | 497 | def __init__(self, protected_path, timeout=0.0, delay=0.05): 498 | """Create new :class:`LockFile` object.""" 499 | self.lockfile = protected_path + '.lock' 500 | self._lockfile = None 501 | self.timeout = timeout 502 | self.delay = delay 503 | self._lock = Event() 504 | atexit.register(self.release) 505 | 506 | @property 507 | def locked(self): 508 | """``True`` if file is locked by this instance.""" 509 | return self._lock.is_set() 510 | 511 | def acquire(self, blocking=True): 512 | """Acquire the lock if possible. 513 | 514 | If the lock is in use and ``blocking`` is ``False``, return 515 | ``False``. 516 | 517 | Otherwise, check every :attr:`delay` seconds until it acquires 518 | lock or exceeds attr:`timeout` and raises an :class:`AcquisitionError`. 519 | 520 | """ 521 | if self.locked and not blocking: 522 | return False 523 | 524 | start = time.time() 525 | while True: 526 | # Raise error if we've been waiting too long to acquire the lock 527 | if self.timeout and (time.time() - start) >= self.timeout: 528 | raise AcquisitionError('lock acquisition timed out') 529 | 530 | # If already locked, wait then try again 531 | if self.locked: 532 | time.sleep(self.delay) 533 | continue 534 | 535 | # Create in append mode so we don't lose any contents 536 | if self._lockfile is None: 537 | self._lockfile = open(self.lockfile, 'a') 538 | 539 | # Try to acquire the lock 540 | try: 541 | fcntl.lockf(self._lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB) 542 | self._lock.set() 543 | break 544 | except IOError as err: # pragma: no cover 545 | if err.errno not in (errno.EACCES, errno.EAGAIN): 546 | raise 547 | 548 | # Don't try again 549 | if not blocking: # pragma: no cover 550 | return False 551 | 552 | # Wait, then try again 553 | time.sleep(self.delay) 554 | 555 | return True 556 | 557 | def release(self): 558 | """Release the lock by deleting `self.lockfile`.""" 559 | if not self._lock.is_set(): 560 | return False 561 | 562 | try: 563 | fcntl.lockf(self._lockfile, fcntl.LOCK_UN) 564 | except IOError: # pragma: no cover 565 | pass 566 | finally: 567 | self._lock.clear() 568 | self._lockfile = None 569 | try: 570 | os.unlink(self.lockfile) 571 | except (IOError, OSError): # pragma: no cover 572 | pass 573 | 574 | return True 575 | 576 | def __enter__(self): 577 | """Acquire lock.""" 578 | self.acquire() 579 | return self 580 | 581 | def __exit__(self, typ, value, traceback): 582 | """Release lock.""" 583 | self.release() 584 | 585 | def __del__(self): 586 | """Clear up `self.lockfile`.""" 587 | self.release() # pragma: no cover 588 | 589 | 590 | class uninterruptible(object): 591 | """Decorator that postpones SIGTERM until wrapped function returns. 592 | 593 | .. versionadded:: 1.12 594 | 595 | .. important:: This decorator is NOT thread-safe. 596 | 597 | As of version 2.7, Alfred allows Script Filters to be killed. If 598 | your workflow is killed in the middle of critical code (e.g. 599 | writing data to disk), this may corrupt your workflow's data. 600 | 601 | Use this decorator to wrap critical functions that *must* complete. 602 | If the script is killed while a wrapped function is executing, 603 | the SIGTERM will be caught and handled after your function has 604 | finished executing. 605 | 606 | Alfred-Workflow uses this internally to ensure its settings, data 607 | and cache writes complete. 608 | 609 | """ 610 | 611 | def __init__(self, func, class_name=''): 612 | """Decorate `func`.""" 613 | self.func = func 614 | functools.update_wrapper(self, func) 615 | self._caught_signal = None 616 | 617 | def signal_handler(self, signum, frame): 618 | """Called when process receives SIGTERM.""" 619 | self._caught_signal = (signum, frame) 620 | 621 | def __call__(self, *args, **kwargs): 622 | """Trap ``SIGTERM`` and call wrapped function.""" 623 | self._caught_signal = None 624 | # Register handler for SIGTERM, then call `self.func` 625 | self.old_signal_handler = signal.getsignal(signal.SIGTERM) 626 | signal.signal(signal.SIGTERM, self.signal_handler) 627 | 628 | self.func(*args, **kwargs) 629 | 630 | # Restore old signal handler 631 | signal.signal(signal.SIGTERM, self.old_signal_handler) 632 | 633 | # Handle any signal caught during execution 634 | if self._caught_signal is not None: 635 | signum, frame = self._caught_signal 636 | if callable(self.old_signal_handler): 637 | self.old_signal_handler(signum, frame) 638 | elif self.old_signal_handler == signal.SIG_DFL: 639 | sys.exit(0) 640 | 641 | def __get__(self, obj=None, klass=None): 642 | """Decorator API.""" 643 | return self.__class__(self.func.__get__(obj, klass), 644 | klass.__name__) 645 | -------------------------------------------------------------------------------- /src/workflow/version: -------------------------------------------------------------------------------- 1 | 1.40.0 -------------------------------------------------------------------------------- /src/workflow/web.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2014 Dean Jackson 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2014-02-15 8 | # 9 | 10 | """Lightweight HTTP library with a requests-like interface.""" 11 | 12 | from __future__ import absolute_import, print_function 13 | 14 | import codecs 15 | import json 16 | import mimetypes 17 | import os 18 | import random 19 | import re 20 | import socket 21 | import string 22 | import unicodedata 23 | import urllib 24 | import urllib2 25 | import urlparse 26 | import zlib 27 | 28 | __version__ = open(os.path.join(os.path.dirname(__file__), 'version')).read() 29 | 30 | USER_AGENT = (u'Alfred-Workflow/' + __version__ + 31 | ' (+http://www.deanishe.net/alfred-workflow)') 32 | 33 | # Valid characters for multipart form data boundaries 34 | BOUNDARY_CHARS = string.digits + string.ascii_letters 35 | 36 | # HTTP response codes 37 | RESPONSES = { 38 | 100: 'Continue', 39 | 101: 'Switching Protocols', 40 | 200: 'OK', 41 | 201: 'Created', 42 | 202: 'Accepted', 43 | 203: 'Non-Authoritative Information', 44 | 204: 'No Content', 45 | 205: 'Reset Content', 46 | 206: 'Partial Content', 47 | 300: 'Multiple Choices', 48 | 301: 'Moved Permanently', 49 | 302: 'Found', 50 | 303: 'See Other', 51 | 304: 'Not Modified', 52 | 305: 'Use Proxy', 53 | 307: 'Temporary Redirect', 54 | 400: 'Bad Request', 55 | 401: 'Unauthorized', 56 | 402: 'Payment Required', 57 | 403: 'Forbidden', 58 | 404: 'Not Found', 59 | 405: 'Method Not Allowed', 60 | 406: 'Not Acceptable', 61 | 407: 'Proxy Authentication Required', 62 | 408: 'Request Timeout', 63 | 409: 'Conflict', 64 | 410: 'Gone', 65 | 411: 'Length Required', 66 | 412: 'Precondition Failed', 67 | 413: 'Request Entity Too Large', 68 | 414: 'Request-URI Too Long', 69 | 415: 'Unsupported Media Type', 70 | 416: 'Requested Range Not Satisfiable', 71 | 417: 'Expectation Failed', 72 | 500: 'Internal Server Error', 73 | 501: 'Not Implemented', 74 | 502: 'Bad Gateway', 75 | 503: 'Service Unavailable', 76 | 504: 'Gateway Timeout', 77 | 505: 'HTTP Version Not Supported' 78 | } 79 | 80 | 81 | def str_dict(dic): 82 | """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`. 83 | 84 | :param dic: Mapping of Unicode strings 85 | :type dic: dict 86 | :returns: Dictionary containing only UTF-8 strings 87 | :rtype: dict 88 | 89 | """ 90 | if isinstance(dic, CaseInsensitiveDictionary): 91 | dic2 = CaseInsensitiveDictionary() 92 | else: 93 | dic2 = {} 94 | for k, v in dic.items(): 95 | if isinstance(k, unicode): 96 | k = k.encode('utf-8') 97 | if isinstance(v, unicode): 98 | v = v.encode('utf-8') 99 | dic2[k] = v 100 | return dic2 101 | 102 | 103 | class NoRedirectHandler(urllib2.HTTPRedirectHandler): 104 | """Prevent redirections.""" 105 | 106 | def redirect_request(self, *args): 107 | """Ignore redirect.""" 108 | return None 109 | 110 | 111 | # Adapted from https://gist.github.com/babakness/3901174 112 | class CaseInsensitiveDictionary(dict): 113 | """Dictionary with caseless key search. 114 | 115 | Enables case insensitive searching while preserving case sensitivity 116 | when keys are listed, ie, via keys() or items() methods. 117 | 118 | Works by storing a lowercase version of the key as the new key and 119 | stores the original key-value pair as the key's value 120 | (values become dictionaries). 121 | 122 | """ 123 | 124 | def __init__(self, initval=None): 125 | """Create new case-insensitive dictionary.""" 126 | if isinstance(initval, dict): 127 | for key, value in initval.iteritems(): 128 | self.__setitem__(key, value) 129 | 130 | elif isinstance(initval, list): 131 | for (key, value) in initval: 132 | self.__setitem__(key, value) 133 | 134 | def __contains__(self, key): 135 | return dict.__contains__(self, key.lower()) 136 | 137 | def __getitem__(self, key): 138 | return dict.__getitem__(self, key.lower())['val'] 139 | 140 | def __setitem__(self, key, value): 141 | return dict.__setitem__(self, key.lower(), {'key': key, 'val': value}) 142 | 143 | def get(self, key, default=None): 144 | """Return value for case-insensitive key or default.""" 145 | try: 146 | v = dict.__getitem__(self, key.lower()) 147 | except KeyError: 148 | return default 149 | else: 150 | return v['val'] 151 | 152 | def update(self, other): 153 | """Update values from other ``dict``.""" 154 | for k, v in other.items(): 155 | self[k] = v 156 | 157 | def items(self): 158 | """Return ``(key, value)`` pairs.""" 159 | return [(v['key'], v['val']) for v in dict.itervalues(self)] 160 | 161 | def keys(self): 162 | """Return original keys.""" 163 | return [v['key'] for v in dict.itervalues(self)] 164 | 165 | def values(self): 166 | """Return all values.""" 167 | return [v['val'] for v in dict.itervalues(self)] 168 | 169 | def iteritems(self): 170 | """Iterate over ``(key, value)`` pairs.""" 171 | for v in dict.itervalues(self): 172 | yield v['key'], v['val'] 173 | 174 | def iterkeys(self): 175 | """Iterate over original keys.""" 176 | for v in dict.itervalues(self): 177 | yield v['key'] 178 | 179 | def itervalues(self): 180 | """Interate over values.""" 181 | for v in dict.itervalues(self): 182 | yield v['val'] 183 | 184 | 185 | class Request(urllib2.Request): 186 | """Subclass of :class:`urllib2.Request` that supports custom methods.""" 187 | 188 | def __init__(self, *args, **kwargs): 189 | """Create a new :class:`Request`.""" 190 | self._method = kwargs.pop('method', None) 191 | urllib2.Request.__init__(self, *args, **kwargs) 192 | 193 | def get_method(self): 194 | return self._method.upper() 195 | 196 | 197 | class Response(object): 198 | """ 199 | Returned by :func:`request` / :func:`get` / :func:`post` functions. 200 | 201 | Simplified version of the ``Response`` object in the ``requests`` library. 202 | 203 | >>> r = request('http://www.google.com') 204 | >>> r.status_code 205 | 200 206 | >>> r.encoding 207 | ISO-8859-1 208 | >>> r.content # bytes 209 | ... 210 | >>> r.text # unicode, decoded according to charset in HTTP header/meta tag 211 | u' ...' 212 | >>> r.json() # content parsed as JSON 213 | 214 | """ 215 | 216 | def __init__(self, request, stream=False): 217 | """Call `request` with :mod:`urllib2` and process results. 218 | 219 | :param request: :class:`Request` instance 220 | :param stream: Whether to stream response or retrieve it all at once 221 | :type stream: bool 222 | 223 | """ 224 | self.request = request 225 | self._stream = stream 226 | self.url = None 227 | self.raw = None 228 | self._encoding = None 229 | self.error = None 230 | self.status_code = None 231 | self.reason = None 232 | self.headers = CaseInsensitiveDictionary() 233 | self._content = None 234 | self._content_loaded = False 235 | self._gzipped = False 236 | 237 | # Execute query 238 | try: 239 | self.raw = urllib2.urlopen(request) 240 | except urllib2.HTTPError as err: 241 | self.error = err 242 | try: 243 | self.url = err.geturl() 244 | # sometimes (e.g. when authentication fails) 245 | # urllib can't get a URL from an HTTPError 246 | # This behaviour changes across Python versions, 247 | # so no test cover (it isn't important). 248 | except AttributeError: # pragma: no cover 249 | pass 250 | self.status_code = err.code 251 | else: 252 | self.status_code = self.raw.getcode() 253 | self.url = self.raw.geturl() 254 | self.reason = RESPONSES.get(self.status_code) 255 | 256 | # Parse additional info if request succeeded 257 | if not self.error: 258 | headers = self.raw.info() 259 | self.transfer_encoding = headers.getencoding() 260 | self.mimetype = headers.gettype() 261 | for key in headers.keys(): 262 | self.headers[key.lower()] = headers.get(key) 263 | 264 | # Is content gzipped? 265 | # Transfer-Encoding appears to not be used in the wild 266 | # (contrary to the HTTP standard), but no harm in testing 267 | # for it 268 | if 'gzip' in headers.get('content-encoding', '') or \ 269 | 'gzip' in headers.get('transfer-encoding', ''): 270 | self._gzipped = True 271 | 272 | @property 273 | def stream(self): 274 | """Whether response is streamed. 275 | 276 | Returns: 277 | bool: `True` if response is streamed. 278 | 279 | """ 280 | return self._stream 281 | 282 | @stream.setter 283 | def stream(self, value): 284 | if self._content_loaded: 285 | raise RuntimeError("`content` has already been read from " 286 | "this Response.") 287 | 288 | self._stream = value 289 | 290 | def json(self): 291 | """Decode response contents as JSON. 292 | 293 | :returns: object decoded from JSON 294 | :rtype: list, dict or unicode 295 | 296 | """ 297 | return json.loads(self.content, self.encoding or 'utf-8') 298 | 299 | @property 300 | def encoding(self): 301 | """Text encoding of document or ``None``. 302 | 303 | :returns: Text encoding if found. 304 | :rtype: str or ``None`` 305 | 306 | """ 307 | if not self._encoding: 308 | self._encoding = self._get_encoding() 309 | 310 | return self._encoding 311 | 312 | @property 313 | def content(self): 314 | """Raw content of response (i.e. bytes). 315 | 316 | :returns: Body of HTTP response 317 | :rtype: str 318 | 319 | """ 320 | if not self._content: 321 | 322 | # Decompress gzipped content 323 | if self._gzipped: 324 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) 325 | self._content = decoder.decompress(self.raw.read()) 326 | 327 | else: 328 | self._content = self.raw.read() 329 | 330 | self._content_loaded = True 331 | 332 | return self._content 333 | 334 | @property 335 | def text(self): 336 | """Unicode-decoded content of response body. 337 | 338 | If no encoding can be determined from HTTP headers or the content 339 | itself, the encoded response body will be returned instead. 340 | 341 | :returns: Body of HTTP response 342 | :rtype: unicode or str 343 | 344 | """ 345 | if self.encoding: 346 | return unicodedata.normalize('NFC', unicode(self.content, 347 | self.encoding)) 348 | return self.content 349 | 350 | def iter_content(self, chunk_size=4096, decode_unicode=False): 351 | """Iterate over response data. 352 | 353 | .. versionadded:: 1.6 354 | 355 | :param chunk_size: Number of bytes to read into memory 356 | :type chunk_size: int 357 | :param decode_unicode: Decode to Unicode using detected encoding 358 | :type decode_unicode: bool 359 | :returns: iterator 360 | 361 | """ 362 | if not self.stream: 363 | raise RuntimeError("You cannot call `iter_content` on a " 364 | "Response unless you passed `stream=True`" 365 | " to `get()`/`post()`/`request()`.") 366 | 367 | if self._content_loaded: 368 | raise RuntimeError( 369 | "`content` has already been read from this Response.") 370 | 371 | def decode_stream(iterator, r): 372 | dec = codecs.getincrementaldecoder(r.encoding)(errors='replace') 373 | 374 | for chunk in iterator: 375 | data = dec.decode(chunk) 376 | if data: 377 | yield data 378 | 379 | data = dec.decode(b'', final=True) 380 | if data: # pragma: no cover 381 | yield data 382 | 383 | def generate(): 384 | if self._gzipped: 385 | decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) 386 | 387 | while True: 388 | chunk = self.raw.read(chunk_size) 389 | if not chunk: 390 | break 391 | 392 | if self._gzipped: 393 | chunk = decoder.decompress(chunk) 394 | 395 | yield chunk 396 | 397 | chunks = generate() 398 | 399 | if decode_unicode and self.encoding: 400 | chunks = decode_stream(chunks, self) 401 | 402 | return chunks 403 | 404 | def save_to_path(self, filepath): 405 | """Save retrieved data to file at ``filepath``. 406 | 407 | .. versionadded: 1.9.6 408 | 409 | :param filepath: Path to save retrieved data. 410 | 411 | """ 412 | filepath = os.path.abspath(filepath) 413 | dirname = os.path.dirname(filepath) 414 | if not os.path.exists(dirname): 415 | os.makedirs(dirname) 416 | 417 | self.stream = True 418 | 419 | with open(filepath, 'wb') as fileobj: 420 | for data in self.iter_content(): 421 | fileobj.write(data) 422 | 423 | def raise_for_status(self): 424 | """Raise stored error if one occurred. 425 | 426 | error will be instance of :class:`urllib2.HTTPError` 427 | """ 428 | if self.error is not None: 429 | raise self.error 430 | return 431 | 432 | def _get_encoding(self): 433 | """Get encoding from HTTP headers or content. 434 | 435 | :returns: encoding or `None` 436 | :rtype: unicode or ``None`` 437 | 438 | """ 439 | headers = self.raw.info() 440 | encoding = None 441 | 442 | if headers.getparam('charset'): 443 | encoding = headers.getparam('charset') 444 | 445 | # HTTP Content-Type header 446 | for param in headers.getplist(): 447 | if param.startswith('charset='): 448 | encoding = param[8:] 449 | break 450 | 451 | if not self.stream: # Try sniffing response content 452 | # Encoding declared in document should override HTTP headers 453 | if self.mimetype == 'text/html': # sniff HTML headers 454 | m = re.search(r"""""", 455 | self.content) 456 | if m: 457 | encoding = m.group(1) 458 | 459 | elif ((self.mimetype.startswith('application/') 460 | or self.mimetype.startswith('text/')) 461 | and 'xml' in self.mimetype): 462 | m = re.search(r"""]*\?>""", 463 | self.content) 464 | if m: 465 | encoding = m.group(1) 466 | 467 | # Format defaults 468 | if self.mimetype == 'application/json' and not encoding: 469 | # The default encoding for JSON 470 | encoding = 'utf-8' 471 | 472 | elif self.mimetype == 'application/xml' and not encoding: 473 | # The default for 'application/xml' 474 | encoding = 'utf-8' 475 | 476 | if encoding: 477 | encoding = encoding.lower() 478 | 479 | return encoding 480 | 481 | 482 | def request(method, url, params=None, data=None, headers=None, cookies=None, 483 | files=None, auth=None, timeout=60, allow_redirects=False, 484 | stream=False): 485 | """Initiate an HTTP(S) request. Returns :class:`Response` object. 486 | 487 | :param method: 'GET' or 'POST' 488 | :type method: unicode 489 | :param url: URL to open 490 | :type url: unicode 491 | :param params: mapping of URL parameters 492 | :type params: dict 493 | :param data: mapping of form data ``{'field_name': 'value'}`` or 494 | :class:`str` 495 | :type data: dict or str 496 | :param headers: HTTP headers 497 | :type headers: dict 498 | :param cookies: cookies to send to server 499 | :type cookies: dict 500 | :param files: files to upload (see below). 501 | :type files: dict 502 | :param auth: username, password 503 | :type auth: tuple 504 | :param timeout: connection timeout limit in seconds 505 | :type timeout: int 506 | :param allow_redirects: follow redirections 507 | :type allow_redirects: bool 508 | :param stream: Stream content instead of fetching it all at once. 509 | :type stream: bool 510 | :returns: Response object 511 | :rtype: :class:`Response` 512 | 513 | 514 | The ``files`` argument is a dictionary:: 515 | 516 | {'fieldname' : { 'filename': 'blah.txt', 517 | 'content': '', 518 | 'mimetype': 'text/plain'} 519 | } 520 | 521 | * ``fieldname`` is the name of the field in the HTML form. 522 | * ``mimetype`` is optional. If not provided, :mod:`mimetypes` will 523 | be used to guess the mimetype, or ``application/octet-stream`` 524 | will be used. 525 | 526 | """ 527 | # TODO: cookies 528 | socket.setdefaulttimeout(timeout) 529 | 530 | # Default handlers 531 | openers = [urllib2.ProxyHandler(urllib2.getproxies())] 532 | 533 | if not allow_redirects: 534 | openers.append(NoRedirectHandler()) 535 | 536 | if auth is not None: # Add authorisation handler 537 | username, password = auth 538 | password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm() 539 | password_manager.add_password(None, url, username, password) 540 | auth_manager = urllib2.HTTPBasicAuthHandler(password_manager) 541 | openers.append(auth_manager) 542 | 543 | # Install our custom chain of openers 544 | opener = urllib2.build_opener(*openers) 545 | urllib2.install_opener(opener) 546 | 547 | if not headers: 548 | headers = CaseInsensitiveDictionary() 549 | else: 550 | headers = CaseInsensitiveDictionary(headers) 551 | 552 | if 'user-agent' not in headers: 553 | headers['user-agent'] = USER_AGENT 554 | 555 | # Accept gzip-encoded content 556 | encodings = [s.strip() for s in 557 | headers.get('accept-encoding', '').split(',')] 558 | if 'gzip' not in encodings: 559 | encodings.append('gzip') 560 | 561 | headers['accept-encoding'] = ', '.join(encodings) 562 | 563 | if files: 564 | if not data: 565 | data = {} 566 | new_headers, data = encode_multipart_formdata(data, files) 567 | headers.update(new_headers) 568 | elif data and isinstance(data, dict): 569 | data = urllib.urlencode(str_dict(data)) 570 | 571 | # Make sure everything is encoded text 572 | headers = str_dict(headers) 573 | 574 | if isinstance(url, unicode): 575 | url = url.encode('utf-8') 576 | 577 | if params: # GET args (POST args are handled in encode_multipart_formdata) 578 | 579 | scheme, netloc, path, query, fragment = urlparse.urlsplit(url) 580 | 581 | if query: # Combine query string and `params` 582 | url_params = urlparse.parse_qs(query) 583 | # `params` take precedence over URL query string 584 | url_params.update(params) 585 | params = url_params 586 | 587 | query = urllib.urlencode(str_dict(params), doseq=True) 588 | url = urlparse.urlunsplit((scheme, netloc, path, query, fragment)) 589 | 590 | req = Request(url, data, headers, method=method) 591 | return Response(req, stream) 592 | 593 | 594 | def get(url, params=None, headers=None, cookies=None, auth=None, 595 | timeout=60, allow_redirects=True, stream=False): 596 | """Initiate a GET request. Arguments as for :func:`request`. 597 | 598 | :returns: :class:`Response` instance 599 | 600 | """ 601 | return request('GET', url, params, headers=headers, cookies=cookies, 602 | auth=auth, timeout=timeout, allow_redirects=allow_redirects, 603 | stream=stream) 604 | 605 | 606 | def delete(url, params=None, data=None, headers=None, cookies=None, auth=None, 607 | timeout=60, allow_redirects=True, stream=False): 608 | """Initiate a DELETE request. Arguments as for :func:`request`. 609 | 610 | :returns: :class:`Response` instance 611 | 612 | """ 613 | return request('DELETE', url, params, data, headers=headers, 614 | cookies=cookies, auth=auth, timeout=timeout, 615 | allow_redirects=allow_redirects, stream=stream) 616 | 617 | 618 | def post(url, params=None, data=None, headers=None, cookies=None, files=None, 619 | auth=None, timeout=60, allow_redirects=False, stream=False): 620 | """Initiate a POST request. Arguments as for :func:`request`. 621 | 622 | :returns: :class:`Response` instance 623 | 624 | """ 625 | return request('POST', url, params, data, headers, cookies, files, auth, 626 | timeout, allow_redirects, stream) 627 | 628 | 629 | def put(url, params=None, data=None, headers=None, cookies=None, files=None, 630 | auth=None, timeout=60, allow_redirects=False, stream=False): 631 | """Initiate a PUT request. Arguments as for :func:`request`. 632 | 633 | :returns: :class:`Response` instance 634 | 635 | """ 636 | return request('PUT', url, params, data, headers, cookies, files, auth, 637 | timeout, allow_redirects, stream) 638 | 639 | 640 | def encode_multipart_formdata(fields, files): 641 | """Encode form data (``fields``) and ``files`` for POST request. 642 | 643 | :param fields: mapping of ``{name : value}`` pairs for normal form fields. 644 | :type fields: dict 645 | :param files: dictionary of fieldnames/files elements for file data. 646 | See below for details. 647 | :type files: dict of :class:`dict` 648 | :returns: ``(headers, body)`` ``headers`` is a 649 | :class:`dict` of HTTP headers 650 | :rtype: 2-tuple ``(dict, str)`` 651 | 652 | The ``files`` argument is a dictionary:: 653 | 654 | {'fieldname' : { 'filename': 'blah.txt', 655 | 'content': '', 656 | 'mimetype': 'text/plain'} 657 | } 658 | 659 | - ``fieldname`` is the name of the field in the HTML form. 660 | - ``mimetype`` is optional. If not provided, :mod:`mimetypes` will 661 | be used to guess the mimetype, or ``application/octet-stream`` 662 | will be used. 663 | 664 | """ 665 | def get_content_type(filename): 666 | """Return or guess mimetype of ``filename``. 667 | 668 | :param filename: filename of file 669 | :type filename: unicode/str 670 | :returns: mime-type, e.g. ``text/html`` 671 | :rtype: str 672 | 673 | """ 674 | return mimetypes.guess_type(filename)[0] or 'application/octet-stream' 675 | 676 | boundary = '-----' + ''.join(random.choice(BOUNDARY_CHARS) 677 | for i in range(30)) 678 | CRLF = '\r\n' 679 | output = [] 680 | 681 | # Normal form fields 682 | for (name, value) in fields.items(): 683 | if isinstance(name, unicode): 684 | name = name.encode('utf-8') 685 | if isinstance(value, unicode): 686 | value = value.encode('utf-8') 687 | output.append('--' + boundary) 688 | output.append('Content-Disposition: form-data; name="%s"' % name) 689 | output.append('') 690 | output.append(value) 691 | 692 | # Files to upload 693 | for name, d in files.items(): 694 | filename = d[u'filename'] 695 | content = d[u'content'] 696 | if u'mimetype' in d: 697 | mimetype = d[u'mimetype'] 698 | else: 699 | mimetype = get_content_type(filename) 700 | if isinstance(name, unicode): 701 | name = name.encode('utf-8') 702 | if isinstance(filename, unicode): 703 | filename = filename.encode('utf-8') 704 | if isinstance(mimetype, unicode): 705 | mimetype = mimetype.encode('utf-8') 706 | output.append('--' + boundary) 707 | output.append('Content-Disposition: form-data; ' 708 | 'name="%s"; filename="%s"' % (name, filename)) 709 | output.append('Content-Type: %s' % mimetype) 710 | output.append('') 711 | output.append(content) 712 | 713 | output.append('--' + boundary + '--') 714 | output.append('') 715 | body = CRLF.join(output) 716 | headers = { 717 | 'Content-Type': 'multipart/form-data; boundary=%s' % boundary, 718 | 'Content-Length': str(len(body)), 719 | } 720 | return (headers, body) 721 | -------------------------------------------------------------------------------- /src/workflow/workflow3.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2016 Dean Jackson 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2016-06-25 8 | # 9 | 10 | """An Alfred 3+ version of :class:`~workflow.Workflow`. 11 | 12 | :class:`~workflow.Workflow3` supports new features, such as 13 | setting :ref:`workflow-variables` and 14 | :class:`the more advanced modifiers ` supported by Alfred 3+. 15 | 16 | In order for the feedback mechanism to work correctly, it's important 17 | to create :class:`Item3` and :class:`Modifier` objects via the 18 | :meth:`Workflow3.add_item()` and :meth:`Item3.add_modifier()` methods 19 | respectively. If you instantiate :class:`Item3` or :class:`Modifier` 20 | objects directly, the current :class:`Workflow3` object won't be aware 21 | of them, and they won't be sent to Alfred when you call 22 | :meth:`Workflow3.send_feedback()`. 23 | 24 | """ 25 | 26 | 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 or list, optional): Main output/``{query}``. 54 | **variables: Workflow variables to set. 55 | 56 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a 57 | :class:`list` or :class:`tuple`. 58 | 59 | Attributes: 60 | arg (unicode or list): Output value (``{query}``). 61 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a 62 | :class:`list` or :class:`tuple`. 63 | config (dict): Configuration for downstream workflow element. 64 | 65 | """ 66 | 67 | def __init__(self, arg=None, **variables): 68 | """Create a new `Variables` object.""" 69 | self.arg = arg 70 | self.config = {} 71 | super(Variables, self).__init__(**variables) 72 | 73 | @property 74 | def obj(self): 75 | """``alfredworkflow`` :class:`dict`.""" 76 | o = {} 77 | if self: 78 | d2 = {} 79 | for k, v in self.items(): 80 | d2[k] = v 81 | o['variables'] = d2 82 | 83 | if self.config: 84 | o['config'] = self.config 85 | 86 | if self.arg is not None: 87 | o['arg'] = self.arg 88 | 89 | return {'alfredworkflow': o} 90 | 91 | def __unicode__(self): 92 | """Convert to ``alfredworkflow`` JSON object. 93 | 94 | Returns: 95 | unicode: ``alfredworkflow`` JSON object 96 | 97 | """ 98 | if not self and not self.config: 99 | if not self.arg: 100 | return u'' 101 | if isinstance(self.arg, unicode): 102 | return self.arg 103 | 104 | return json.dumps(self.obj) 105 | 106 | def __str__(self): 107 | """Convert to ``alfredworkflow`` JSON object. 108 | 109 | Returns: 110 | str: UTF-8 encoded ``alfredworkflow`` JSON object 111 | 112 | """ 113 | return unicode(self).encode('utf-8') 114 | 115 | 116 | class Modifier(object): 117 | """Modify :class:`Item3` arg/icon/variables when modifier key is pressed. 118 | 119 | Don't use this class directly (as it won't be associated with any 120 | :class:`Item3`), but rather use :meth:`Item3.add_modifier()` 121 | to add modifiers to results. 122 | 123 | >>> it = wf.add_item('Title', 'Subtitle', valid=True) 124 | >>> it.setvar('name', 'default') 125 | >>> m = it.add_modifier('cmd') 126 | >>> m.setvar('name', 'alternate') 127 | 128 | See :ref:`workflow-variables` in the User Guide for more information 129 | and :ref:`example usage `. 130 | 131 | Args: 132 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. 133 | subtitle (unicode, optional): Override default subtitle. 134 | arg (unicode, optional): Argument to pass for this modifier. 135 | valid (bool, optional): Override item's validity. 136 | icon (unicode, optional): Filepath/UTI of icon to use 137 | icontype (unicode, optional): Type of icon. See 138 | :meth:`Workflow.add_item() ` 139 | for valid values. 140 | 141 | Attributes: 142 | arg (unicode): Arg to pass to following action. 143 | config (dict): Configuration for a downstream element, such as 144 | a File Filter. 145 | icon (unicode): Filepath/UTI of icon. 146 | icontype (unicode): Type of icon. See 147 | :meth:`Workflow.add_item() ` 148 | for valid values. 149 | key (unicode): Modifier key (see above). 150 | subtitle (unicode): Override item subtitle. 151 | valid (bool): Override item validity. 152 | variables (dict): Workflow variables set by this modifier. 153 | 154 | """ 155 | 156 | def __init__(self, key, subtitle=None, arg=None, valid=None, icon=None, 157 | icontype=None): 158 | """Create a new :class:`Modifier`. 159 | 160 | Don't use this class directly (as it won't be associated with any 161 | :class:`Item3`), but rather use :meth:`Item3.add_modifier()` 162 | to add modifiers to results. 163 | 164 | Args: 165 | key (unicode): Modifier key, e.g. ``"cmd"``, ``"alt"`` etc. 166 | subtitle (unicode, optional): Override default subtitle. 167 | arg (unicode, optional): Argument to pass for this modifier. 168 | valid (bool, optional): Override item's validity. 169 | icon (unicode, optional): Filepath/UTI of icon to use 170 | icontype (unicode, optional): Type of icon. See 171 | :meth:`Workflow.add_item() ` 172 | for valid values. 173 | 174 | """ 175 | self.key = key 176 | self.subtitle = subtitle 177 | self.arg = arg 178 | self.valid = valid 179 | self.icon = icon 180 | self.icontype = icontype 181 | 182 | self.config = {} 183 | self.variables = {} 184 | 185 | def setvar(self, name, value): 186 | """Set a workflow variable for this Item. 187 | 188 | Args: 189 | name (unicode): Name of variable. 190 | value (unicode): Value of variable. 191 | 192 | """ 193 | self.variables[name] = value 194 | 195 | def getvar(self, name, default=None): 196 | """Return value of workflow variable for ``name`` or ``default``. 197 | 198 | Args: 199 | name (unicode): Variable name. 200 | default (None, optional): Value to return if variable is unset. 201 | 202 | Returns: 203 | unicode or ``default``: Value of variable if set or ``default``. 204 | 205 | """ 206 | return self.variables.get(name, default) 207 | 208 | @property 209 | def obj(self): 210 | """Modifier formatted for JSON serialization for Alfred 3. 211 | 212 | Returns: 213 | dict: Modifier for serializing to JSON. 214 | 215 | """ 216 | o = {} 217 | 218 | if self.subtitle is not None: 219 | o['subtitle'] = self.subtitle 220 | 221 | if self.arg is not None: 222 | o['arg'] = self.arg 223 | 224 | if self.valid is not None: 225 | o['valid'] = self.valid 226 | 227 | if self.variables: 228 | o['variables'] = self.variables 229 | 230 | if self.config: 231 | o['config'] = self.config 232 | 233 | icon = self._icon() 234 | if icon: 235 | o['icon'] = icon 236 | 237 | return o 238 | 239 | def _icon(self): 240 | """Return `icon` object for item. 241 | 242 | Returns: 243 | dict: Mapping for item `icon` (may be empty). 244 | 245 | """ 246 | icon = {} 247 | if self.icon is not None: 248 | icon['path'] = self.icon 249 | 250 | if self.icontype is not None: 251 | icon['type'] = self.icontype 252 | 253 | return icon 254 | 255 | 256 | class Item3(object): 257 | """Represents a feedback item for Alfred 3+. 258 | 259 | Generates Alfred-compliant JSON for a single item. 260 | 261 | Don't use this class directly (as it then won't be associated with 262 | any :class:`Workflow3 ` object), but rather use 263 | :meth:`Workflow3.add_item() `. 264 | See :meth:`~workflow.Workflow3.add_item` for details of arguments. 265 | 266 | """ 267 | 268 | def __init__(self, title, subtitle='', arg=None, autocomplete=None, 269 | match=None, valid=False, uid=None, icon=None, icontype=None, 270 | type=None, largetext=None, copytext=None, quicklookurl=None): 271 | """Create a new :class:`Item3` object. 272 | 273 | Use same arguments as for 274 | :class:`Workflow.Item `. 275 | 276 | Argument ``subtitle_modifiers`` is not supported. 277 | 278 | """ 279 | self.title = title 280 | self.subtitle = subtitle 281 | self.arg = arg 282 | self.autocomplete = autocomplete 283 | self.match = match 284 | self.valid = valid 285 | self.uid = uid 286 | self.icon = icon 287 | self.icontype = icontype 288 | self.type = type 289 | self.quicklookurl = quicklookurl 290 | self.largetext = largetext 291 | self.copytext = copytext 292 | 293 | self.modifiers = {} 294 | 295 | self.config = {} 296 | self.variables = {} 297 | 298 | def setvar(self, name, value): 299 | """Set a workflow variable for this Item. 300 | 301 | Args: 302 | name (unicode): Name of variable. 303 | value (unicode): Value of variable. 304 | 305 | """ 306 | self.variables[name] = value 307 | 308 | def getvar(self, name, default=None): 309 | """Return value of workflow variable for ``name`` or ``default``. 310 | 311 | Args: 312 | name (unicode): Variable name. 313 | default (None, optional): Value to return if variable is unset. 314 | 315 | Returns: 316 | unicode or ``default``: Value of variable if set or ``default``. 317 | 318 | """ 319 | return self.variables.get(name, default) 320 | 321 | def add_modifier(self, key, subtitle=None, arg=None, valid=None, icon=None, 322 | icontype=None): 323 | """Add alternative values for a modifier key. 324 | 325 | Args: 326 | key (unicode): Modifier key, e.g. ``"cmd"`` or ``"alt"`` 327 | subtitle (unicode, optional): Override item subtitle. 328 | arg (unicode, optional): Input for following action. 329 | valid (bool, optional): Override item validity. 330 | icon (unicode, optional): Filepath/UTI of icon. 331 | icontype (unicode, optional): Type of icon. See 332 | :meth:`Workflow.add_item() ` 333 | for valid values. 334 | 335 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a 336 | :class:`list` or :class:`tuple`. 337 | 338 | Returns: 339 | Modifier: Configured :class:`Modifier`. 340 | 341 | """ 342 | mod = Modifier(key, subtitle, arg, valid, icon, icontype) 343 | 344 | # Add Item variables to Modifier 345 | mod.variables.update(self.variables) 346 | 347 | self.modifiers[key] = mod 348 | 349 | return mod 350 | 351 | @property 352 | def obj(self): 353 | """Item formatted for JSON serialization. 354 | 355 | Returns: 356 | dict: Data suitable for Alfred 3 feedback. 357 | 358 | """ 359 | # Required values 360 | o = { 361 | 'title': self.title, 362 | 'subtitle': self.subtitle, 363 | 'valid': self.valid, 364 | } 365 | 366 | # Optional values 367 | if self.arg is not None: 368 | o['arg'] = self.arg 369 | 370 | if self.autocomplete is not None: 371 | o['autocomplete'] = self.autocomplete 372 | 373 | if self.match is not None: 374 | o['match'] = self.match 375 | 376 | if self.uid is not None: 377 | o['uid'] = self.uid 378 | 379 | if self.type is not None: 380 | o['type'] = self.type 381 | 382 | if self.quicklookurl is not None: 383 | o['quicklookurl'] = self.quicklookurl 384 | 385 | if self.variables: 386 | o['variables'] = self.variables 387 | 388 | if self.config: 389 | o['config'] = self.config 390 | 391 | # Largetype and copytext 392 | text = self._text() 393 | if text: 394 | o['text'] = text 395 | 396 | icon = self._icon() 397 | if icon: 398 | o['icon'] = icon 399 | 400 | # Modifiers 401 | mods = self._modifiers() 402 | if mods: 403 | o['mods'] = mods 404 | 405 | return o 406 | 407 | def _icon(self): 408 | """Return `icon` object for item. 409 | 410 | Returns: 411 | dict: Mapping for item `icon` (may be empty). 412 | 413 | """ 414 | icon = {} 415 | if self.icon is not None: 416 | icon['path'] = self.icon 417 | 418 | if self.icontype is not None: 419 | icon['type'] = self.icontype 420 | 421 | return icon 422 | 423 | def _text(self): 424 | """Return `largetext` and `copytext` object for item. 425 | 426 | Returns: 427 | dict: `text` mapping (may be empty) 428 | 429 | """ 430 | text = {} 431 | if self.largetext is not None: 432 | text['largetype'] = self.largetext 433 | 434 | if self.copytext is not None: 435 | text['copy'] = self.copytext 436 | 437 | return text 438 | 439 | def _modifiers(self): 440 | """Build `mods` dictionary for JSON feedback. 441 | 442 | Returns: 443 | dict: Modifier mapping or `None`. 444 | 445 | """ 446 | if self.modifiers: 447 | mods = {} 448 | for k, mod in self.modifiers.items(): 449 | mods[k] = mod.obj 450 | 451 | return mods 452 | 453 | return None 454 | 455 | 456 | class Workflow3(Workflow): 457 | """Workflow class that generates Alfred 3+ feedback. 458 | 459 | It is a subclass of :class:`~workflow.Workflow` and most of its 460 | methods are documented there. 461 | 462 | Attributes: 463 | item_class (class): Class used to generate feedback items. 464 | variables (dict): Top level workflow variables. 465 | 466 | """ 467 | 468 | item_class = Item3 469 | 470 | def __init__(self, **kwargs): 471 | """Create a new :class:`Workflow3` object. 472 | 473 | See :class:`~workflow.Workflow` for documentation. 474 | 475 | """ 476 | Workflow.__init__(self, **kwargs) 477 | self.variables = {} 478 | self._rerun = 0 479 | # Get session ID from environment if present 480 | self._session_id = os.getenv('_WF_SESSION_ID') or None 481 | if self._session_id: 482 | self.setvar('_WF_SESSION_ID', self._session_id) 483 | 484 | @property 485 | def _default_cachedir(self): 486 | """Alfred 4's default cache directory.""" 487 | return os.path.join( 488 | os.path.expanduser( 489 | '~/Library/Caches/com.runningwithcrayons.Alfred/' 490 | 'Workflow Data/'), 491 | self.bundleid) 492 | 493 | @property 494 | def _default_datadir(self): 495 | """Alfred 4's default data directory.""" 496 | return os.path.join(os.path.expanduser( 497 | '~/Library/Application Support/Alfred/Workflow Data/'), 498 | self.bundleid) 499 | 500 | @property 501 | def rerun(self): 502 | """How often (in seconds) Alfred should re-run the Script Filter.""" 503 | return self._rerun 504 | 505 | @rerun.setter 506 | def rerun(self, seconds): 507 | """Interval at which Alfred should re-run the Script Filter. 508 | 509 | Args: 510 | seconds (int): Interval between runs. 511 | """ 512 | self._rerun = seconds 513 | 514 | @property 515 | def session_id(self): 516 | """A unique session ID every time the user uses the workflow. 517 | 518 | .. versionadded:: 1.25 519 | 520 | The session ID persists while the user is using this workflow. 521 | It expires when the user runs a different workflow or closes 522 | Alfred. 523 | 524 | """ 525 | if not self._session_id: 526 | from uuid import uuid4 527 | self._session_id = uuid4().hex 528 | self.setvar('_WF_SESSION_ID', self._session_id) 529 | 530 | return self._session_id 531 | 532 | def setvar(self, name, value, persist=False): 533 | """Set a "global" workflow variable. 534 | 535 | .. versionchanged:: 1.33 536 | 537 | These variables are always passed to downstream workflow objects. 538 | 539 | If you have set :attr:`rerun`, these variables are also passed 540 | back to the script when Alfred runs it again. 541 | 542 | Args: 543 | name (unicode): Name of variable. 544 | value (unicode): Value of variable. 545 | persist (bool, optional): Also save variable to ``info.plist``? 546 | 547 | """ 548 | self.variables[name] = value 549 | if persist: 550 | from .util import set_config 551 | set_config(name, value, self.bundleid) 552 | self.logger.debug('saved variable %r with value %r to info.plist', 553 | name, value) 554 | 555 | def getvar(self, name, default=None): 556 | """Return value of workflow variable for ``name`` or ``default``. 557 | 558 | Args: 559 | name (unicode): Variable name. 560 | default (None, optional): Value to return if variable is unset. 561 | 562 | Returns: 563 | unicode or ``default``: Value of variable if set or ``default``. 564 | 565 | """ 566 | return self.variables.get(name, default) 567 | 568 | def add_item(self, title, subtitle='', arg=None, autocomplete=None, 569 | valid=False, uid=None, icon=None, icontype=None, type=None, 570 | largetext=None, copytext=None, quicklookurl=None, match=None): 571 | """Add an item to be output to Alfred. 572 | 573 | Args: 574 | match (unicode, optional): If you have "Alfred filters results" 575 | turned on for your Script Filter, Alfred (version 3.5 and 576 | above) will filter against this field, not ``title``. 577 | 578 | In Alfred 4.1+ and Alfred-Workflow 1.40+, ``arg`` may also be a 579 | :class:`list` or :class:`tuple`. 580 | 581 | See :meth:`Workflow.add_item() ` for 582 | the main documentation and other parameters. 583 | 584 | The key difference is that this method does not support the 585 | ``modifier_subtitles`` argument. Use the :meth:`~Item3.add_modifier()` 586 | method instead on the returned item instead. 587 | 588 | Returns: 589 | Item3: Alfred feedback item. 590 | 591 | """ 592 | item = self.item_class(title, subtitle, arg, autocomplete, 593 | match, valid, uid, icon, icontype, type, 594 | largetext, copytext, quicklookurl) 595 | 596 | # Add variables to child item 597 | item.variables.update(self.variables) 598 | 599 | self._items.append(item) 600 | return item 601 | 602 | @property 603 | def _session_prefix(self): 604 | """Filename prefix for current session.""" 605 | return '_wfsess-{0}-'.format(self.session_id) 606 | 607 | def _mk_session_name(self, name): 608 | """New cache name/key based on session ID.""" 609 | return self._session_prefix + name 610 | 611 | def cache_data(self, name, data, session=False): 612 | """Cache API with session-scoped expiry. 613 | 614 | .. versionadded:: 1.25 615 | 616 | Args: 617 | name (str): Cache key 618 | data (object): Data to cache 619 | session (bool, optional): Whether to scope the cache 620 | to the current session. 621 | 622 | ``name`` and ``data`` are the same as for the 623 | :meth:`~workflow.Workflow.cache_data` method on 624 | :class:`~workflow.Workflow`. 625 | 626 | If ``session`` is ``True``, then ``name`` is prefixed 627 | with :attr:`session_id`. 628 | 629 | """ 630 | if session: 631 | name = self._mk_session_name(name) 632 | 633 | return super(Workflow3, self).cache_data(name, data) 634 | 635 | def cached_data(self, name, data_func=None, max_age=60, session=False): 636 | """Cache API with session-scoped expiry. 637 | 638 | .. versionadded:: 1.25 639 | 640 | Args: 641 | name (str): Cache key 642 | data_func (callable): Callable that returns fresh data. It 643 | is called if the cache has expired or doesn't exist. 644 | max_age (int): Maximum allowable age of cache in seconds. 645 | session (bool, optional): Whether to scope the cache 646 | to the current session. 647 | 648 | ``name``, ``data_func`` and ``max_age`` are the same as for the 649 | :meth:`~workflow.Workflow.cached_data` method on 650 | :class:`~workflow.Workflow`. 651 | 652 | If ``session`` is ``True``, then ``name`` is prefixed 653 | with :attr:`session_id`. 654 | 655 | """ 656 | if session: 657 | name = self._mk_session_name(name) 658 | 659 | return super(Workflow3, self).cached_data(name, data_func, max_age) 660 | 661 | def clear_session_cache(self, current=False): 662 | """Remove session data from the cache. 663 | 664 | .. versionadded:: 1.25 665 | .. versionchanged:: 1.27 666 | 667 | By default, data belonging to the current session won't be 668 | deleted. Set ``current=True`` to also clear current session. 669 | 670 | Args: 671 | current (bool, optional): If ``True``, also remove data for 672 | current session. 673 | 674 | """ 675 | def _is_session_file(filename): 676 | if current: 677 | return filename.startswith('_wfsess-') 678 | return filename.startswith('_wfsess-') \ 679 | and not filename.startswith(self._session_prefix) 680 | 681 | self.clear_cache(_is_session_file) 682 | 683 | @property 684 | def obj(self): 685 | """Feedback formatted for JSON serialization. 686 | 687 | Returns: 688 | dict: Data suitable for Alfred 3 feedback. 689 | 690 | """ 691 | items = [] 692 | for item in self._items: 693 | items.append(item.obj) 694 | 695 | o = {'items': items} 696 | if self.variables: 697 | o['variables'] = self.variables 698 | if self.rerun: 699 | o['rerun'] = self.rerun 700 | return o 701 | 702 | def warn_empty(self, title, subtitle=u'', icon=None): 703 | """Add a warning to feedback if there are no items. 704 | 705 | .. versionadded:: 1.31 706 | 707 | Add a "warning" item to Alfred feedback if no other items 708 | have been added. This is a handy shortcut to prevent Alfred 709 | from showing its fallback searches, which is does if no 710 | items are returned. 711 | 712 | Args: 713 | title (unicode): Title of feedback item. 714 | subtitle (unicode, optional): Subtitle of feedback item. 715 | icon (str, optional): Icon for feedback item. If not 716 | specified, ``ICON_WARNING`` is used. 717 | 718 | Returns: 719 | Item3: Newly-created item. 720 | 721 | """ 722 | if len(self._items): 723 | return 724 | 725 | icon = icon or ICON_WARNING 726 | return self.add_item(title, subtitle, icon=icon) 727 | 728 | def send_feedback(self): 729 | """Print stored items to console/Alfred as JSON.""" 730 | if self.debugging: 731 | json.dump(self.obj, sys.stdout, indent=2, separators=(',', ': ')) 732 | else: 733 | json.dump(self.obj, sys.stdout) 734 | sys.stdout.flush() 735 | --------------------------------------------------------------------------------