├── LICENSE ├── README.md ├── media_converter ├── __init__.py ├── bulkconvert.py ├── common.py ├── config.json ├── config.md ├── config.py ├── consts.py ├── dialogs │ ├── __init__.py │ ├── bulk_convert_dialog.py │ ├── bulk_convert_result_dialog.py │ ├── main_settings_dialog.py │ ├── paste_image_dialog.py │ └── settings_dialog_base.py ├── events.py ├── file_converters │ ├── __init__.py │ ├── audio_converter.py │ ├── common.py │ ├── convert_result.py │ ├── file_converter.py │ ├── image_converter.py │ ├── internal_file_converter.py │ ├── on_add_note_converter.py │ └── on_paste_converter.py ├── icons │ ├── edit.svg │ └── webp.png ├── media_rename.py ├── menus.py ├── support │ └── .ensure_dir ├── utils │ ├── __init__.py │ ├── converter_interfaces.py │ ├── file_paths_factory.py │ ├── mime_helper.py │ ├── show_options.py │ └── temp_file.py └── widgets │ ├── __init__.py │ ├── audio_settings_widget.py │ ├── audio_slider_box.py │ ├── behavior_settings_widget.py │ ├── bulk_convert_settings_widget.py │ ├── image_settings_widget.py │ ├── image_slider_box.py │ ├── presets_editor.py │ ├── rich_slider.py │ └── scale_settings_widget.py ├── playground ├── no_anki_config.py ├── run_bulk_convert_dialog.py ├── run_bulk_convert_result.py ├── run_main_settings_dialog.py └── run_paste_image_dialog.py ├── pyproject.toml └── scripts ├── format.sh ├── libwebp-dl.sh └── package.sh /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 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 Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AJT Media Converter 2 | 3 | [![Rate on AnkiWeb](https://glutanimate.com/logos/ankiweb-rate.svg)](https://ankiweb.net/shared/info/1151815987) 4 | ![GitHub](https://img.shields.io/github/license/Ajatt-Tools/PasteImagesAsWebP) 5 | [![Patreon](https://img.shields.io/badge/support-patreon-orange)](https://www.patreon.com/tatsumoto_ren) 6 | [![Matrix](https://img.shields.io/badge/chat-join-green.svg)](https://tatsumoto-ren.github.io/blog/join-our-community.html) 7 | 8 | > An Anki add-on that makes your images small. 9 | 10 |

11 | 12 |

13 | 14 | We all know that people who don't store their images in 15 | [WebP](https://developers.google.com/speed/webp) 16 | or [AVIF](https://aomediacodec.github.io/av1-avif/) 17 | are wasting a lot of disk space. 18 | Not only on their hard drives, but on AnkiWeb as well. 19 | Unfortunately, Anki doesn't convert images to WebP when you paste them from elsewhere, 20 | and it takes time to convert and resize images manually. 21 | 22 | For the longest time I used a bash script 23 | to automatically convert images in my Anki collection to WebP 24 | until I decided that we simply need an add-on for this. 25 | 26 | > WebP lossy images are 25-34% smaller than comparable JPEG images at equivalent SSIM quality index. 27 | 28 | Storing images in WebP is a great way to reduce the size of your Anki collection. 29 | 30 | ## Installation 31 | 32 | Install from [AnkiWeb](https://ankiweb.net/shared/info/1151815987), or manually with `git`: 33 | 34 | ``` 35 | $ git clone 'https://github.com/Ajatt-Tools/PasteImagesAsWebP.git' ~/.local/share/Anki2/addons21/PasteImagesAsWebP 36 | ``` 37 | 38 | Don't forget to initialize `git` submodules. 39 | 40 | ### WebP images 41 | 42 | The add-on expects `cwebp` executable to be added to PATH. 43 | On Arch Linux, `cwebp` is a part of `libwebp`. 44 | 45 | ``` 46 | $ sudo pacman -S libwebp 47 | ``` 48 | 49 | On Debian/Ubuntu: 50 | 51 | ``` 52 | $ sudo apt install webp 53 | ``` 54 | 55 | On macOS: 56 | ``` 57 | $ brew install webp 58 | ``` 59 | 60 | Or download it from [google.com](https://developers.google.com/speed/webp/download) 61 | and save the `cwebp` executable in `~/.local/share/Anki2/addons21/PasteImagesAsWebP/support/`. 62 | `cwebp` comes included in the AnkiWeb package. 63 | 64 | ### AVIF images 65 | 66 | To convert images to [AVIF](https://aomediacodec.github.io/av1-avif/), 67 | The add-on expects [FFmpeg](https://wiki.archlinux.org/title/FFmpeg) executable to be added to PATH. 68 | On Arch Linux, Install the `ffmpeg` package. 69 | 70 | ``` 71 | sudo pacman -S ffmpeg 72 | ``` 73 | 74 | ## Configuration 75 | 76 | To configure the add-on select "AJT" > "WebP settings" from the top menu bar. 77 | To view hidden settings open the Anki Add-on Menu 78 | via "Tools" > "Add-ons" and select "AJT Media Converter". 79 | Then click the Config button on the right-side of the screen. 80 | 81 | ## Usage 82 | 83 | Watch video demonstration: 84 | 85 |

86 | 87 | After installation images will be automatically converted to `WebP` and resized on pasting or drag-and-dropping. 88 | There's also a button in the Editor toolbar that lets you do the same. 89 | 90 | To bulk-convert existing images in your collection, select `Edit` > `Bulk-convert to WebP` in the card browser. 91 | 92 | To rename media files on a particular note, 93 | open the Anki Browser, select the note and click on the pencil icon on the toolbar. 94 | A dialog will pop up asking you to enter new filenames. 95 | 96 | ## Contributions 97 | 98 | If you've found a bug or want to extend the add-on, please let us know in the 99 | [Matrix chat](https://tatsumoto-ren.github.io/blog/join-our-community.html). 100 | I'm open to suggestions and pull requests. 101 | 102 | My special thanks to all my 103 | [Patreon](https://www.patreon.com/tatsumoto_ren) 104 | supporters for making this project possible. 105 | -------------------------------------------------------------------------------- /media_converter/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | import sys 5 | 6 | from aqt import mw 7 | 8 | 9 | def start_addon() -> None: 10 | from . import bulkconvert, events, media_rename, menus 11 | 12 | bulkconvert.init() 13 | menus.init() 14 | events.init() 15 | media_rename.init() 16 | 17 | 18 | if mw and "pytest" not in sys.modules: 19 | start_addon() 20 | -------------------------------------------------------------------------------- /media_converter/bulkconvert.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | import collections 5 | import functools 6 | from collections.abc import Iterable, Sequence 7 | from typing import cast 8 | 9 | from anki.collection import Collection 10 | from anki.notes import Note, NoteId 11 | from anki.utils import join_fields 12 | from aqt import gui_hooks, mw 13 | from aqt.browser import Browser 14 | from aqt.operations import CollectionOp, ResultWithChanges 15 | from aqt.qt import * 16 | 17 | from .common import find_convertible_audio, find_convertible_images, tooltip 18 | from .config import config 19 | from .consts import ADDON_FULL_NAME, ADDON_NAME_SNAKE 20 | from .dialogs.bulk_convert_dialog import AnkiBulkConvertDialog 21 | from .dialogs.bulk_convert_result_dialog import BulkConvertResultDialog 22 | from .dialogs.settings_dialog_base import AnkiSaveAndRestoreGeomDialog 23 | from .file_converters.common import LocalFile 24 | from .file_converters.convert_result import ConvertResult 25 | from .file_converters.internal_file_converter import InternalFileConverter 26 | 27 | ACTION_NAME = f"{ADDON_FULL_NAME}: Bulk-convert" 28 | 29 | 30 | class ConvertTask: 31 | _browser: Browser 32 | _selected_fields: list[str] 33 | _result: ConvertResult 34 | _to_convert: dict[LocalFile, dict[NoteId, Note]] 35 | 36 | def __init__(self, browser: Browser, note_ids: Sequence[NoteId], selected_fields: list[str]): 37 | self._browser = browser 38 | self._selected_fields = selected_fields 39 | self._result = ConvertResult() 40 | self._to_convert = self._find_files_to_convert_and_notes(note_ids) 41 | 42 | @property 43 | def size(self) -> int: 44 | return len(self._to_convert) 45 | 46 | def __call__(self): 47 | if self._result.is_dirty(): 48 | raise RuntimeError("Already converted.") 49 | for progress_idx, file in enumerate(self._to_convert): 50 | yield progress_idx 51 | try: 52 | converted_filename = self._convert_stored_file(file) 53 | except (OSError, RuntimeError, FileNotFoundError) as ex: 54 | self._result.add_failed(file, exception=ex) 55 | else: 56 | self._result.add_converted(file, converted_filename) 57 | 58 | def update_notes(self): 59 | def show_report_message() -> int: 60 | dialog = BulkConvertResultDialog(self._browser) 61 | dialog.set_result(self._result) 62 | return dialog.exec() 63 | 64 | def on_finish() -> None: 65 | assert self._browser.editor 66 | show_report_message() 67 | self._browser.editor.loadNoteKeepingFocus() 68 | 69 | if self._result.is_dirty(): 70 | if not self._result.converted: 71 | return show_report_message() 72 | CollectionOp(parent=self._browser, op=lambda col: self._update_notes_op(col)).success( 73 | lambda out: on_finish() 74 | ).run_in_background() 75 | 76 | def _first_referenced(self, file: LocalFile) -> Note: 77 | return next(note for note in self._to_convert[file].values()) 78 | 79 | def _keys_to_update(self, note: Note) -> Iterable[str]: 80 | if not self._selected_fields: 81 | return note.keys() 82 | else: 83 | return set(note.keys()).intersection(self._selected_fields) 84 | 85 | def _find_files_to_convert_and_notes(self, note_ids: Sequence[NoteId]) -> dict[LocalFile, dict[NoteId, Note]]: 86 | """ 87 | Maps each filename to a set of note ids that reference the filename. 88 | """ 89 | assert mw 90 | to_convert: dict[LocalFile, dict[NoteId, Note]] = collections.defaultdict(dict) 91 | 92 | for note in map(mw.col.get_note, note_ids): 93 | note_content = join_fields([note[field] for field in self._keys_to_update(note)]) 94 | if " str: 106 | conv = InternalFileConverter(self._browser.editor, file, self._first_referenced(file)) 107 | conv.convert_internal() 108 | return conv.new_filename 109 | 110 | def _update_notes_op(self, col: Collection) -> ResultWithChanges: 111 | pos = col.add_custom_undo_entry(f"Convert {len(self._result.converted)} images to WebP") 112 | to_update: dict[NoteId, Note] = {} 113 | 114 | for old_file, converted_filename in self._result.converted.items(): 115 | for note in self._to_convert[old_file].values(): 116 | for field_name in self._keys_to_update(note): 117 | note[field_name] = note[field_name].replace(old_file.file_name, converted_filename) 118 | to_update[note.id] = note 119 | 120 | col.update_notes(list(to_update.values())) 121 | return col.merge_undo_entries(pos) 122 | 123 | 124 | class ConvertSignals(QObject): 125 | canceled = pyqtSignal() 126 | task_done = pyqtSignal() 127 | update_progress = pyqtSignal(int) 128 | 129 | 130 | class ConvertRunnable(QRunnable): 131 | canceled: bool 132 | 133 | def __init__(self, task: ConvertTask, signals: ConvertSignals): 134 | super().__init__() 135 | self.canceled = False 136 | self.task = task 137 | self.signals = signals 138 | qconnect(self.signals.canceled, self.set_canceled) 139 | 140 | def set_canceled(self): 141 | self.canceled = True 142 | 143 | def run(self): 144 | for progress_value in self.task(): 145 | if self.canceled: 146 | break 147 | self.signals.update_progress.emit(progress_value) # type: ignore 148 | self.signals.task_done.emit() # type: ignore 149 | 150 | 151 | class ProgressBar(AnkiSaveAndRestoreGeomDialog): 152 | name: str = f"ajt__{ADDON_NAME_SNAKE}_convert_progress_bar" 153 | task: ConvertTask 154 | 155 | def __init__(self, task: ConvertTask, parent=None) -> None: 156 | super().__init__(parent) 157 | self.bar = QProgressBar() 158 | self.cancel_button = QPushButton("Cancel") 159 | self.setLayout(self.setup_layout()) 160 | self.task = task 161 | self.signals = ConvertSignals() 162 | self.pool = QThreadPool.globalInstance() 163 | cast(QDialog, self).setWindowTitle("Converting...") 164 | self.setMinimumSize(320, 24) 165 | self.move(100, 100) 166 | self.set_range(0, task.size) 167 | qconnect(self.cancel_button.clicked, self.set_canceled) 168 | qconnect(self.signals.task_done, self.accept) 169 | qconnect(self.signals.update_progress, self.bar.setValue) 170 | 171 | def start_task(self) -> int: 172 | runnable = ConvertRunnable(self.task, self.signals) 173 | self.pool.start(runnable) 174 | return self.exec() 175 | 176 | def set_canceled(self): 177 | self.signals.canceled.emit() # type: ignore 178 | 179 | def setup_layout(self) -> QLayout: 180 | layout = QVBoxLayout() 181 | layout.addWidget(self.bar) 182 | layout.addLayout(self.setup_cancel_button_layout()) 183 | return layout 184 | 185 | def setup_cancel_button_layout(self) -> QLayout: 186 | layout = QHBoxLayout() 187 | layout.addStretch() 188 | layout.addWidget(self.cancel_button) 189 | return layout 190 | 191 | def set_range(self, min_val: int, max_val: int) -> None: 192 | return self.bar.setRange(min_val, max_val) 193 | 194 | 195 | def reload_note(func: Callable[[Browser, Sequence[NoteId], list[str]], None]): 196 | @functools.wraps(func) 197 | def decorator(browser: Browser, note_ids: Sequence[NoteId], selected_fields: list[str]) -> None: 198 | assert browser.editor 199 | note = browser.editor.note 200 | if note: 201 | browser.editor.currentField = None 202 | browser.editor.set_note(None) 203 | func(browser, note_ids, selected_fields) 204 | if note: 205 | browser.editor.set_note(note) 206 | 207 | return decorator 208 | 209 | 210 | @reload_note 211 | def bulk_convert(browser: Browser, note_ids: Sequence[NoteId], selected_fields: list[str]) -> None: 212 | progress_bar = ProgressBar(task=ConvertTask(browser, note_ids, selected_fields)) 213 | progress_bar.start_task() # blocks 214 | progress_bar.task.update_notes() 215 | 216 | 217 | def on_bulk_convert(browser: Browser): 218 | selected_nids = browser.selectedNotes() 219 | if selected_nids: 220 | dialog = AnkiBulkConvertDialog(parent=browser, config=config) 221 | if dialog.exec(): 222 | if len(selected_nids) == 1: 223 | browser.table.clear_selection() 224 | bulk_convert(browser, selected_nids, dialog.selected_fields()) 225 | else: 226 | tooltip("No cards selected.", parent=browser) 227 | 228 | 229 | def setup_menu(browser: Browser): 230 | a = QAction(ACTION_NAME, browser) 231 | qconnect(a.triggered, lambda: on_bulk_convert(browser)) 232 | browser.form.menuEdit.addAction(a) 233 | 234 | 235 | def init() -> None: 236 | gui_hooks.browser_menus_did_init.append(setup_menu) 237 | -------------------------------------------------------------------------------- /media_converter/common.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | import re 5 | from collections.abc import Iterable 6 | from typing import Optional 7 | 8 | from aqt.editor import Editor 9 | from aqt.qt import * 10 | 11 | from .dialogs.paste_image_dialog import AnkiPasteImageDialog 12 | from .utils.show_options import ImageDimensions, ShowOptions 13 | 14 | RE_IMAGE_HTML_TAG = re.compile(r']*src="([^"]+)"[^<>]*>', flags=re.IGNORECASE) 15 | RE_AUDIO_HTML_TAG = re.compile(r"\[sound:([^\]]+)\]", flags=re.IGNORECASE) 16 | 17 | 18 | def get_file_extension(file_path: str) -> str: 19 | return os.path.splitext(file_path)[1].lower() 20 | 21 | 22 | def is_excluded_image_extension(filename: str, include_converted: bool = False) -> bool: 23 | """ 24 | Return true if the image file with this filename should not be converted. 25 | 26 | :param filename: Name of the file. 27 | :param include_converted: Allow reconversion. The target extension (webp, avif) will not be excluded. 28 | """ 29 | from .config import config 30 | 31 | return get_file_extension(filename) in config.get_excluded_image_extensions(include_converted) 32 | 33 | 34 | def is_excluded_audio_extension(filename: str, include_converted: bool = False) -> bool: 35 | """ 36 | Return true if the audio file with this filename should not be converted. 37 | 38 | :param filename: Name of the file. 39 | :param include_converted: Allow reconversion. The target extension (webp, avif) will not be excluded. 40 | """ 41 | from .config import config 42 | 43 | return get_file_extension(filename) in config.get_excluded_audio_extensions(include_converted) 44 | 45 | 46 | def find_convertible_images(html: str, include_converted: bool = False) -> Iterable[str]: 47 | """ 48 | Find image files referenced by a note. 49 | :param html: Note content (joined fields). 50 | :param include_converted: Reconvert files even if they already have been converted to the target format. E.g. to reduce size. 51 | :return: Filenames 52 | """ 53 | if " Iterable[str]: 63 | """ 64 | Find audio files referenced by a note. 65 | :param html: Note content (joined fields). 66 | :param include_converted: Reconvert files even if they already have been converted to the target format. E.g. to reduce size. 67 | :return: Filenames 68 | """ 69 | if "[sound:" not in html: 70 | return 71 | filename: str 72 | for filename in re.findall(RE_AUDIO_HTML_TAG, html): 73 | # Check if the filename ends with any of the excluded extensions 74 | if not is_excluded_audio_extension(filename, include_converted): 75 | yield filename 76 | 77 | 78 | def tooltip(msg: str, parent: Optional[QWidget] = None) -> None: 79 | from aqt.utils import tooltip as _tooltip 80 | from .config import config 81 | 82 | return _tooltip(msg=msg, period=config.tooltip_duration_seconds * 1000, parent=parent) 83 | 84 | 85 | def filesize_kib(filepath: str) -> float: 86 | return os.stat(filepath).st_size / 1024.0 87 | 88 | 89 | def image_html(image_filename: str) -> str: 90 | from .config import config 91 | 92 | return f'{config.image_format.name} image' 93 | 94 | 95 | def insert_image_html(editor: Editor, image_filename: str): 96 | editor.doPaste(html=image_html(image_filename), internal=True) 97 | 98 | 99 | def has_local_file(mime: QMimeData) -> bool: 100 | for url in mime.urls(): 101 | if url.isLocalFile(): 102 | return True 103 | return False 104 | 105 | 106 | def key_to_str(shortcut: str) -> str: 107 | return QKeySequence(shortcut).toString(QKeySequence.SequenceFormat.NativeText) 108 | 109 | 110 | def maybe_show_settings(dimensions: ImageDimensions, parent: Optional[QWidget], action: ShowOptions) -> int: 111 | from .config import config 112 | 113 | if config.should_show_settings(action): 114 | dlg = AnkiPasteImageDialog(config=config, dimensions=dimensions, parent=parent) 115 | return dlg.exec() 116 | return QDialog.DialogCode.Accepted 117 | -------------------------------------------------------------------------------- /media_converter/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "avoid_upscaling": true, 3 | "show_context_menu_entry": false, 4 | "show_editor_button": true, 5 | "convert_on_note_add": true, 6 | "delete_original_file_on_convert": false, 7 | "shortcut": "Ctrl+Meta+v", 8 | "image_width": 0, 9 | "image_height": 250, 10 | "max_image_width": 1000, 11 | "max_image_height": 1000, 12 | "image_format": "webp", 13 | "audio_container": "ogg", 14 | "excluded_image_containers": "svg,webp,avif", 15 | "excluded_audio_containers": "mid,aac,opus,ogg", 16 | "image_quality": 20, 17 | "show_settings": "toolbar", 18 | "drag_and_drop": true, 19 | "copy_paste": false, 20 | "cwebp_args": [ 21 | "-short", 22 | "-mt", 23 | "-pass", 24 | "10", 25 | "-af", 26 | "-blend_alpha", 27 | "0xffffff", 28 | "-m", 29 | "6" 30 | ], 31 | "ffmpeg_args": [ 32 | "-threads", 33 | "0", 34 | "-map_metadata", 35 | "-1", 36 | "-cpu-used", 37 | "6" 38 | ], 39 | "filename_pattern_num": 0, 40 | "tooltip_duration_seconds": 5, 41 | "preserve_original_filenames": true, 42 | "bulk_convert_fields": [], 43 | "bulk_reconvert": false, 44 | "custom_name_field": "VocabKanji", 45 | "saved_presets": [], 46 | "enable_image_conversion": true, 47 | "enable_audio_conversion": false, 48 | "ffmpeg_audio_args": [ 49 | ], 50 | "ffmpeg_audio_bitrate": 32 51 | } 52 | -------------------------------------------------------------------------------- /media_converter/config.md: -------------------------------------------------------------------------------- 1 | ## AJT Media Converter — edit config 2 | 3 | Here you can edit the config file. 4 | You must know what you're doing. 5 | Anki needs to be restarted for changes to be applied. 6 | To restore default settings, click the **"Restore Defaults"** button. 7 | 8 | If you have any questions, 9 | ask other users in the [user group](https://tatsumoto.neocities.org/blog/join-our-community.html). 10 | 11 | **** 12 | 13 | * `avoid_upscaling` - Don't resize an image when its original size is less than requested. 14 | * `bulk_convert_fields` - List of fields where the add-on looks for images when bulk-converting. 15 | * `bulk_reconvert` - When bulk-converting, reconvert images that are already in the desired format. 16 | * `copy_paste` - Convert images when you copy-paste them. 17 | * `cwebp_args` - Extra [cwebp arguments](https://www.unix.com/man-page/debian/1/cwebp/). 18 | They are applied on each call to `cwebp`. 19 | * `ffmpeg_args` - Extra [ffmpeg arguments](https://ffmpeg.org/ffmpeg.html). 20 | They are applied on each call to `ffmpeg`. 21 | * `drag_and_drop` - Convert images on drag and drop. 22 | * `image_height` - Desired height. 23 | * `image_width` - Desired width. 24 | * `image_format` - Desired format ("avif" or "webp"). 25 | * `excluded_image_containers` - A comma-separated list of file formats (extensions without the dot) 26 | to skip from image conversion. 27 | * `excluded_audio_containers` - A comma-separated list of file formats (extensions without the dot) 28 | to skip from audio conversion. 29 | * `image_quality` - Compression factor between `0` and `100`. 30 | `0` produces the worst quality but the smallest file size. 31 | * `max_image_height` - Limit for the height slider. 32 | * `max_image_width` - Limit for the width slider. 33 | * `shortcut` - Define a keyboard shortcut for pasting images in the configured `image_format`. 34 | * `show_context_menu_entry` - Add an entry to the editor context menu. 35 | * `show_editor_button` - Add a button to the editor toolbar. 36 | * `show_settings` - When to show the settings dialog. 37 | * `always` - Every time you try to insert a converted image. 38 | * `toolbar` - When the toolbar button is clicked. 39 | * `paste` - When you paste an image. 40 | * `drag_and_drop` - On drag-and-drop (if enabled). 41 | * `never` - Only when you press `Tools > WebP settings`. 42 | * `filename_pattern_num` - Used internally. 43 | * `tooltip_duration_seconds` - Duration of tooltips. 44 | * `preserve_original_filenames` - If an image is already named, reuse that name. 45 | Works when dragging an image from a GUI file manager, e.g. [Thunar](https://wiki.archlinux.org/title/Thunar). 46 | * `enable_audio_conversion` - Enable/disable conversion of audio files to `opus`. 47 | * `enable_image_conversion` - Enable/disable conversion of audio files to `webp` or `avif` 48 | based on the selected image format. 49 | * `ffmpeg_audio_args` - Extra [ffmpeg arguments](https://ffmpeg.org/ffmpeg.html) for audio. 50 | * `-b:a` - Audio bitrate. Default "64k". 51 | [About opus bitrates](https://wiki.xiph.org/Opus_Recommended_Settings). 52 | * `audio_container` - Container (file extension name) for opus files ("opus" or "ogg"). 53 | 54 | If one of the dimensions is set to `0`, images will be resized 55 | preserving the aspect ratio. 56 | If both `width` and `height` are `0`, no resizing is performed (not recommended). 57 | 58 | **** 59 | 60 | If you enjoy this add-on, please consider supporting my work by 61 | **[making a donation](https://tatsumoto.neocities.org/blog/donating-to-tatsumoto.html)**. 62 | Thank you so much! 63 | -------------------------------------------------------------------------------- /media_converter/config.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | import enum 5 | from collections.abc import Iterable, Sequence 6 | from typing import Union 7 | 8 | from aqt import mw 9 | 10 | from .ajt_common.addon_config import AddonConfigManager, set_config_update_action 11 | from .ajt_common.utils import clamp 12 | from .utils.show_options import ShowOptions 13 | from .widgets.audio_slider_box import MAX_AUDIO_BITRATE_K, MIN_AUDIO_BITRATE_K 14 | 15 | 16 | @enum.unique 17 | class ImageFormat(enum.Enum): 18 | webp = enum.auto() 19 | avif = enum.auto() 20 | 21 | 22 | @enum.unique 23 | class AudioContainer(enum.Enum): 24 | opus = "opus" 25 | ogg = "ogg" 26 | 27 | @classmethod 28 | def _missing_(cls, _value): 29 | return cls.ogg 30 | 31 | 32 | def cfg_comma_sep_str_to_file_ext_set(cfg_str: str) -> set[str]: 33 | """ 34 | Take a string containing a comma-separated list of file formats 35 | and convert it into a set of file extensions 36 | """ 37 | return {f".{ext}".lower().strip() for ext in cfg_str.split(",")} 38 | 39 | 40 | class MediaConverterConfig(AddonConfigManager): 41 | 42 | def __init__(self, default: bool = False) -> None: 43 | super().__init__(default) 44 | 45 | def show_settings(self) -> Sequence[ShowOptions]: 46 | instances = [] 47 | for name in self["show_settings"].split(","): 48 | try: 49 | instances.append(ShowOptions[name]) 50 | except KeyError: 51 | continue 52 | return instances 53 | 54 | def set_show_options(self, options: Iterable[ShowOptions]): 55 | self["show_settings"] = ",".join(option.name for option in options) 56 | 57 | @property 58 | def image_format(self) -> ImageFormat: 59 | return ImageFormat[self["image_format"].lower()] 60 | 61 | @property 62 | def audio_container(self) -> AudioContainer: 63 | return AudioContainer(self["audio_container"].lower()) 64 | 65 | @property 66 | def image_extension(self) -> str: 67 | return f".{self.image_format.name}".lower() 68 | 69 | @property 70 | def audio_extension(self) -> str: 71 | return f".{self.audio_container.name}".lower() 72 | 73 | @property 74 | def bulk_reconvert(self) -> bool: 75 | return self["bulk_reconvert"] 76 | 77 | @bulk_reconvert.setter 78 | def bulk_reconvert(self, value: bool) -> None: 79 | self["bulk_reconvert"] = value 80 | 81 | @property 82 | def image_quality(self) -> int: 83 | return clamp(min_val=0, val=self["image_quality"], max_val=100) 84 | 85 | @property 86 | def image_width(self) -> int: 87 | return clamp(min_val=0, val=self["image_width"], max_val=99_999) 88 | 89 | @property 90 | def image_height(self) -> int: 91 | return clamp(min_val=0, val=self["image_height"], max_val=99_999) 92 | 93 | @property 94 | def preserve_original_filenames(self) -> bool: 95 | return self["preserve_original_filenames"] 96 | 97 | @property 98 | def convert_on_note_add(self) -> bool: 99 | return bool(self["convert_on_note_add"]) 100 | 101 | @property 102 | def show_editor_button(self) -> bool: 103 | return bool(self["show_editor_button"]) 104 | 105 | @property 106 | def delete_original_file_on_convert(self) -> bool: 107 | return self["delete_original_file_on_convert"] 108 | 109 | @property 110 | def filename_pattern_num(self) -> int: 111 | """ 112 | Index in list FileNamePatterns 113 | """ 114 | return int(self["filename_pattern_num"]) 115 | 116 | def get_excluded_image_extensions(self, include_converted: bool) -> frozenset[str]: 117 | """ 118 | Return excluded formats and prepend a dot to each format. 119 | If the "reconvert" option is enabled when using bulk-convert, 120 | the target extension (.avif or .webp) is not excluded. 121 | 122 | :param include_converted: The current image extension will not be in the list, 123 | thus webp/avif files will be reconverted. 124 | :return: Image extensions. 125 | """ 126 | excluded_extensions = cfg_comma_sep_str_to_file_ext_set(self.excluded_image_containers) 127 | if include_converted: 128 | excluded_extensions.discard(self.image_extension) 129 | else: 130 | excluded_extensions.add(self.image_extension) 131 | return frozenset(excluded_extensions) 132 | 133 | def get_excluded_audio_extensions(self, include_converted: bool) -> frozenset[str]: 134 | """ 135 | Return excluded formats and prepend a dot to each format. 136 | If the "reconvert" option is enabled when using bulk-convert, 137 | the target extension (.opus or .ogg) is not excluded. 138 | 139 | :param include_converted: The current audio extension will not be in the list, 140 | thus ogg/opus files will be reconverted. 141 | :return: Audio extensions. 142 | """ 143 | excluded_extensions = cfg_comma_sep_str_to_file_ext_set(self["excluded_audio_containers"]) 144 | if include_converted: 145 | excluded_extensions.discard(self.audio_extension) 146 | else: 147 | excluded_extensions.add(self.audio_extension) 148 | return frozenset(excluded_extensions) 149 | 150 | @property 151 | def enable_image_conversion(self) -> bool: 152 | return bool(self["enable_image_conversion"]) 153 | 154 | @property 155 | def enable_audio_conversion(self) -> bool: 156 | return bool(self["enable_audio_conversion"]) 157 | 158 | @property 159 | def cwebp_args(self) -> list[Union[str, int]]: 160 | return self["cwebp_args"] 161 | 162 | @property 163 | def ffmpeg_args(self) -> list[Union[str, int]]: 164 | return self["ffmpeg_args"] 165 | 166 | @property 167 | def ffmpeg_audio_args(self) -> list[Union[str, int]]: 168 | return self["ffmpeg_audio_args"] 169 | 170 | @property 171 | def audio_bitrate_k(self) -> int: 172 | return clamp(MIN_AUDIO_BITRATE_K, self["ffmpeg_audio_bitrate"], MAX_AUDIO_BITRATE_K) 173 | 174 | @audio_bitrate_k.setter 175 | def audio_bitrate_k(self, kbit_s: int) -> None: 176 | self["ffmpeg_audio_bitrate"] = kbit_s 177 | 178 | @property 179 | def tooltip_duration_seconds(self) -> int: 180 | return int(self["tooltip_duration_seconds"]) 181 | 182 | @property 183 | def drag_and_drop(self) -> bool: 184 | return bool(self["drag_and_drop"]) 185 | 186 | @property 187 | def copy_paste(self) -> bool: 188 | return bool(self["copy_paste"]) 189 | 190 | @property 191 | def excluded_image_containers(self) -> str: 192 | return self["excluded_image_containers"] 193 | 194 | @excluded_image_containers.setter 195 | def excluded_image_containers(self, value: str) -> None: 196 | self["excluded_image_containers"] = value 197 | 198 | @property 199 | def shortcut(self) -> str: 200 | return self["shortcut"] 201 | 202 | @property 203 | def custom_name_field(self) -> str: 204 | return self["custom_name_field"] 205 | 206 | @property 207 | def avoid_upscaling(self) -> bool: 208 | return bool(self["avoid_upscaling"]) 209 | 210 | def should_show_settings(self, action: ShowOptions) -> bool: 211 | return bool(action in self.show_settings()) 212 | 213 | 214 | def get_global_config() -> MediaConverterConfig: 215 | assert mw, "anki must be running" 216 | return config 217 | 218 | 219 | if mw: 220 | config = MediaConverterConfig() 221 | set_config_update_action(config.update_from_addon_manager) 222 | -------------------------------------------------------------------------------- /media_converter/consts.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | import os 5 | 6 | from .ajt_common.consts import ADDON_SERIES 7 | 8 | ADDON_PATH = os.path.dirname(os.path.abspath(__file__)) 9 | ADDON_NAME = "Media Converter" 10 | ADDON_FULL_NAME = f"{ADDON_SERIES} {ADDON_NAME}" 11 | ADDON_NAME_SNAKE = ADDON_NAME.lower().replace(" ", "_") 12 | THIS_ADDON_MODULE = __name__.split(".")[0] 13 | SUPPORT_DIR = os.path.join(ADDON_PATH, "support") 14 | 15 | WINDOW_MIN_WIDTH = 400 16 | 17 | REQUEST_HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; Anki)"} 18 | REQUEST_TIMEOUTS = (3.05, 12.05) 19 | 20 | assert os.path.isdir(SUPPORT_DIR), "support dir must exist." 21 | -------------------------------------------------------------------------------- /media_converter/dialogs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ajatt-Tools/PasteImagesAsWebP/4521052e0c4764bafb56342b041f4261745f7a15/media_converter/dialogs/__init__.py -------------------------------------------------------------------------------- /media_converter/dialogs/bulk_convert_dialog.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | import itertools 4 | from collections.abc import Iterable 5 | 6 | from anki.notes import Note 7 | from aqt import mw 8 | from aqt.browser import Browser 9 | from aqt.utils import showInfo 10 | 11 | from ..config import MediaConverterConfig 12 | from ..consts import ADDON_NAME_SNAKE 13 | from ..widgets.audio_settings_widget import AudioSettings 14 | from ..widgets.bulk_convert_settings_widget import BulkConvertSettings 15 | from ..widgets.image_settings_widget import ImageSettings 16 | from .settings_dialog_base import ( 17 | AnkiSaveAndRestoreGeomDialog, 18 | SettingsDialogBase, 19 | SettingsTabs, 20 | ) 21 | 22 | 23 | def get_all_keys(notes: Iterable[Note]) -> list[str]: 24 | """ 25 | Returns a list of field names present in passed notes, without duplicates. 26 | """ 27 | return sorted(frozenset(itertools.chain(*(note.keys() for note in notes)))) 28 | 29 | 30 | class BulkConvertDialog(SettingsDialogBase): 31 | """Dialog shown on bulk-convert.""" 32 | 33 | name: str = f"ajt__{ADDON_NAME_SNAKE}_bulk_convert_dialog" 34 | _tabs: SettingsTabs 35 | _image_settings: ImageSettings 36 | _audio_settings: AudioSettings 37 | _bulk_convert_settings: BulkConvertSettings 38 | 39 | def __init__(self, config: MediaConverterConfig, parent=None) -> None: 40 | super().__init__(config, parent) 41 | self._image_settings = ImageSettings(config=self.config) 42 | self._audio_settings = AudioSettings(config=self.config) 43 | self._bulk_convert_settings = BulkConvertSettings(config=self.config) 44 | self._tabs = SettingsTabs(self.config, self._image_settings, self._audio_settings, self._bulk_convert_settings) 45 | self._setup_ui() 46 | self.setup_bottom_button_box() 47 | self.set_initial_values() 48 | 49 | def _setup_ui(self) -> None: 50 | self.main_vbox.addWidget(self._tabs) 51 | self.main_vbox.addStretch() 52 | self.main_vbox.addWidget(self.button_box) 53 | 54 | def set_initial_values(self) -> None: 55 | self._image_settings.set_initial_values() 56 | self._audio_settings.set_initial_values() 57 | self._bulk_convert_settings.set_initial_values(all_field_names=self.selected_notes_fields()) 58 | 59 | def selected_fields(self) -> list[str]: 60 | return self._bulk_convert_settings.field_selector.checked_texts() 61 | 62 | def selected_notes_fields(self) -> list[str]: 63 | """ 64 | A dummy used when Anki isn't running. 65 | """ 66 | assert mw is None 67 | return ["A", "B", "C"] 68 | 69 | def accept(self) -> None: 70 | if not self._bulk_convert_settings.field_selector.has_valid_selection(): 71 | showInfo(title="Can't accept settings", text="No fields selected. Nothing to convert.") 72 | return 73 | self._image_settings.pass_settings_to_config() 74 | self._audio_settings.pass_settings_to_config() 75 | self._bulk_convert_settings.pass_settings_to_config() 76 | self.config.write_config() 77 | return super().accept() 78 | 79 | 80 | class AnkiBulkConvertDialog(BulkConvertDialog, AnkiSaveAndRestoreGeomDialog): 81 | """ 82 | Adds methods that work only when Anki is running. 83 | """ 84 | 85 | def __init__(self, config: MediaConverterConfig, parent=None) -> None: 86 | super().__init__(config, parent) 87 | 88 | def selected_notes_fields(self) -> list[str]: 89 | """ 90 | Return a list of field names where each field name is present in at least one selected note. 91 | """ 92 | browser = self.parent() 93 | assert mw 94 | assert isinstance(browser, Browser) 95 | return get_all_keys(mw.col.get_note(nid) for nid in browser.selectedNotes()) 96 | -------------------------------------------------------------------------------- /media_converter/dialogs/bulk_convert_result_dialog.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | import io 4 | from typing import Optional 5 | 6 | import aqt 7 | from aqt.qt import * 8 | 9 | from ..ajt_common.about_menu import tweak_window 10 | from ..consts import ADDON_FULL_NAME 11 | from ..file_converters.convert_result import ConvertResult 12 | 13 | 14 | def fallback_parent(parent) -> Optional[QWidget]: 15 | if parent is None: 16 | try: 17 | return aqt.mw.app.activeWindow() or aqt.mw 18 | except AttributeError: 19 | assert aqt.mw is None 20 | pass 21 | return parent 22 | 23 | 24 | def form_report_message(result: ConvertResult) -> str: 25 | buffer = io.StringIO() 26 | buffer.write(f"

Converted {len(result.converted)} files.

") 27 | if result.failed: 28 | buffer.write(f"

Failed {len(result.failed)} files.

") 29 | buffer.write("
    ") 30 | for file, reason in result.failed.items(): 31 | buffer.write(f"
  1. {file}: {reason}
  2. ") 32 | buffer.write("
") 33 | return buffer.getvalue() 34 | 35 | 36 | class AJTScrollLabel(QScrollArea): 37 | def __init__(self, parent: Optional[QWidget] = None) -> None: 38 | super().__init__(parent) 39 | self.setWidgetResizable(True) 40 | content = QWidget(self) 41 | self.setWidget(content) 42 | layout = QVBoxLayout(content) 43 | self._label = QLabel(content) 44 | self._label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop) 45 | self._label.setWordWrap(False) 46 | self._label.setTextFormat(Qt.TextFormat.RichText) 47 | layout.addWidget(self._label) 48 | 49 | def set_text(self, text: str) -> None: 50 | return self._label.setText(text) 51 | 52 | 53 | class BulkConvertResultDialog(QDialog): 54 | def __init__(self, parent: Optional[QWidget] = None) -> None: 55 | super().__init__(parent=fallback_parent(parent)) 56 | tweak_window(self) 57 | self.setWindowModality(Qt.WindowModality.ApplicationModal) 58 | self.setWindowTitle(f"{ADDON_FULL_NAME} - Convert Results") 59 | self.setSizePolicy(self.make_size_policy()) 60 | self.setMinimumSize(320, 320) 61 | self._button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok) 62 | self._label = AJTScrollLabel() 63 | self.setLayout(self.make_root_layout()) 64 | qconnect(self._button_box.accepted, self.accept) 65 | 66 | def set_result(self, result: ConvertResult) -> None: 67 | self._label.set_text(form_report_message(result)) 68 | 69 | def make_root_layout(self) -> QLayout: 70 | root_layout = QVBoxLayout() 71 | root_layout.addWidget(self._label) 72 | root_layout.addWidget(self._button_box) 73 | return root_layout 74 | 75 | def make_size_policy(self) -> QSizePolicy: 76 | size_policy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum) 77 | size_policy.setHorizontalStretch(0) 78 | size_policy.setVerticalStretch(0) 79 | size_policy.setHeightForWidth(self.sizePolicy().hasHeightForWidth()) 80 | return size_policy 81 | -------------------------------------------------------------------------------- /media_converter/dialogs/main_settings_dialog.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | from typing import cast 4 | 5 | from aqt.addons import AddonsDialog, ConfigEditor 6 | from aqt.qt import * 7 | 8 | from ..config import MediaConverterConfig 9 | from ..consts import ADDON_NAME_SNAKE, THIS_ADDON_MODULE 10 | from ..widgets.audio_settings_widget import AudioSettings 11 | from ..widgets.behavior_settings_widget import BehaviorSettings 12 | from ..widgets.image_settings_widget import ImageSettings 13 | from .settings_dialog_base import ( 14 | AnkiSaveAndRestoreGeomDialog, 15 | SettingsDialogBase, 16 | SettingsTabs, 17 | ) 18 | 19 | 20 | class MainSettingsDialog(SettingsDialogBase): 21 | """Dialog available from the "AJT" menu (main window).""" 22 | 23 | name: str = f"ajt__{ADDON_NAME_SNAKE}_main_menu_dialog" 24 | _tabs: SettingsTabs 25 | _image_settings: ImageSettings 26 | _audio_settings: AudioSettings 27 | 28 | def __init__(self, config: MediaConverterConfig, parent=None) -> None: 29 | super().__init__(config, parent) 30 | self._image_settings = ImageSettings(config=self.config) 31 | self._audio_settings = AudioSettings(config=self.config) 32 | self._behavior_settings = BehaviorSettings(config=self.config) 33 | self._tabs = SettingsTabs(self.config, self._image_settings, self._audio_settings, self._behavior_settings) 34 | self._setup_ui() 35 | self.setup_bottom_button_box() 36 | self.set_initial_values() 37 | 38 | def _setup_ui(self) -> None: 39 | self.main_vbox.addWidget(self._tabs) 40 | self.main_vbox.addStretch() 41 | self.main_vbox.addWidget(self.button_box) 42 | 43 | def set_initial_values(self) -> None: 44 | self._image_settings.set_initial_values() 45 | self._audio_settings.set_initial_values() 46 | self._behavior_settings.set_initial_values() 47 | 48 | def accept(self) -> None: 49 | self._image_settings.pass_settings_to_config() 50 | self._audio_settings.pass_settings_to_config() 51 | self._behavior_settings.pass_settings_to_config() 52 | self.config.write_config() 53 | return super().accept() 54 | 55 | 56 | class AnkiMainSettingsDialog(MainSettingsDialog, AnkiSaveAndRestoreGeomDialog): 57 | """ 58 | Adds methods that work only when Anki is running. 59 | """ 60 | 61 | def __init__(self, config: MediaConverterConfig, parent=None) -> None: 62 | super().__init__(config, parent) 63 | self._add_advanced_button() 64 | 65 | def _add_advanced_button(self) -> None: 66 | """ 67 | Add the "Show advanced settings" button to the bottom button box (Okay, Cancel). 68 | """ 69 | 70 | def advanced_clicked() -> None: 71 | d = ConfigEditor(cast(AddonsDialog, self), THIS_ADDON_MODULE, self.config.dict_copy()) 72 | qconnect(d.accepted, self.set_initial_values) 73 | 74 | b = self.button_box.addButton("Advanced", QDialogButtonBox.ButtonRole.HelpRole) 75 | qconnect(b.clicked, advanced_clicked) 76 | -------------------------------------------------------------------------------- /media_converter/dialogs/paste_image_dialog.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | from aqt import qconnect 5 | 6 | from ..config import MediaConverterConfig 7 | from ..consts import ADDON_NAME_SNAKE 8 | from ..utils.show_options import ImageDimensions 9 | from ..widgets.image_settings_widget import ImageSettings 10 | from ..widgets.scale_settings_widget import ScaleSettings 11 | from .settings_dialog_base import AnkiSaveAndRestoreGeomDialog, SettingsDialogBase 12 | 13 | 14 | class PasteImageDialog(SettingsDialogBase): 15 | """Dialog shown on paste.""" 16 | 17 | name: str = f"ajt__{ADDON_NAME_SNAKE}_paste_image_dialog" 18 | _image_settings: ImageSettings 19 | _scale_settings: ScaleSettings 20 | _dimensions: ImageDimensions 21 | 22 | def __init__(self, config: MediaConverterConfig, dimensions: ImageDimensions, parent=None) -> None: 23 | super().__init__(config, parent) 24 | self._dimensions = dimensions 25 | self._image_settings = ImageSettings(config=self.config) 26 | self._scale_settings = ScaleSettings( 27 | config=self.config, title=f"Original size: {self._dimensions.width} x {self._dimensions.height} px" 28 | ) 29 | self._setup_ui() 30 | self.setup_bottom_button_box() 31 | self.set_initial_values() 32 | qconnect(self._scale_settings.factor_changed, self._set_factor) 33 | 34 | def _setup_ui(self) -> None: 35 | self.main_vbox.addWidget(self._image_settings) 36 | self.main_vbox.addWidget(self._scale_settings) 37 | self.main_vbox.addStretch() 38 | self.main_vbox.addWidget(self.button_box) 39 | 40 | def set_initial_values(self) -> None: 41 | self._image_settings.set_initial_values() 42 | 43 | def _set_factor(self, factor: float) -> None: 44 | self._image_settings.set_dimensions( 45 | width=int(self._dimensions.width * factor), height=int(self._dimensions.height * factor) 46 | ) 47 | 48 | def accept(self) -> None: 49 | self._image_settings.pass_settings_to_config() 50 | self.config.write_config() 51 | return super().accept() 52 | 53 | 54 | class AnkiPasteImageDialog(PasteImageDialog, AnkiSaveAndRestoreGeomDialog): 55 | """ 56 | Adds methods that work only when Anki is running. 57 | """ 58 | 59 | def __init__(self, config: MediaConverterConfig, dimensions: ImageDimensions, parent=None) -> None: 60 | super().__init__(config, dimensions, parent) 61 | -------------------------------------------------------------------------------- /media_converter/dialogs/settings_dialog_base.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | from aqt.qt import * 5 | from aqt.utils import restoreGeom, saveGeom 6 | 7 | from ..ajt_common.about_menu import tweak_window 8 | from ..ajt_common.addon_config import MgrPropMixIn 9 | from ..config import MediaConverterConfig 10 | from ..consts import ADDON_FULL_NAME, ADDON_NAME_SNAKE, WINDOW_MIN_WIDTH 11 | 12 | 13 | def make_accept_reject_box() -> QDialogButtonBox: 14 | return QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) 15 | 16 | 17 | class ConfigPropMixIn: 18 | _config: MediaConverterConfig 19 | 20 | @property 21 | def config(self) -> MediaConverterConfig: 22 | assert self._config is not None 23 | return self._config 24 | 25 | 26 | class WidgetHasName(QWidget): 27 | name: str = "undefined" 28 | 29 | 30 | class SettingsDialogBase(QDialog, ConfigPropMixIn, MgrPropMixIn): 31 | name: str = f"ajt__{ADDON_NAME_SNAKE}_settings_dialog" 32 | 33 | def __init__(self, config: MediaConverterConfig, parent=None) -> None: 34 | super().__init__(parent) 35 | self._config = config 36 | self.setWindowTitle(ADDON_FULL_NAME) 37 | self.setMinimumWidth(WINDOW_MIN_WIDTH) 38 | self._main_vbox = QVBoxLayout() 39 | self._button_box = make_accept_reject_box() 40 | self.setLayout(self._main_vbox) 41 | tweak_window(self) 42 | 43 | @property 44 | def button_box(self) -> QDialogButtonBox: 45 | return self._button_box 46 | 47 | @property 48 | def main_vbox(self) -> QVBoxLayout: 49 | return self._main_vbox 50 | 51 | def setup_bottom_button_box(self) -> None: 52 | """ 53 | Adds the button box at the bottom of the main layout and connects the Accept and Reject buttons. 54 | """ 55 | qconnect(self._button_box.accepted, self.accept) 56 | qconnect(self._button_box.rejected, self.reject) 57 | self._button_box.button(QDialogButtonBox.StandardButton.Ok).setFocus() 58 | 59 | 60 | class SettingsTabs(QTabWidget): 61 | """ 62 | A widget that accepts a list of widgets and groups them into tabs. 63 | """ 64 | 65 | _config: MediaConverterConfig 66 | 67 | def __init__( 68 | self, 69 | config: MediaConverterConfig, 70 | *tabs: WidgetHasName, 71 | parent=None, 72 | ) -> None: 73 | super().__init__(parent) 74 | self._config = config 75 | for widget in tabs: 76 | self.addTab(widget, widget.name) 77 | 78 | 79 | class AnkiSaveAndRestoreGeomDialog(QDialog): 80 | """ 81 | A dialog running inside Anki should save and restore its position and size when closed/opened. 82 | """ 83 | 84 | name: str 85 | 86 | def __init__(self, *args, **kwargs) -> None: 87 | super().__init__(*args, **kwargs) 88 | assert isinstance(self.name, str) and self.name, "Dialog name must be set." 89 | restoreGeom(self, self.name, adjustSize=True) 90 | print(f"restored geom for {self.name}") 91 | 92 | def _save_geom(self) -> None: 93 | saveGeom(self, self.name) 94 | print(f"saved geom for {self.name}") 95 | 96 | def accept(self) -> None: 97 | self._save_geom() 98 | return super().accept() 99 | 100 | def reject(self) -> None: 101 | self._save_geom() 102 | return super().reject() 103 | -------------------------------------------------------------------------------- /media_converter/events.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | import os.path 4 | 5 | import anki 6 | import aqt.editor 7 | from anki import hooks 8 | from anki.hooks import wrap 9 | from aqt import gui_hooks, mw 10 | from aqt.qt import * 11 | from aqt.utils import KeyboardModifiersPressed 12 | 13 | from .common import has_local_file, image_html, is_excluded_image_extension, tooltip 14 | from .config import config 15 | from .file_converters.file_converter import FFmpegNotFoundError 16 | from .file_converters.image_converter import CanceledPaste, ffmpeg_not_found_dialog 17 | from .file_converters.on_add_note_converter import OnAddNoteConverter 18 | from .file_converters.on_paste_converter import ( 19 | TEMP_IMAGE_FORMAT, 20 | OnPasteConverter, 21 | mime_to_image_file, 22 | ) 23 | from .utils.show_options import ShowOptions 24 | from .utils.temp_file import TempFile 25 | 26 | 27 | def should_paste_raw() -> bool: 28 | return KeyboardModifiersPressed().shift 29 | 30 | 31 | def convert_mime(mime: QMimeData, editor: aqt.editor.Editor, action: ShowOptions) -> QMimeData: 32 | conv = OnPasteConverter(editor, action) 33 | with TempFile(suffix=f".{TEMP_IMAGE_FORMAT}") as tmp_file: 34 | if to_convert := mime_to_image_file(mime, tmp_file.path()): 35 | try: 36 | new_file_path = conv.convert_mime(to_convert) 37 | except FFmpegNotFoundError: 38 | ffmpeg_not_found_dialog() 39 | except CanceledPaste as ex: 40 | conv.tooltip(ex) 41 | # Treat "Cancel" as both "don't convert" and "don't paste". Erase mime data. 42 | mime = QMimeData() 43 | except FileNotFoundError: 44 | conv.tooltip("File not found.") 45 | except (RuntimeError, AttributeError) as ex: 46 | conv.tooltip(ex) 47 | else: 48 | # File has been converted. 49 | mime = QMimeData() 50 | mime.setHtml(image_html(os.path.basename(new_file_path))) 51 | conv.result_tooltip(new_file_path) 52 | return mime 53 | 54 | 55 | def on_process_mime( 56 | mime: QMimeData, editor_web_view: aqt.editor.EditorWebView, internal: bool, _extended: bool, drop_event: bool 57 | ) -> QMimeData: 58 | if internal or should_paste_raw(): 59 | return mime 60 | 61 | if config.drag_and_drop and drop_event: 62 | return convert_mime(mime, editor_web_view.editor, action=ShowOptions.drag_and_drop) 63 | 64 | if config.copy_paste and not drop_event and (mime.hasImage() or has_local_file(mime)): 65 | return convert_mime(mime, editor_web_view.editor, action=ShowOptions.paste) 66 | 67 | return mime 68 | 69 | 70 | def should_convert_images_in_new_note(note: anki.notes.Note) -> bool: 71 | """ 72 | Convert media files when a new note is added by AnkiConnect. 73 | Skip notes added using the Add dialog. 74 | """ 75 | assert mw 76 | return config.convert_on_note_add is True and mw.app.activeWindow() is None and note.id == 0 77 | 78 | 79 | def on_add_note(_self: anki.collection.Collection, note: anki.notes.Note, _deck_id: anki.decks.DeckId) -> None: 80 | if should_convert_images_in_new_note(note): 81 | converter = OnAddNoteConverter(note, action=ShowOptions.add_note, parent=mw) 82 | try: 83 | converter.convert_note() 84 | except FFmpegNotFoundError: 85 | ffmpeg_not_found_dialog() 86 | except CanceledPaste as ex: 87 | tooltip(str(ex), parent=mw) 88 | except (OSError, RuntimeError, FileNotFoundError): 89 | pass 90 | 91 | 92 | def on_setup_mask_editor(self: aqt.editor.Editor, image_path: str, _old: Callable) -> None: 93 | """ 94 | Wrap Image Occlusion and convert the pasted image before Occlusion is used. 95 | https://docs.ankiweb.net/editing.html#image-occlusion 96 | """ 97 | if config.copy_paste and not is_excluded_image_extension(os.path.basename(image_path)): 98 | conv = OnPasteConverter(self, action=ShowOptions.paste) 99 | try: 100 | image_path = conv.convert_image(image_path) 101 | except FFmpegNotFoundError: 102 | ffmpeg_not_found_dialog() 103 | except FileNotFoundError: 104 | conv.tooltip("File not found.") 105 | except Exception as ex: 106 | conv.tooltip(ex) 107 | else: 108 | conv.result_tooltip(image_path) 109 | return _old(self, image_path) 110 | 111 | 112 | def init() -> None: 113 | gui_hooks.editor_will_process_mime.append(on_process_mime) 114 | hooks.note_will_be_added.append(on_add_note) 115 | aqt.editor.Editor.setup_mask_editor = wrap(aqt.editor.Editor.setup_mask_editor, on_setup_mask_editor, pos="around") 116 | -------------------------------------------------------------------------------- /media_converter/file_converters/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | from .audio_converter import AudioConverter 4 | from .image_converter import ImageConverter 5 | -------------------------------------------------------------------------------- /media_converter/file_converters/audio_converter.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | from ..config import get_global_config 5 | from .common import ConverterType, create_process, run_process 6 | from .file_converter import FFmpegNotFoundError, FileConverter, find_ffmpeg_exe 7 | 8 | 9 | class AudioConverter(FileConverter, mode=ConverterType.audio): 10 | _source_path: str 11 | _destination_path: str 12 | 13 | def __init__(self, source_path: str, destination_path: str) -> None: 14 | self._config = get_global_config() 15 | self._source_path = source_path 16 | self._destination_path = destination_path 17 | 18 | def convert(self) -> None: 19 | if not find_ffmpeg_exe(): 20 | raise FFmpegNotFoundError("ffmpeg executable is not in PATH") 21 | 22 | args = [ 23 | find_ffmpeg_exe(), 24 | "-hide_banner", 25 | "-nostdin", 26 | "-y", 27 | "-loglevel", 28 | "quiet", 29 | "-sn", 30 | "-vn", 31 | "-i", 32 | self._source_path, 33 | "-c:a", 34 | "libopus", 35 | "-vbr", 36 | "on", 37 | "-compression_level", 38 | "10", 39 | "-map", 40 | "0:a", 41 | "-application", 42 | "audio", 43 | "-b:a", 44 | f"{self._config.audio_bitrate_k}k", 45 | *self._config.ffmpeg_audio_args, 46 | self._destination_path, 47 | ] 48 | 49 | print(f"executing args: {args}") 50 | p = create_process(args) 51 | run_process(p) 52 | -------------------------------------------------------------------------------- /media_converter/file_converters/common.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | import enum 4 | import functools 5 | import subprocess 6 | import sys 7 | import typing 8 | from typing import Any 9 | 10 | IS_MAC = sys.platform.startswith("darwin") 11 | IS_WIN = sys.platform.startswith("win32") 12 | COMMON_AUDIO_FORMATS = frozenset( 13 | (".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".aiff", ".amr", ".ape", ".mp2", ".oga", ".oma", ".opus") 14 | ) 15 | 16 | 17 | class ConverterType(enum.Enum): 18 | audio = "audio" 19 | image = "image" 20 | 21 | 22 | class LocalFile(typing.NamedTuple): 23 | file_name: str 24 | type: ConverterType 25 | 26 | def __repr__(self) -> str: 27 | return f"{self.type.name} '{self.file_name}'" 28 | 29 | @classmethod 30 | def image(cls, file_name: str): 31 | return cls(file_name, ConverterType.image) 32 | 33 | @classmethod 34 | def audio(cls, file_name: str): 35 | return cls(file_name, ConverterType.audio) 36 | 37 | 38 | @functools.cache 39 | def startup_info(): 40 | if IS_WIN: 41 | # Prevents a console window from popping up on Windows 42 | si = subprocess.STARTUPINFO() 43 | si.dwFlags |= subprocess.STARTF_USESHOWWINDOW 44 | else: 45 | si = None 46 | return si 47 | 48 | 49 | def stringify_args(args: list[Any]) -> list[str]: 50 | return [str(arg) for arg in args] 51 | 52 | 53 | def create_process(args: list[Any]) -> subprocess.Popen: 54 | return subprocess.Popen( 55 | stringify_args(args), 56 | shell=False, 57 | bufsize=-1, 58 | stdout=subprocess.PIPE, 59 | stderr=subprocess.STDOUT, 60 | startupinfo=startup_info(), 61 | universal_newlines=True, 62 | encoding="utf8", 63 | ) 64 | 65 | 66 | def run_process(p: subprocess.Popen) -> None: 67 | stdout, stderr = p.communicate() 68 | if p.wait() != 0: 69 | print("Conversion failed.") 70 | print(f"exit code = {p.returncode}") 71 | print(stdout) 72 | raise RuntimeError(f"Conversion failed with code {p.returncode}.") 73 | -------------------------------------------------------------------------------- /media_converter/file_converters/convert_result.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | from typing import Optional 4 | 5 | from .common import LocalFile 6 | 7 | 8 | class ConvertResult: 9 | def __init__(self) -> None: 10 | self._converted: dict[LocalFile, str] = {} 11 | self._failed: dict[LocalFile, Optional[Exception]] = {} 12 | 13 | def add_converted(self, old_file: LocalFile, new_filename: str) -> None: 14 | self._converted[old_file] = new_filename 15 | 16 | def add_failed(self, file: LocalFile, exception: Optional[Exception] = None): 17 | self._failed[file] = exception 18 | 19 | @property 20 | def converted(self) -> dict[LocalFile, str]: 21 | return self._converted 22 | 23 | @property 24 | def failed(self) -> dict[LocalFile, Optional[Exception]]: 25 | return self._failed 26 | 27 | def is_dirty(self) -> bool: 28 | return bool(self._converted or self._failed) 29 | -------------------------------------------------------------------------------- /media_converter/file_converters/file_converter.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | import functools 5 | from typing import Optional 6 | 7 | from ..ajt_common.utils import find_executable as find_executable_ajt 8 | from ..common import get_file_extension 9 | from .common import COMMON_AUDIO_FORMATS, ConverterType 10 | 11 | 12 | class FFmpegNotFoundError(FileNotFoundError): 13 | pass 14 | 15 | 16 | def is_audio_file(filename: str) -> bool: 17 | return get_file_extension(filename) in COMMON_AUDIO_FORMATS 18 | 19 | 20 | @functools.cache 21 | def find_ffmpeg_exe() -> Optional[str]: 22 | # https://www.gyan.dev/ffmpeg/builds/ffmpeg-git-essentials.7z 23 | return find_executable_ajt("ffmpeg") 24 | 25 | 26 | class FileConverter: 27 | """ 28 | Base class for the image and audio converters. 29 | """ 30 | 31 | _subclasses_map: dict[ConverterType, type["FileConverter"]] = {} # audio -> AudioConverter 32 | _mode: ConverterType # used to mark subclasses 33 | 34 | def __init_subclass__(cls, **kwargs) -> None: 35 | # mode is one of ("audio", "image") 36 | mode = kwargs.pop("mode") # suppresses ide warning 37 | super().__init_subclass__(**kwargs) 38 | cls._subclasses_map[mode] = cls 39 | cls._mode = mode 40 | 41 | def __new__(cls, source_path: str, destination_path: str) -> "FileConverter": 42 | if is_audio_file(source_path): 43 | mode = ConverterType.audio 44 | else: 45 | mode = ConverterType.image 46 | obj = object.__new__(cls._subclasses_map[mode]) 47 | assert obj.mode == mode, f"{obj.mode} should be equal to {mode}" 48 | return obj 49 | 50 | @property 51 | def mode(self) -> ConverterType: 52 | return self._mode 53 | 54 | def convert(self) -> None: 55 | raise NotImplementedError() 56 | -------------------------------------------------------------------------------- /media_converter/file_converters/image_converter.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | import functools 5 | from typing import Optional 6 | 7 | from aqt.qt import * 8 | from aqt.utils import showWarning 9 | 10 | from ..ajt_common.utils import find_executable as find_executable_ajt 11 | from ..common import get_file_extension 12 | from ..config import ImageFormat, MediaConverterConfig, get_global_config 13 | from ..consts import ADDON_FULL_NAME, SUPPORT_DIR 14 | from ..utils.mime_helper import iter_files 15 | from ..utils.show_options import ImageDimensions 16 | from .common import IS_MAC, IS_WIN, ConverterType, create_process, run_process 17 | from .file_converter import FFmpegNotFoundError, FileConverter, find_ffmpeg_exe 18 | 19 | ANIMATED_OR_VIDEO_FORMATS = frozenset( 20 | [".apng", ".gif", ".mp4", ".avi", ".mov", ".mkv", ".wmv", ".flv", ".webm", ".m4v", ".mpg", ".mpeg"] 21 | ) 22 | AVIF_WORST_CRF = 63 23 | 24 | 25 | class CanceledPaste(Warning): 26 | pass 27 | 28 | 29 | class MimeImageNotFound(Warning): 30 | pass 31 | 32 | 33 | @functools.cache 34 | def support_exe_suffix() -> str: 35 | """ 36 | The mecab executable file in the "support" dir has a different suffix depending on the platform. 37 | """ 38 | if IS_WIN: 39 | return ".exe" 40 | elif IS_MAC: 41 | return ".mac" 42 | else: 43 | return ".lin" 44 | 45 | 46 | def get_bundled_executable(name: str) -> str: 47 | """ 48 | Get path to executable in the bundled "support" folder. 49 | Used to provide "cwebp' and 'ffmpeg' on computers where it is not installed system-wide or can't be found. 50 | """ 51 | path_to_exe = os.path.join(SUPPORT_DIR, name) + support_exe_suffix() 52 | assert os.path.isfile(path_to_exe), f"{path_to_exe} doesn't exist. Can't recover." 53 | if not IS_WIN: 54 | os.chmod(path_to_exe, 0o755) 55 | return path_to_exe 56 | 57 | 58 | @functools.cache 59 | def find_cwebp_exe() -> str: 60 | # https://developers.google.com/speed/webp/download 61 | return find_executable_ajt("cwebp") or get_bundled_executable("cwebp") 62 | 63 | 64 | def fetch_filename(mime: QMimeData) -> Optional[str]: 65 | for file in iter_files(mime): 66 | if base := os.path.basename(file): 67 | return base 68 | return None 69 | 70 | 71 | def quality_percent_to_avif_crf(q: int) -> int: 72 | # https://github.com/strukturag/libheif/commit/7caa01dd150b6c96f33d35bff2eab8a32b8edf2b 73 | return (100 - q) * AVIF_WORST_CRF // 100 74 | 75 | 76 | def is_animation(source_path: str) -> bool: 77 | return get_file_extension(source_path) in ANIMATED_OR_VIDEO_FORMATS 78 | 79 | 80 | def ffmpeg_not_found_dialog(parent=None): 81 | return showWarning( 82 | title=ADDON_FULL_NAME, 83 | parent=parent, 84 | text=""" 85 |

FFmpeg is not found in PATH.

86 | 87 | Install ffmpeg if it is not installed yet. 88 | Follow Arch Wiki 89 | or the project home page for details. 90 | 91 | Make sure that ffmpeg is added to the PATH. 92 | To learn how, read 93 | this section 94 | in Arch Wiki or follow your operating system's instructions. 95 | """, 96 | textFormat="rich", 97 | ) 98 | 99 | 100 | def find_image_dimensions(file_path: str) -> ImageDimensions: 101 | with open(file_path, "rb") as f: 102 | image = QImage.fromData(f.read()) # type: ignore 103 | return ImageDimensions(image.width(), image.height()) 104 | 105 | 106 | class ImageConverter(FileConverter, mode=ConverterType.image): 107 | _source_path: str 108 | _dimensions: ImageDimensions 109 | _destination_path: str 110 | _config: MediaConverterConfig 111 | 112 | def __init__(self, source_path: str, destination_path: str) -> None: 113 | self._config = get_global_config() 114 | self._source_path = source_path 115 | self._destination_path = destination_path 116 | self._dimensions = find_image_dimensions(source_path) 117 | 118 | @property 119 | def initial_dimensions(self) -> ImageDimensions: 120 | return self._dimensions 121 | 122 | def smaller_than_requested(self, image: ImageDimensions) -> bool: 123 | return 0 < image.width < self._config.image_width or 0 < image.height < self._config.image_height 124 | 125 | def _get_resize_dimensions(self) -> Optional[ImageDimensions]: 126 | if self._config.avoid_upscaling and self.smaller_than_requested(self._dimensions): 127 | # skip resizing if the image is already smaller than the requested size 128 | return None 129 | 130 | if self._config.image_width == 0 and self._config.image_height == 0: 131 | # skip resizing if both width and height are set to 0 132 | return None 133 | 134 | # For cwebp, the resize arguments are directly "-resize width height" 135 | # For ffmpeg, the resize argument is part of the filtergraph: "scale=width:height" 136 | # The distinction will be made in the respective conversion functions 137 | return ImageDimensions(self._config.image_width, self._config.image_height) 138 | 139 | def _get_ffmpeg_scale_arg(self) -> str: 140 | # Check if either width or height is 0 and adjust accordingly 141 | if resize_args := self._get_resize_dimensions(): 142 | if resize_args.width < 1 and resize_args.height > 0: 143 | return f"scale=-2:{resize_args.height}" 144 | elif resize_args.height < 1 and resize_args.width > 0: 145 | return f"scale={resize_args.width}:-2" 146 | elif resize_args.width > 0 and resize_args.height > 0: 147 | return f"scale={resize_args.width}:{resize_args.height}" 148 | return "scale=-1:-1" 149 | 150 | def _make_to_webp_args(self, source_path: str, destination_path: str) -> list[Union[str, int]]: 151 | args = [ 152 | find_cwebp_exe(), 153 | source_path, 154 | "-o", 155 | destination_path, 156 | "-q", 157 | self._config.image_quality, 158 | *self._config.cwebp_args, 159 | ] 160 | if resize_args := self._get_resize_dimensions(): 161 | args.extend(["-resize", resize_args.width, resize_args.height]) 162 | return args 163 | 164 | def _make_to_avif_args(self, source_path: str, destination_path: str) -> list[Union[str, int]]: 165 | if not find_ffmpeg_exe(): 166 | raise FFmpegNotFoundError("ffmpeg executable is not in PATH") 167 | # Use ffmpeg for non-webp formats, dynamically using the format from config 168 | args = [ 169 | find_ffmpeg_exe(), 170 | "-hide_banner", 171 | "-nostdin", 172 | "-y", 173 | "-loglevel", 174 | "quiet", 175 | "-sn", 176 | "-an", 177 | "-i", 178 | source_path, 179 | "-c:v", 180 | "libaom-av1", 181 | "-vf", 182 | self._get_ffmpeg_scale_arg() + ":flags=sinc+accurate_rnd", 183 | "-crf", 184 | quality_percent_to_avif_crf(self._config.image_quality), 185 | *self._config.ffmpeg_args, 186 | ] 187 | if not is_animation(source_path): 188 | args += [ 189 | "-still-picture", 190 | "1", 191 | "-frames:v", 192 | "1", 193 | ] 194 | args.append(destination_path) 195 | return args 196 | 197 | def convert(self) -> None: 198 | if self._config.image_format == ImageFormat.webp: 199 | args = self._make_to_webp_args(self._source_path, self._destination_path) 200 | else: 201 | args = self._make_to_avif_args(self._source_path, self._destination_path) 202 | 203 | print(f"executing args: {args}") 204 | p = create_process(args) 205 | run_process(p) 206 | -------------------------------------------------------------------------------- /media_converter/file_converters/internal_file_converter.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | import os 4 | import os.path 5 | from typing import Optional 6 | 7 | import aqt.editor 8 | from anki.notes import Note 9 | from aqt import mw 10 | from aqt.qt import * 11 | 12 | from ..config import config 13 | from ..utils.file_paths_factory import FilePathFactory 14 | from ..utils.show_options import ImageDimensions 15 | from .common import ConverterType, LocalFile 16 | from .file_converter import FileConverter 17 | from .image_converter import ImageConverter 18 | 19 | 20 | def get_target_extension(file: LocalFile) -> str: 21 | """ 22 | If image file, convert to avif or webp. If audio file, convert to opus. 23 | """ 24 | if file.type == ConverterType.audio: 25 | return config.audio_extension 26 | return config.image_extension 27 | 28 | 29 | class InternalFileConverter: 30 | """ 31 | Converter used when converting an image or audio file already stored in the collection (e.g. bulk-convert). 32 | """ 33 | 34 | _initial_file_path: str 35 | _destination_file_path: str 36 | _conversion_finished: bool 37 | _converter: FileConverter 38 | 39 | def __init__(self, editor: Optional[aqt.editor.Editor], file: LocalFile, note: Note): 40 | self._conversion_finished = False 41 | self._fpf = FilePathFactory(note=note, editor=editor) 42 | self._initial_file_path = os.path.join(self._dest_dir, file.file_name) 43 | self._destination_file_path = self._fpf.make_unique_filepath( 44 | self._dest_dir, 45 | file.file_name, 46 | extension=get_target_extension(file), 47 | ) 48 | self._converter = FileConverter(self._initial_file_path, self._destination_file_path) 49 | 50 | @property 51 | def _dest_dir(self) -> str: 52 | assert mw 53 | return mw.col.media.dir() 54 | 55 | @property 56 | def new_file_path(self) -> str: 57 | if not self._conversion_finished: 58 | raise RuntimeError("Conversion wasn't performed.") 59 | return self._destination_file_path 60 | 61 | @property 62 | def new_filename(self) -> str: 63 | return os.path.basename(self.new_file_path) 64 | 65 | @property 66 | def file_type(self) -> ConverterType: 67 | return self._converter.mode 68 | 69 | def is_image(self) -> bool: 70 | return self.file_type == ConverterType.image 71 | 72 | @property 73 | def initial_dimensions(self) -> ImageDimensions: 74 | assert isinstance(self._converter, ImageConverter) 75 | return self._converter.initial_dimensions 76 | 77 | def convert_internal(self) -> None: 78 | self._converter.convert() 79 | self._conversion_finished = True 80 | if config.delete_original_file_on_convert: 81 | print("Removing original file.") 82 | os.remove(self._initial_file_path) 83 | -------------------------------------------------------------------------------- /media_converter/file_converters/on_add_note_converter.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | from typing import Optional 4 | 5 | from anki.notes import Note 6 | from aqt import mw 7 | from aqt.qt import * 8 | 9 | from ..common import find_convertible_images, maybe_show_settings 10 | from ..config import config 11 | from ..utils.show_options import ImageDimensions, ShowOptions 12 | from .common import LocalFile 13 | from .image_converter import CanceledPaste 14 | from .internal_file_converter import InternalFileConverter 15 | 16 | 17 | class OnAddNoteConverter: 18 | """ 19 | Converter used when a new note is added by AnkiConnect. 20 | """ 21 | 22 | _settings_shown: bool 23 | _action: ShowOptions 24 | _note: Note 25 | _parent: Optional[QWidget] = None 26 | 27 | def __init__(self, note: Note, action: ShowOptions, parent: Optional[QWidget]) -> None: 28 | self._settings_shown = False 29 | self._action = action 30 | self._note = note 31 | self._parent = parent 32 | 33 | def _should_show_settings(self) -> bool: 34 | if self._settings_shown is False: 35 | self._settings_shown = True 36 | return config.should_show_settings(self._action) 37 | return False 38 | 39 | def _maybe_show_settings(self, dimensions: ImageDimensions) -> int: 40 | """If a note contains multiple images, show settings only once per note.""" 41 | if self._settings_shown is False: 42 | self._settings_shown = True 43 | return maybe_show_settings(dimensions, parent=self._parent, action=self._action) 44 | return QDialog.DialogCode.Accepted 45 | 46 | def _convert_and_replace_stored_image(self, filename: str) -> None: 47 | conv = InternalFileConverter(file=LocalFile.image(filename), editor=None, note=self._note) 48 | ans = self._maybe_show_settings(conv.initial_dimensions) 49 | if ans == QDialog.DialogCode.Rejected: 50 | raise CanceledPaste("Cancelled.") 51 | conv.convert_internal() 52 | self._update_note_fields(filename, conv.new_filename) 53 | 54 | def convert_note(self): 55 | for filename in find_convertible_images(self._note.joined_fields()): 56 | if mw.col.media.have(filename): 57 | print(f"Converting file: {filename}") 58 | self._convert_and_replace_stored_image(filename) 59 | # TODO handle audio files 60 | 61 | def _update_note_fields(self, old_filename: str, new_filename: str) -> None: 62 | for field_name, field_value in self._note.items(): 63 | self._note[field_name] = field_value.replace(f'src="{old_filename}"', f'src="{new_filename}"') 64 | -------------------------------------------------------------------------------- /media_converter/file_converters/on_paste_converter.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | import typing 4 | 5 | import aqt.editor 6 | from aqt.qt import * 7 | 8 | from ..common import ( 9 | filesize_kib, 10 | is_excluded_image_extension, 11 | maybe_show_settings, 12 | tooltip, 13 | ) 14 | from ..config import config 15 | from ..utils.file_paths_factory import FilePathFactory 16 | from ..utils.mime_helper import image_candidates 17 | from ..utils.show_options import ImageDimensions, ShowOptions 18 | from .image_converter import ( 19 | CanceledPaste, 20 | ImageConverter, 21 | MimeImageNotFound, 22 | fetch_filename, 23 | ) 24 | 25 | TEMP_IMAGE_FORMAT = "png" 26 | 27 | 28 | class ConverterPayload(typing.NamedTuple): 29 | tmp_path: str 30 | dimensions: ImageDimensions 31 | initial_filename: typing.Optional[str] 32 | 33 | 34 | def save_image(mime: QMimeData, tmp_path: str) -> ConverterPayload: 35 | for image in image_candidates(mime): 36 | if image and image.save(tmp_path, TEMP_IMAGE_FORMAT) is True: 37 | return ConverterPayload( 38 | tmp_path=tmp_path, 39 | initial_filename=fetch_filename(mime), 40 | dimensions=ImageDimensions(image.width(), image.height()), 41 | ) 42 | raise MimeImageNotFound("Not an image file.") 43 | 44 | 45 | def mime_to_image_file(mime: QMimeData, destination_path: str) -> typing.Optional[ConverterPayload]: 46 | """ 47 | Try to save image file. Return None if the file can't be saved or the file type is excluded by the user. 48 | """ 49 | try: 50 | to_convert = save_image(mime, destination_path) 51 | except MimeImageNotFound: 52 | # Mime doesn't contain images or the images are not supported by Qt. 53 | return None 54 | if to_convert.initial_filename and is_excluded_image_extension(to_convert.initial_filename): 55 | # Skip files with excluded extensions. 56 | return None 57 | return to_convert 58 | 59 | 60 | class OnPasteConverter: 61 | """ 62 | Converter used when an image is pasted or dragged from outside. 63 | """ 64 | 65 | _editor: aqt.editor.Editor 66 | _action: ShowOptions 67 | 68 | def __init__(self, editor: aqt.editor.Editor, action: ShowOptions) -> None: 69 | self._editor = editor 70 | self._action = action 71 | 72 | def _maybe_show_settings(self, dimensions: ImageDimensions) -> None: 73 | result = maybe_show_settings(dimensions, parent=self._editor.parentWindow, action=self._action) 74 | if result == QDialog.DialogCode.Rejected: 75 | raise CanceledPaste("Cancelled.") 76 | 77 | def convert_image(self, image_path: str) -> str: 78 | fpf = FilePathFactory(note=self._editor.note, editor=self._editor) 79 | destination_path = fpf.make_unique_filepath( 80 | dest_dir=self._dest_dir, 81 | original_filename=os.path.basename(image_path), 82 | extension=config.image_extension, 83 | ) 84 | conv = ImageConverter(image_path, destination_path) 85 | self._maybe_show_settings(conv.initial_dimensions) 86 | conv.convert() 87 | return destination_path 88 | 89 | def convert_mime(self, to_convert: ConverterPayload) -> str: 90 | self._maybe_show_settings(to_convert.dimensions) 91 | fpf = FilePathFactory(note=self._editor.note, editor=self._editor) 92 | destination_path = fpf.make_unique_filepath( 93 | dest_dir=self._dest_dir, 94 | original_filename=to_convert.initial_filename, 95 | extension=config.image_extension, 96 | ) 97 | conv = ImageConverter(to_convert.tmp_path, destination_path) 98 | conv.convert() 99 | return destination_path 100 | # TODO handle audio 101 | 102 | def tooltip(self, msg: Union[Exception, str]) -> None: 103 | return tooltip(str(msg), parent=self._editor.parentWindow) 104 | 105 | def result_tooltip(self, filepath: str) -> None: 106 | return self.tooltip( 107 | f"{os.path.basename(filepath)} added.
File size: {filesize_kib(filepath):.3f} KiB." 108 | ) 109 | 110 | @property 111 | def _dest_dir(self) -> str: 112 | return self._editor.mw.col.media.dir() 113 | -------------------------------------------------------------------------------- /media_converter/icons/edit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /media_converter/icons/webp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ajatt-Tools/PasteImagesAsWebP/4521052e0c4764bafb56342b041f4261745f7a15/media_converter/icons/webp.png -------------------------------------------------------------------------------- /media_converter/media_rename.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | import re 5 | from collections.abc import Iterable 6 | from typing import Optional, cast 7 | 8 | from anki.notes import Note 9 | from anki.utils import join_fields 10 | from aqt import gui_hooks, mw 11 | from aqt.editor import Editor 12 | from aqt.operations import CollectionOp 13 | from aqt.qt import * 14 | from aqt.utils import showCritical, tooltip 15 | 16 | from .ajt_common.about_menu import tweak_window 17 | from .ajt_common.media import find_all_media 18 | from .ajt_common.monospace_line_edit import MonoSpaceLineEdit 19 | from .consts import * 20 | 21 | 22 | class FileNameEdit(MonoSpaceLineEdit): 23 | _edit_max_len = 119 24 | 25 | def __init__(self, text: str): 26 | super().__init__() 27 | self.setMaxLength(self._edit_max_len) 28 | self.setText(text) 29 | qconnect(self.textChanged, self.validate) 30 | self._valid = True 31 | 32 | @property 33 | def valid(self) -> bool: 34 | return self._valid 35 | 36 | def text(self): 37 | return super().text().strip("-_ ") 38 | 39 | def validate(self): 40 | self._valid = len(self.text().encode("utf-8")) <= self._edit_max_len and re.fullmatch( 41 | r'^[^\[\]<>:\'"/|?*\\]+\.\w{,5}$', self.text() 42 | ) 43 | if self._valid: 44 | cast(QWidget, self).setStyleSheet("") 45 | else: 46 | cast(QWidget, self).setStyleSheet("background-color: #eb6b60;") 47 | 48 | 49 | class MediaRenameDialog(QDialog): 50 | def __init__(self, editor: Editor, note: Note, filenames: list[str], *args, **kwargs): 51 | super().__init__(parent=editor.widget, *args, **kwargs) 52 | self.editor = editor 53 | self.edits = {filename: FileNameEdit(text=filename) for filename in filenames} 54 | self.note = note 55 | self.edits_layout = QVBoxLayout() 56 | self.bottom_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) 57 | cast(QDialog, self).setWindowTitle(f"{ADDON_FULL_NAME}: rename files") 58 | self.setMinimumWidth(WINDOW_MIN_WIDTH) 59 | self.setLayout(self.make_layout()) 60 | self.connect_ui_elements() 61 | tweak_window(self) 62 | 63 | def show(self) -> None: 64 | for widget in self.edits.values(): 65 | self.edits_layout.addWidget(widget) 66 | return cast(QDialog, super()).show() 67 | 68 | def make_layout(self) -> QLayout: 69 | layout = QVBoxLayout() 70 | layout.addLayout(self.edits_layout) 71 | layout.addWidget(self.bottom_box) 72 | return layout 73 | 74 | def connect_ui_elements(self): 75 | qconnect(self.bottom_box.accepted, self.accept) 76 | qconnect(self.bottom_box.rejected, self.reject) 77 | 78 | def to_rename(self) -> Iterable[tuple[str, str]]: 79 | widget: QLineEdit 80 | for old_filename, widget in self.edits.items(): 81 | if old_filename != (new_filename := widget.text()): 82 | yield old_filename, new_filename 83 | 84 | def accept(self) -> None: 85 | if all(e.valid for e in self.edits.values()) and (to_rename := list(self.to_rename())): 86 | rename_media_files(to_rename, self.note, self.editor) 87 | super().accept() 88 | 89 | 90 | def rename_file(old_filename: str, new_filename: str) -> str: 91 | print(f"{old_filename} => {new_filename}") 92 | with open(os.path.join(mw.col.media.dir(), old_filename), "rb") as f: 93 | return mw.col.media.write_data(new_filename, f.read()) 94 | 95 | 96 | def rename_media_files(to_rename: list[tuple[str, str]], note: Note, parent: Editor): 97 | for old_filename, new_filename in to_rename: 98 | try: 99 | new_filename = rename_file(old_filename, new_filename) 100 | except FileNotFoundError: 101 | showCritical(f"{old_filename} doesn't exist.", title="Couldn't rename file.") 102 | continue 103 | for field_name, field_value in note.items(): 104 | note[field_name] = field_value.replace(old_filename, new_filename) 105 | CollectionOp(parent=parent.widget, op=lambda col: col.update_note(note)).success( 106 | lambda out: tooltip(f"Renamed {len(to_rename)} files", parent=parent.parentWindow) 107 | ).run_in_background() 108 | 109 | 110 | class Menus: 111 | """Holds a reference to MediaRenameDialog to avoid spawning the same window multiple times.""" 112 | 113 | file_rename_dialog: Optional[MediaRenameDialog] = None 114 | 115 | @classmethod 116 | def del_ref(cls) -> None: 117 | cls.file_rename_dialog = None 118 | 119 | @classmethod 120 | def show_rename_dialog(cls, editor: Editor) -> None: 121 | if cls.file_rename_dialog: 122 | return 123 | elif editor.note and (filenames := find_all_media(join_fields(editor.note.fields))): 124 | d = cls.file_rename_dialog = MediaRenameDialog(editor, editor.note, filenames) 125 | qconnect(d.finished, lambda result: cls.del_ref()) 126 | d.show() 127 | 128 | @classmethod 129 | def add_editor_button(cls, buttons: list[str], editor: Editor) -> None: 130 | b = editor.addButton( 131 | icon=os.path.join(ADDON_PATH, "icons", "edit.svg"), 132 | cmd="ajt__rename_media_files", 133 | func=cls.show_rename_dialog, 134 | tip="Rename media files referenced by note.", 135 | ) 136 | buttons.append(b) 137 | 138 | 139 | def init() -> None: 140 | gui_hooks.editor_did_init_buttons.append(Menus.add_editor_button) 141 | -------------------------------------------------------------------------------- /media_converter/menus.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | import functools 4 | import os.path 5 | 6 | from aqt import gui_hooks, mw 7 | from aqt.editor import EditorWebView 8 | 9 | from .ajt_common.about_menu import menu_root_entry 10 | from .common import * 11 | from .config import config 12 | from .consts import ADDON_FULL_NAME, ADDON_NAME, ADDON_PATH 13 | from .dialogs.main_settings_dialog import AnkiMainSettingsDialog 14 | from .file_converters.file_converter import FFmpegNotFoundError 15 | from .file_converters.image_converter import ffmpeg_not_found_dialog 16 | from .file_converters.on_paste_converter import ( 17 | TEMP_IMAGE_FORMAT, 18 | OnPasteConverter, 19 | mime_to_image_file, 20 | ) 21 | from .utils.show_options import ShowOptions 22 | from .utils.temp_file import TempFile 23 | 24 | 25 | def setup_mainwindow_menu(): 26 | """ 27 | setup menu in anki 28 | """ 29 | root_menu = menu_root_entry() 30 | 31 | def open_settings(): 32 | dialog = AnkiMainSettingsDialog(config, mw) 33 | dialog.show() 34 | 35 | action = QAction(f"{ADDON_NAME} Options...", root_menu) 36 | qconnect(action.triggered, open_settings) 37 | root_menu.addAction(action) 38 | 39 | 40 | def action_tooltip(): 41 | return ( 42 | f"{ADDON_FULL_NAME}: Paste" 43 | if not config.shortcut 44 | else f"{ADDON_FULL_NAME}: Paste ({key_to_str(config.shortcut)})" 45 | ) 46 | 47 | 48 | def convert_and_insert(editor: Editor, source: ShowOptions) -> None: 49 | mime: QMimeData = editor.mw.app.clipboard().mimeData() 50 | conv = OnPasteConverter(editor, source) 51 | with TempFile(suffix=f".{TEMP_IMAGE_FORMAT}") as tmp_file: 52 | if to_convert := mime_to_image_file(mime, tmp_file.path()): 53 | try: 54 | new_file_path = conv.convert_mime(to_convert) 55 | except FFmpegNotFoundError: 56 | ffmpeg_not_found_dialog() 57 | except FileNotFoundError: 58 | conv.tooltip("File not found.") 59 | except Exception as ex: 60 | conv.tooltip(ex) 61 | else: 62 | # File has been converted. 63 | insert_image_html(editor, os.path.basename(new_file_path)) 64 | conv.result_tooltip(new_file_path) 65 | 66 | 67 | def on_editor_will_show_context_menu(webview: EditorWebView, menu: QMenu): 68 | if config.get("show_context_menu_entry") is True: 69 | action: QAction = menu.addAction(action_tooltip()) 70 | qconnect( 71 | action.triggered, functools.partial(convert_and_insert, editor=webview.editor, source=ShowOptions.paste) 72 | ) 73 | 74 | 75 | def on_editor_did_init_buttons(buttons: list[str], editor: Editor): 76 | """ 77 | Append a new editor button if it's enabled. 78 | """ 79 | if config.show_editor_button is True: 80 | buttons.append( 81 | editor.addButton( 82 | icon=os.path.join(ADDON_PATH, "icons", "webp.png"), 83 | cmd=f"ajt__{ADDON_FULL_NAME.lower().replace(' ', '_')}_button", 84 | func=functools.partial(convert_and_insert, source=ShowOptions.toolbar), 85 | tip=action_tooltip(), 86 | keys=config.shortcut or None, 87 | ) 88 | ) 89 | 90 | 91 | def on_editor_did_init_shortcuts(cuts: list[tuple], self: Editor): 92 | """ 93 | Add keyboard shortcut if it is set and if editor button is disabled. 94 | If editor button is enabled, it has its own keyboard shortcut. 95 | """ 96 | if config.show_editor_button is False and config.shortcut: 97 | cuts.append((config.shortcut, functools.partial(convert_and_insert, editor=self, source=ShowOptions.paste))) 98 | 99 | 100 | def setup_editor_menus(): 101 | gui_hooks.editor_did_init_buttons.append(on_editor_did_init_buttons) 102 | gui_hooks.editor_did_init_shortcuts.append(on_editor_did_init_shortcuts) 103 | gui_hooks.editor_will_show_context_menu.append(on_editor_will_show_context_menu) 104 | 105 | 106 | def init() -> None: 107 | setup_mainwindow_menu() 108 | setup_editor_menus() 109 | -------------------------------------------------------------------------------- /media_converter/support/.ensure_dir: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ajatt-Tools/PasteImagesAsWebP/4521052e0c4764bafb56342b041f4261745f7a15/media_converter/support/.ensure_dir -------------------------------------------------------------------------------- /media_converter/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | -------------------------------------------------------------------------------- /media_converter/utils/converter_interfaces.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | from collections.abc import Iterable 5 | from typing import Callable 6 | 7 | 8 | class FileNamePatterns: 9 | _prefixes: dict[str, Callable[[], str]] 10 | _suffixes: dict[str, Callable[[], str]] 11 | _patterns: list[str] 12 | 13 | def __init__(self): 14 | self._prefixes = { 15 | "paste": self._default_prefix, 16 | "sort-field": self._sort_field, 17 | "custom-field": self._custom_field, 18 | "current-field": self._current_field, 19 | } 20 | self._suffixes = { 21 | "time-number": self._time_number, 22 | "time-human": self._time_human, 23 | } 24 | self._patterns = [f"{prefix}_{suffix}" for prefix in self._prefixes for suffix in self._suffixes] 25 | 26 | def all_examples(self) -> Iterable[str]: 27 | return self._patterns 28 | 29 | @staticmethod 30 | def _default_prefix(): 31 | return "paste" 32 | 33 | @staticmethod 34 | def _sort_field() -> str: 35 | return "sort-field" 36 | 37 | @staticmethod 38 | def _custom_field() -> str: 39 | return "custom-field" 40 | 41 | @staticmethod 42 | def _current_field() -> str: 43 | return "current-field" 44 | 45 | @staticmethod 46 | def _time_number(): 47 | return "epoch-time" 48 | 49 | @staticmethod 50 | def _time_human(): 51 | return "date-time" 52 | -------------------------------------------------------------------------------- /media_converter/utils/file_paths_factory.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | import functools 5 | import itertools 6 | import os 7 | import random 8 | import re 9 | import time 10 | import unicodedata 11 | from time import gmtime, strftime 12 | from typing import Callable, Optional 13 | 14 | from anki.notes import Note 15 | from anki.utils import html_to_text_line 16 | from aqt.editor import Editor 17 | 18 | from ..config import MediaConverterConfig, get_global_config 19 | from .converter_interfaces import FileNamePatterns 20 | 21 | 22 | def compatible_filename(f: Callable[..., str]): 23 | max_len_bytes = 90 24 | 25 | def replace_forbidden_chars(s: str) -> str: 26 | return re.sub(r'[\[\]<>:"/|?*\\;,&\']+', " ", s, flags=re.MULTILINE | re.IGNORECASE) 27 | 28 | def sub_spaces(s: str) -> str: 29 | return re.sub(r" +", " ", s) 30 | 31 | @functools.wraps(f) 32 | def wrapper(*args, **kwargs) -> str: 33 | s = f(*args, **kwargs) 34 | s = html_to_text_line(s) 35 | s = s.encode("utf-8")[:max_len_bytes].decode("utf-8", errors="ignore") 36 | s = unicodedata.normalize("NFC", s) 37 | s = replace_forbidden_chars(s) 38 | s = s.lower() 39 | s = sub_spaces(s) 40 | s = s.strip("-_ ") 41 | return s or "file" 42 | 43 | return wrapper 44 | 45 | 46 | def ensure_unique(file_path: str) -> str: 47 | name, ext = os.path.splitext(file_path) 48 | while os.path.isfile(file_path): 49 | file_path = name + "_" + str(random.randint(0, 9999)).zfill(4) + ext 50 | return file_path 51 | 52 | 53 | def note_sort_field_content(note: Note) -> str: 54 | return note.values()[note.note_type()["sortf"]] 55 | 56 | 57 | class FilePathFactory(FileNamePatterns): 58 | _note: Optional[Note] 59 | _editor: Optional[Editor] 60 | _config: MediaConverterConfig 61 | 62 | def __init__(self, note: Optional[Note], editor: Optional[Editor]) -> None: 63 | super().__init__() 64 | self._config = get_global_config() 65 | self._note = note 66 | self._editor = editor 67 | 68 | def make_unique_filepath(self, dest_dir: str, original_filename: Optional[str], extension: str) -> str: 69 | new_file_path = os.path.join(dest_dir, self._make_filename_no_ext(original_filename) + extension) 70 | return ensure_unique(new_file_path) 71 | 72 | @compatible_filename 73 | def _make_filename_no_ext(self, original_filename: Optional[str]) -> str: 74 | if original_filename and self._config.preserve_original_filenames: 75 | return os.path.splitext(original_filename)[0] 76 | 77 | def get_pattern() -> str: 78 | try: 79 | return self._patterns[self._config.filename_pattern_num] 80 | except IndexError: 81 | return self._patterns[0] 82 | 83 | return self._apply_pattern(get_pattern()) 84 | 85 | def _apply_pattern(self, pattern: str) -> str: 86 | for keyword, replace_fn in itertools.chain(self._prefixes.items(), self._suffixes.items()): 87 | pattern = pattern.replace(keyword, replace_fn()) 88 | return pattern 89 | 90 | def _sort_field(self) -> str: 91 | if self._note: 92 | try: 93 | return note_sort_field_content(self._note) 94 | except (AttributeError, TypeError, KeyError): 95 | pass 96 | return "image" 97 | 98 | def _custom_field(self) -> str: 99 | if self._note: 100 | try: 101 | return self._note[self._config.custom_name_field] 102 | except (AttributeError, TypeError, KeyError): 103 | pass 104 | return self._sort_field() 105 | 106 | def _current_field(self) -> str: 107 | if self._note and self._editor and self._editor.currentField is not None: 108 | try: 109 | return self._note.values()[self._editor.currentField] 110 | except (AttributeError, TypeError, KeyError): 111 | pass 112 | return self._sort_field() 113 | 114 | @staticmethod 115 | def _time_number(): 116 | return str(int(time.time() * 1000)) 117 | 118 | @staticmethod 119 | def _time_human(): 120 | return strftime("%d-%b-%Y_%H-%M-%S", gmtime()) 121 | -------------------------------------------------------------------------------- /media_converter/utils/mime_helper.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | import re 5 | from collections.abc import Iterable 6 | from typing import Optional 7 | 8 | import requests 9 | from aqt.qt import * 10 | from requests.exceptions import InvalidSchema, Timeout 11 | 12 | from ..consts import REQUEST_HEADERS, REQUEST_TIMEOUTS 13 | 14 | 15 | def urls_from_html(html: str) -> list: 16 | return re.findall('(?<= src=")http[^"]+(?=")', html) 17 | 18 | 19 | def data_from_html(html: str) -> list[QByteArray]: 20 | return [QByteArray.fromBase64(data.encode("ascii")) for data in re.findall('(?<=;base64,)[^"]+(?=")', html)] 21 | 22 | 23 | def iter_urls(mime: QMimeData) -> Iterable[str]: 24 | return (url.toString() for url in mime.urls() if not url.isLocalFile()) 25 | 26 | 27 | def iter_files(mime: QMimeData) -> Iterable[str]: 28 | return (url.toLocalFile() for url in mime.urls() if url.isLocalFile()) 29 | 30 | 31 | def image_from_url(src_url: str) -> Optional[QImage]: 32 | image = QImage() 33 | try: 34 | with requests.get(src_url, timeout=REQUEST_TIMEOUTS, headers=REQUEST_HEADERS) as r: 35 | image.loadFromData(r.content) 36 | except (Timeout, InvalidSchema, OSError): 37 | return None 38 | return image 39 | 40 | 41 | def image_from_file(filepath: str): 42 | with open(filepath, "rb") as f: 43 | return QImage.fromData(f.read()) 44 | 45 | 46 | def image_candidates(mime: QMimeData) -> Iterable[Optional[QImage]]: 47 | yield mime.imageData() 48 | for data in data_from_html(mime.html()): 49 | yield QImage.fromData(data) 50 | for file in iter_files(mime): 51 | yield image_from_file(file) 52 | for url in iter_urls(mime): 53 | yield image_from_url(url) 54 | for url in urls_from_html(mime.html()): 55 | yield image_from_url(url) 56 | -------------------------------------------------------------------------------- /media_converter/utils/show_options.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | import enum 5 | from typing import NamedTuple 6 | 7 | 8 | class ShowOptions(enum.Enum): 9 | toolbar = "Toolbar" 10 | drag_and_drop = "On drag and drop" 11 | add_note = "Note added" 12 | paste = "On paste" 13 | 14 | 15 | class ImageDimensions(NamedTuple): 16 | width: int 17 | height: int 18 | 19 | 20 | def main(): 21 | print(ShowOptions["toolbar"]) 22 | 23 | 24 | if __name__ == "__main__": 25 | main() 26 | -------------------------------------------------------------------------------- /media_converter/utils/temp_file.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | import os 4 | from tempfile import mkstemp 5 | 6 | 7 | class TempFileException(Exception): 8 | pass 9 | 10 | 11 | class TempFile(os.PathLike): 12 | """A simple class for automatic management of temp file paths""" 13 | 14 | _tmp_filepath: str = "" 15 | _fd: int = 0 16 | _opened: bool = False 17 | 18 | def __init__(self, suffix: str = ".png"): 19 | self._fd, self._tmp_filepath = mkstemp(prefix="ajt__", suffix=suffix) 20 | self._opened = True 21 | 22 | def __enter__(self): 23 | return self 24 | 25 | def __exit__(self, exc_type, exc_value, trace_back): 26 | self.close() 27 | 28 | def __del__(self): 29 | self.close() 30 | 31 | def __fspath__(self) -> str: 32 | return self.path() 33 | 34 | def __repr__(self) -> str: 35 | return self.path() 36 | 37 | def __str__(self) -> str: 38 | return self.path() 39 | 40 | def path(self) -> str: 41 | if not (self._opened and self._tmp_filepath): 42 | raise TempFileException("error creating temp file") 43 | return self._tmp_filepath 44 | 45 | def close(self): 46 | if self._opened is True: 47 | os.close(self._fd) 48 | os.remove(self._tmp_filepath) 49 | self._opened = False 50 | -------------------------------------------------------------------------------- /media_converter/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ren Tatsumoto 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | -------------------------------------------------------------------------------- /media_converter/widgets/audio_settings_widget.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | from aqt.qt import * 5 | 6 | from ..ajt_common.enum_select_combo import EnumSelectCombo 7 | from ..config import AudioContainer, MediaConverterConfig 8 | from ..dialogs.settings_dialog_base import ConfigPropMixIn, WidgetHasName 9 | from .audio_slider_box import AudioSliderBox 10 | 11 | 12 | class AudioSettings(WidgetHasName, ConfigPropMixIn): 13 | name: str = "Audio settings" 14 | _audio_container_combo: EnumSelectCombo 15 | 16 | def __init__(self, config: MediaConverterConfig, parent=None) -> None: 17 | super().__init__(parent) 18 | self._config = config 19 | self._enable_checkbox = QCheckBox("Enable audio conversion") 20 | self._audio_container_combo = EnumSelectCombo(enum_type=AudioContainer) 21 | self._bitrate_slider = AudioSliderBox() 22 | self._layout = QFormLayout() 23 | self._setup_ui() 24 | self._add_tooltips() 25 | 26 | def _setup_ui(self) -> None: 27 | self._layout.addRow(self._enable_checkbox) 28 | self._layout.addRow("Audio container", self._audio_container_combo) 29 | self._layout.addRow("Audio bitrate", self._bitrate_slider) 30 | self.setLayout(self._layout) 31 | 32 | def _add_tooltips(self) -> None: 33 | self._enable_checkbox.setToolTip( 34 | "Enable conversion of audio files.\n" 35 | "FFmpeg must be installed in the system.\n" 36 | "If running Arch, run `sudo pacman -S ffmpeg` to install it." 37 | ) 38 | self._audio_container_combo.setToolTip( 39 | "Audio container, or the file extension\n" 40 | "that will be used for audio files.\n" 41 | "Choose the one that works on your computer.\n" 42 | "Regardless of the chosen container, the target codec is always Opus." 43 | ) 44 | 45 | def set_initial_values(self) -> None: 46 | self._enable_checkbox.setChecked(self.config.enable_audio_conversion) 47 | self._audio_container_combo.setCurrentName(self.config.audio_container) 48 | self._bitrate_slider.audio_bitrate_k = self.config.audio_bitrate_k 49 | 50 | def pass_settings_to_config(self) -> None: 51 | self.config["enable_audio_conversion"] = self._enable_checkbox.isChecked() 52 | self.config["audio_container"] = self._audio_container_combo.currentName() 53 | self.config.audio_bitrate_k = self._bitrate_slider.audio_bitrate_k 54 | -------------------------------------------------------------------------------- /media_converter/widgets/audio_slider_box.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | from aqt.qt import * 4 | 5 | from ..widgets.rich_slider import RichSlider 6 | 7 | MIN_AUDIO_BITRATE_K = 8 8 | MAX_AUDIO_BITRATE_K = 600 9 | 10 | 11 | class AudioSliderBox(QWidget): 12 | def __init__(self, parent=None) -> None: 13 | super().__init__(parent) 14 | self._slider = RichSlider("Bitrate", "kbit/s", lower_limit=MIN_AUDIO_BITRATE_K, upper_limit=MAX_AUDIO_BITRATE_K) 15 | self._setup_ui() 16 | 17 | def _setup_ui(self) -> None: 18 | layout = QHBoxLayout() 19 | for widget in self._slider.widgets: 20 | layout.addWidget(widget) 21 | layout.setContentsMargins(0, 0, 0, 0) 22 | self.setLayout(layout) 23 | 24 | @property 25 | def audio_bitrate_k(self) -> int: 26 | return self._slider.value 27 | 28 | @audio_bitrate_k.setter 29 | def audio_bitrate_k(self, kbit_per_s: int) -> None: 30 | self._slider.value = kbit_per_s 31 | -------------------------------------------------------------------------------- /media_converter/widgets/behavior_settings_widget.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | from aqt.qt import * 5 | 6 | from ..ajt_common.anki_field_selector import AnkiFieldSelector 7 | from ..ajt_common.checkable_combobox import CheckableComboBox 8 | from ..ajt_common.enum_select_combo import EnumSelectCombo 9 | from ..config import ImageFormat, MediaConverterConfig 10 | from ..dialogs.settings_dialog_base import ConfigPropMixIn, WidgetHasName 11 | from ..utils.converter_interfaces import FileNamePatterns 12 | from ..utils.show_options import ShowOptions 13 | 14 | # Keys in the config file that will be converted to checkboxes. 15 | VISIBLE_BOOL_CONFIG_KEYS = { 16 | "drag_and_drop": "Convert images on drag and drop", 17 | "copy_paste": "Convert images on copy-paste", 18 | "convert_on_note_add": "Convert when AnkiConnect creates new notes", 19 | "preserve_original_filenames": "Preserve original filenames, if available", 20 | "avoid_upscaling": "Avoid upscaling", 21 | "show_editor_button": "Show a Converter button on the Editor Toolbar", 22 | "show_context_menu_entry": "Show a separate context menu item", 23 | "delete_original_file_on_convert": "Delete original file after conversion", 24 | } 25 | 26 | 27 | def create_when_show_dialog_combo_box() -> CheckableComboBox: 28 | """ 29 | When to show the settings dialog: (toolbar button clicked, when a file is drag-and-dropped, etc.) 30 | """ 31 | combobox = CheckableComboBox() 32 | for option in ShowOptions: 33 | combobox.addCheckableItem(option.value, option) 34 | return combobox 35 | 36 | 37 | def create_filename_pattern_combo_box() -> QComboBox: 38 | """ 39 | How to name the newly created files. 40 | """ 41 | combobox = QComboBox() 42 | for option in FileNamePatterns().all_examples(): 43 | combobox.addItem(option) 44 | return combobox 45 | 46 | 47 | class BehaviorSettings(WidgetHasName, ConfigPropMixIn): 48 | name: str = "Behavior settings" 49 | 50 | def __init__(self, config: MediaConverterConfig, parent=None) -> None: 51 | super().__init__(parent) 52 | self._config = config 53 | self._layout = QFormLayout() 54 | # Create widgets 55 | self._image_format_combo_box = EnumSelectCombo(ImageFormat) 56 | self._when_show_dialog_combo_box = create_when_show_dialog_combo_box() 57 | self._filename_pattern_combo_box = create_filename_pattern_combo_box() 58 | self._custom_name_field_combo_box = AnkiFieldSelector(self) 59 | self._excluded_image_containers_edit = QLineEdit() 60 | self._excluded_audio_containers_edit = QLineEdit() 61 | self._checkboxes = {key: QCheckBox(text) for key, text in VISIBLE_BOOL_CONFIG_KEYS.items()} 62 | # Setup UI 63 | self._setup_ui() 64 | self._add_tooltips() 65 | 66 | def _setup_ui(self) -> None: 67 | self._layout.addRow("Image format", self._image_format_combo_box) 68 | self._layout.addRow("Show Settings", self._when_show_dialog_combo_box) 69 | self._layout.addRow("Filename pattern", self._filename_pattern_combo_box) 70 | self._layout.addRow("Custom name field", self._custom_name_field_combo_box) 71 | self._layout.addRow("Excluded image formats", self._excluded_image_containers_edit) 72 | self._layout.addRow("Excluded audio formats", self._excluded_audio_containers_edit) 73 | for widget in self._checkboxes.values(): 74 | self._layout.addRow(widget) 75 | self.setLayout(self._layout) 76 | 77 | def _add_tooltips(self) -> None: 78 | # TODO handle audio files when a note is added 79 | self._checkboxes["convert_on_note_add"].setToolTip( 80 | "Convert images when a new note is added by an external tool, such as AnkiConnect.\n" 81 | "Does not apply to the native Add dialog." 82 | ) 83 | self._excluded_image_containers_edit.setToolTip( 84 | "A comma-separated list of image file formats (extensions without the dot)\n" 85 | "that should be skipped when converting image files." 86 | ) 87 | self._excluded_audio_containers_edit.setToolTip( 88 | "A comma-separated list of audio file formats (extensions without the dot)\n" 89 | "that should be skipped when converting audio files." 90 | ) 91 | 92 | def set_initial_values(self) -> None: 93 | self._image_format_combo_box.setCurrentName(self.config.image_format) 94 | self._when_show_dialog_combo_box.setCheckedData(self.config.show_settings()) 95 | self._filename_pattern_combo_box.setCurrentIndex(self.config["filename_pattern_num"]) 96 | self._custom_name_field_combo_box.setCurrentText(self.config["custom_name_field"]) 97 | self._excluded_image_containers_edit.setText(self.config["excluded_image_containers"].lower()) 98 | self._excluded_audio_containers_edit.setText(self.config["excluded_audio_containers"].lower()) 99 | for key, widget in self._checkboxes.items(): 100 | widget.setChecked(self.config[key]) 101 | 102 | def pass_settings_to_config(self) -> None: 103 | self.config.set_show_options(self._when_show_dialog_combo_box.checkedData()) 104 | self.config["image_format"] = self._image_format_combo_box.currentName() 105 | self.config["filename_pattern_num"] = self._filename_pattern_combo_box.currentIndex() 106 | self.config["custom_name_field"] = self._custom_name_field_combo_box.currentText() 107 | self.config["excluded_image_containers"] = self._excluded_image_containers_edit.text().lower().strip() 108 | self.config["excluded_audio_containers"] = self._excluded_audio_containers_edit.text().lower().strip() 109 | for key, widget in self._checkboxes.items(): 110 | self.config[key] = widget.isChecked() 111 | -------------------------------------------------------------------------------- /media_converter/widgets/bulk_convert_settings_widget.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | from aqt.qt import * 5 | 6 | from ..ajt_common.multiple_choice_selector import MultipleChoiceSelector 7 | from ..config import ImageFormat, MediaConverterConfig 8 | from ..dialogs.settings_dialog_base import ConfigPropMixIn, WidgetHasName 9 | 10 | 11 | class EnableReconvertCheckbox(QCheckBox): 12 | def __init__(self, enabled_image_format: ImageFormat) -> None: 13 | super().__init__() 14 | self.setText(f"Reconvert existing {enabled_image_format.name} images") 15 | 16 | 17 | class BulkConvertSettings(WidgetHasName, ConfigPropMixIn): 18 | name: str = "Bulk-convert settings" 19 | _field_selector: MultipleChoiceSelector 20 | _reconvert_checkbox: QCheckBox 21 | 22 | def __init__(self, config: MediaConverterConfig, parent=None) -> None: 23 | super().__init__(parent) 24 | self._config = config 25 | self._field_selector = MultipleChoiceSelector() 26 | self._reconvert_checkbox = EnableReconvertCheckbox(self.config.image_format) 27 | self._layout = QFormLayout() 28 | self._setup_ui() 29 | self._add_tooltips() 30 | 31 | @property 32 | def field_selector(self) -> MultipleChoiceSelector: 33 | return self._field_selector 34 | 35 | def _setup_ui(self) -> None: 36 | self._layout.addRow(self._field_selector) 37 | self._layout.addRow(self._reconvert_checkbox) 38 | self.setLayout(self._layout) 39 | 40 | def _add_tooltips(self) -> None: 41 | self._field_selector.setToolTip( 42 | "When enabled, search for image files only in selected fields.\n" "When disabled, search in all fields." 43 | ) 44 | self._reconvert_checkbox.setToolTip( 45 | "If an image was converted to the target format before,\n" 46 | "convert it again.\n" 47 | "For example, change quality or dimensions." 48 | ) 49 | 50 | def set_initial_values(self, all_field_names: list[str]) -> None: 51 | self._field_selector.set_texts(all_field_names) 52 | self._field_selector.set_checked_texts(self.config["bulk_convert_fields"]) 53 | self._reconvert_checkbox.setChecked(self.config.bulk_reconvert) 54 | 55 | def pass_settings_to_config(self) -> None: 56 | self.config["bulk_convert_fields"] = self._field_selector.checked_texts() 57 | self.config.bulk_reconvert = self._reconvert_checkbox.isChecked() 58 | -------------------------------------------------------------------------------- /media_converter/widgets/image_settings_widget.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | from aqt.qt import * 4 | 5 | from ..config import MediaConverterConfig 6 | from ..dialogs.settings_dialog_base import ConfigPropMixIn, WidgetHasName 7 | from .image_slider_box import ImageSliderBox 8 | from .presets_editor import PresetsEditor 9 | 10 | 11 | class ImageSettings(WidgetHasName, ConfigPropMixIn): 12 | name: str = "Image settings" 13 | _enable_checkbox: QCheckBox 14 | _img_sliders: ImageSliderBox 15 | _presets_editor: PresetsEditor 16 | 17 | def __init__(self, config: MediaConverterConfig, parent=None) -> None: 18 | super().__init__(parent) 19 | self._config = config 20 | self._layout = QFormLayout() 21 | self._enable_checkbox = QCheckBox("Enable image conversion") 22 | self._img_sliders = ImageSliderBox() 23 | self._presets_editor = PresetsEditor("Presets", sliders=self._img_sliders) 24 | self._setup_ui() 25 | self._add_tooltips() 26 | 27 | def _setup_ui(self) -> None: 28 | self._layout.addRow(self._enable_checkbox) 29 | self._layout.addRow(self._img_sliders) 30 | self._layout.addRow(self._presets_editor) 31 | self.setLayout(self._layout) 32 | 33 | def _add_tooltips(self) -> None: 34 | self._enable_checkbox.setToolTip( 35 | "Enable conversion of image files.\n" 36 | "When target image format is set to `Avif`,\n" 37 | "FFmpeg must be installed in the system.\n" 38 | "If running Arch, run `sudo pacman -S ffmpeg` to install it." 39 | ) 40 | 41 | def set_dimensions(self, width: int, height: int) -> None: 42 | if self._img_sliders.image_width > 0: 43 | self._img_sliders.image_width = width 44 | if self._img_sliders.image_height > 0: 45 | self._img_sliders.image_height = height 46 | 47 | def set_initial_values(self) -> None: 48 | self._enable_checkbox.setChecked(self.config.enable_image_conversion) 49 | self._img_sliders.set_limits(self.config["max_image_width"], self.config["max_image_height"]) 50 | self._img_sliders.image_width = self.config.image_width 51 | self._img_sliders.image_height = self.config.image_height 52 | self._img_sliders.image_quality = self.config.image_quality 53 | self._presets_editor.set_items(self.config["saved_presets"]) 54 | 55 | def pass_settings_to_config(self) -> None: 56 | self.config["enable_image_conversion"] = self._enable_checkbox.isChecked() 57 | self.config.update(self._img_sliders.as_dict()) 58 | self.config["saved_presets"] = self._presets_editor.as_list() 59 | -------------------------------------------------------------------------------- /media_converter/widgets/image_slider_box.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ren Tatsumoto 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | from collections.abc import Iterable 4 | from typing import NamedTuple, TypedDict 5 | 6 | from aqt.qt import * 7 | 8 | from .rich_slider import RichSlider 9 | 10 | 11 | class Sliders(NamedTuple): 12 | image_width: RichSlider 13 | image_height: RichSlider 14 | image_quality: RichSlider 15 | 16 | 17 | class PresetDict(TypedDict): 18 | image_height: int 19 | image_quality: int 20 | image_width: int 21 | 22 | 23 | def sliders_to_grid(sliders: Iterable[RichSlider]) -> QLayout: 24 | grid = QGridLayout() 25 | slider: RichSlider 26 | for y_index, slider in enumerate(sliders): 27 | grid.addWidget(QLabel(slider.title), y_index, 0) 28 | for x_index, widget in enumerate(slider.widgets, start=1): 29 | grid.addWidget(widget, y_index, x_index) 30 | return grid 31 | 32 | 33 | class ImageSliderBox(QWidget): 34 | def __init__(self, max_width: int = 1000, max_height: int = 1000) -> None: 35 | super().__init__() 36 | self._sliders = Sliders( 37 | image_width=RichSlider("Width", "px", upper_limit=max_width), 38 | image_height=RichSlider("Height", "px", upper_limit=max_height), 39 | image_quality=RichSlider("Quality", "%", upper_limit=100), 40 | ) 41 | self._setup_ui() 42 | self.set_tooltips() 43 | 44 | def _setup_ui(self) -> None: 45 | layout = sliders_to_grid(self._sliders) 46 | layout.setContentsMargins(0, 0, 0, 0) 47 | self.setLayout(layout) 48 | 49 | def set_limits(self, width: int, height: int) -> None: 50 | self._sliders.image_width.set_upper_limit(width) 51 | self._sliders.image_height.set_upper_limit(height) 52 | 53 | def as_dict(self) -> PresetDict: 54 | return {key: slider.value for key, slider in self._sliders._asdict().items()} 55 | 56 | @property 57 | def image_width(self) -> int: 58 | return self._sliders.image_width.value 59 | 60 | @image_width.setter 61 | def image_width(self, value: int): 62 | self._sliders.image_width.value = value 63 | 64 | @property 65 | def image_height(self) -> int: 66 | return self._sliders.image_height.value 67 | 68 | @image_height.setter 69 | def image_height(self, value: int): 70 | self._sliders.image_height.value = value 71 | 72 | @property 73 | def image_quality(self) -> int: 74 | return self._sliders.image_quality.value 75 | 76 | @image_quality.setter 77 | def image_quality(self, value: int): 78 | self._sliders.image_quality.value = value 79 | 80 | def set_tooltips(self): 81 | side_tooltip = str( 82 | "Desired %s.\n" 83 | "If either of the width or height parameters is 0,\n" 84 | "the value will be calculated preserving the aspect-ratio.\n" 85 | "If both values are 0, no resizing is performed (not recommended)." 86 | ) 87 | quality_tooltip = str( 88 | "Specify the compression factor between 0 and 100.\n" 89 | "A small factor produces a smaller file with lower quality.\n" 90 | "Best quality is achieved by using a value of 100." 91 | ) 92 | self._sliders.image_width.set_tooltip(side_tooltip % "width") 93 | self._sliders.image_height.set_tooltip(side_tooltip % "height") 94 | self._sliders.image_quality.set_tooltip(quality_tooltip) 95 | -------------------------------------------------------------------------------- /media_converter/widgets/presets_editor.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ren Tatsumoto 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | from aqt.qt import * 5 | 6 | from .image_slider_box import ImageSliderBox, PresetDict 7 | 8 | 9 | def preset_to_str(preset: PresetDict) -> str: 10 | return f"{preset['image_width']}x{preset['image_height']} @ {preset['image_quality']}" 11 | 12 | 13 | class PresetsEditor(QGroupBox): 14 | def __init__(self, name: str, sliders: ImageSliderBox) -> None: 15 | super().__init__(name) 16 | self._sliders = sliders 17 | self.combo = QComboBox() 18 | self.add_current = QPushButton("Add current") 19 | self.remove_selected = QPushButton("Remove selected") 20 | self.apply_selected = QPushButton("Apply selected") 21 | self.setLayout(self.create_layout()) 22 | self.connect_buttons() 23 | 24 | def create_layout(self) -> QLayout: 25 | layout = QGridLayout() 26 | layout.addWidget(self.combo, 0, 0, 1, 3) # row, col, row-span, col-span 27 | layout.addWidget(self.add_current, 1, 0) 28 | layout.addWidget(self.remove_selected, 1, 1) 29 | layout.addWidget(self.apply_selected, 1, 2) 30 | return layout 31 | 32 | def as_list(self) -> list[PresetDict]: 33 | return [self.combo.itemData(index) for index in range(self.combo.count())] 34 | 35 | def add_items(self, items: list[PresetDict]) -> None: 36 | for item in items: 37 | self.combo.addItem(preset_to_str(item), item) 38 | 39 | def set_items(self, items: list[PresetDict]) -> None: 40 | """ 41 | Remove all previously added items and add new items. 42 | """ 43 | self.combo.clear() 44 | self.add_items(items) 45 | 46 | def add_new_preset(self) -> None: 47 | self.combo.addItem(preset_to_str(preset := self._sliders.as_dict()), preset) 48 | 49 | def apply_selected_preset(self) -> None: 50 | data: PresetDict 51 | if data := self.combo.currentData(): 52 | self._sliders.image_width = data["image_width"] 53 | self._sliders.image_height = data["image_height"] 54 | self._sliders.image_quality = data["image_quality"] 55 | 56 | def connect_buttons(self) -> None: 57 | qconnect(self.add_current.clicked, self.add_new_preset) 58 | qconnect(self.remove_selected.clicked, lambda: self.combo.removeItem(self.combo.currentIndex())) 59 | qconnect(self.apply_selected.clicked, self.apply_selected_preset) 60 | -------------------------------------------------------------------------------- /media_converter/widgets/rich_slider.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ren Tatsumoto 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | from aqt.qt import * 5 | 6 | 7 | class RichSlider: 8 | """ 9 | This class acts like a struct holding a slider and a spinbox together. 10 | The two widgets are connected so that any change to one are reflected on the other. 11 | """ 12 | 13 | SLIDER_STEP = 5 14 | 15 | def __init__( 16 | self, title: str, unit: str = "px", lower_limit: int = 0, upper_limit: int = 100, step: int = SLIDER_STEP 17 | ) -> None: 18 | self.title = title 19 | self.slider = QSlider(Qt.Orientation.Horizontal) 20 | self.spinbox = QSpinBox() 21 | self.unitLabel = QLabel(unit) 22 | qconnect(self.slider.valueChanged, self.spinbox.setValue) 23 | qconnect(self.spinbox.valueChanged, self.slider.setValue) 24 | self.set_range(lower_limit, upper_limit) 25 | self._set_step(step) 26 | 27 | def set_range(self, start: int, stop: int) -> None: 28 | self.slider.setRange(start, stop) 29 | self.spinbox.setRange(start, stop) 30 | 31 | def set_upper_limit(self, limit: int) -> None: 32 | """ 33 | Set the maximum value the user is allowed to apply. 34 | """ 35 | return self.set_range(0, limit) 36 | 37 | def set_tooltip(self, tooltip: str) -> None: 38 | self.slider.setToolTip(tooltip) 39 | 40 | @property 41 | def widgets(self) -> tuple[QSlider, QSpinBox, QLabel]: 42 | return self.slider, self.spinbox, self.unitLabel 43 | 44 | @property 45 | def value(self) -> int: 46 | return self.slider.value() 47 | 48 | @value.setter 49 | def value(self, value: int) -> None: 50 | self.slider.setValue(value) 51 | self.spinbox.setValue(value) 52 | 53 | def _set_step(self, step: int) -> None: 54 | self.step = step 55 | self.spinbox.setSingleStep(step) 56 | -------------------------------------------------------------------------------- /media_converter/widgets/scale_settings_widget.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | import functools 4 | 5 | from aqt.qt import * 6 | 7 | from ..ajt_common.utils import q_emit 8 | from ..ajt_common.widget_placement import place_widgets_in_grid 9 | from ..config import MediaConverterConfig 10 | from ..dialogs.settings_dialog_base import ConfigPropMixIn, WidgetHasName 11 | 12 | 13 | class ScaleSettings(QGroupBox, WidgetHasName, ConfigPropMixIn): 14 | """ 15 | A widget where the user can scale the loaded image in one keypress (0.5x, 1x, 2x, etc.) 16 | """ 17 | 18 | factor_changed = pyqtSignal(float) 19 | name: str = "Scale settings" 20 | 21 | def __init__(self, config: MediaConverterConfig, title: str, parent=None) -> None: 22 | super().__init__(parent) 23 | self._config = config 24 | self.setTitle(title) 25 | self._setup_ui() 26 | self._add_tooltips() 27 | 28 | def _setup_ui(self) -> None: 29 | self.setLayout(self.create_scale_options_grid()) 30 | 31 | def create_scale_options_grid(self) -> QGridLayout: 32 | factors = (1 / 8, 1 / 4, 1 / 2, 1, 1.5, 2) 33 | widgets = [] 34 | for factor in factors: 35 | button = QPushButton(f"{factor}x") 36 | qconnect(button.clicked, functools.partial(self.on_factor_changed, factor)) 37 | widgets.append(button) 38 | return place_widgets_in_grid(widgets, n_columns=3, alignment=None) 39 | 40 | def on_factor_changed(self, factor: float) -> None: 41 | q_emit(self.factor_changed, factor) 42 | 43 | def _add_tooltips(self) -> None: 44 | pass 45 | -------------------------------------------------------------------------------- /playground/no_anki_config.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | import json 5 | import pathlib 6 | 7 | from media_converter.config import MediaConverterConfig 8 | 9 | 10 | class NoAnkiConfigView(MediaConverterConfig): 11 | """ 12 | Loads the default config without starting Anki. 13 | """ 14 | 15 | config_json_path = pathlib.Path(__file__).parent.parent / "media_converter" / "config.json" 16 | 17 | def _set_underlying_dicts(self) -> None: 18 | with open(self.config_json_path) as f: 19 | self._default_config = self._config = json.load(f) 20 | 21 | def write_config(self) -> None: 22 | print("write requested. doing nothing. config contents:") 23 | print(json.dumps(self._config, indent=4, ensure_ascii=False)) 24 | -------------------------------------------------------------------------------- /playground/run_bulk_convert_dialog.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | from aqt.qt import * 5 | 6 | from media_converter.dialogs.bulk_convert_dialog import BulkConvertDialog 7 | from playground.no_anki_config import NoAnkiConfigView 8 | 9 | 10 | def main() -> None: 11 | app = QApplication(sys.argv) 12 | cfg = NoAnkiConfigView() 13 | form = BulkConvertDialog(cfg) 14 | form.show() 15 | sys.exit(app.exec()) 16 | 17 | 18 | if __name__ == "__main__": 19 | main() 20 | -------------------------------------------------------------------------------- /playground/run_bulk_convert_result.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | from aqt.qt import * 5 | 6 | from media_converter.dialogs.bulk_convert_result_dialog import BulkConvertResultDialog 7 | from media_converter.file_converters.common import ConverterType, LocalFile 8 | from media_converter.file_converters.convert_result import ConvertResult 9 | 10 | 11 | def fill_fake_results(result: ConvertResult): 12 | for idx in range(1, 1000): 13 | result.add_failed(LocalFile(f"image_{idx}.jpg", ConverterType.image), RuntimeError("runtime error")) 14 | 15 | 16 | def main() -> None: 17 | app = QApplication(sys.argv) 18 | result = ConvertResult() 19 | fill_fake_results(result) 20 | dialog = BulkConvertResultDialog() 21 | dialog.set_result(result) 22 | dialog.show() 23 | sys.exit(app.exec()) 24 | 25 | 26 | if __name__ == "__main__": 27 | main() 28 | -------------------------------------------------------------------------------- /playground/run_main_settings_dialog.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | 4 | from aqt.qt import * 5 | 6 | from media_converter.dialogs.main_settings_dialog import MainSettingsDialog 7 | from playground.no_anki_config import NoAnkiConfigView 8 | 9 | 10 | def main() -> None: 11 | app = QApplication(sys.argv) 12 | cfg = NoAnkiConfigView() 13 | form = MainSettingsDialog(cfg) 14 | form.show() 15 | sys.exit(app.exec()) 16 | 17 | 18 | if __name__ == "__main__": 19 | main() 20 | -------------------------------------------------------------------------------- /playground/run_paste_image_dialog.py: -------------------------------------------------------------------------------- 1 | # Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools 2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html 3 | from typing import NamedTuple 4 | 5 | from aqt.qt import * 6 | 7 | from media_converter.dialogs.paste_image_dialog import PasteImageDialog 8 | from media_converter.utils.show_options import ImageDimensions 9 | from playground.no_anki_config import NoAnkiConfigView 10 | 11 | 12 | def main() -> None: 13 | app = QApplication(sys.argv) 14 | cfg = NoAnkiConfigView() 15 | form = PasteImageDialog(cfg, dimensions=ImageDimensions(640, 480)) 16 | form.show() 17 | sys.exit(app.exec()) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatchling>=1.24", 5 | "hatch-vcs>=0.4", 6 | ] 7 | 8 | [project] 9 | name = "media-converter" 10 | dynamic = ["version"] 11 | description = "An Anki add-on that makes your images small." 12 | readme = "README.md" 13 | requires-python = "~=3.9" # anki officially only runs on 3.9 14 | license = { file = "LICENSE" } 15 | keywords = ["ajatt"] 16 | authors = [ 17 | { name = "Ajatt-Tools and contributors" }, 18 | { name = "Ren Tatsumoto", email = "tatsu@autistici.org" }, 19 | ] 20 | classifiers = [ 21 | "Programming Language :: Python", 22 | "Programming Language :: Python :: 3.9", 23 | ] 24 | dependencies = [] 25 | 26 | [project.urls] 27 | Documentation = "https://github.com/Ajatt-Tools/PasteImagesAsWebP" 28 | Issues = "https://github.com/Ajatt-Tools/PasteImagesAsWebP/issues" 29 | Source = "https://github.com/Ajatt-Tools/PasteImagesAsWebP" 30 | 31 | [tool.hatch.version] 32 | source = "vcs" 33 | path = "media_converter/__about__.py" 34 | 35 | [tool.hatch.build.hooks.vcs] 36 | version-file = "media_converter/__about__.py" 37 | 38 | [tool.hatch.envs.dev] 39 | dependencies = [ 40 | "mypy>=1.0.0", 41 | "isort", 42 | "pytest", 43 | "aqt[qt6]", 44 | "pyupgrade", 45 | ] 46 | python = "3.9" 47 | 48 | [tool.hatch.envs.dev.scripts] 49 | # run as `hatch run dev:scriptname` 50 | check = "mypy --install-types --non-interactive {args:media_converter tests}" 51 | test = "pytest" 52 | testv = "pytest -vvv -s" 53 | format = "bash \"$(git rev-parse --show-toplevel)/scripts/format.sh\" " 54 | package = "bash \"$(git rev-parse --show-toplevel)/scripts/package.sh\" " 55 | 56 | [tool.coverage.run] 57 | source_pkgs = ["media_converter", "tests"] 58 | branch = true 59 | parallel = true 60 | omit = [ 61 | "media_converter/__about__.py", 62 | ] 63 | 64 | [tool.coverage.paths] 65 | media_converter = ["media_converter", "*/media_converter/media_converter"] 66 | tests = ["tests", "*/media_converter/tests"] 67 | 68 | [tool.coverage.report] 69 | exclude_lines = [ 70 | "no cov", 71 | "if __name__ == .__main__.:", 72 | "if TYPE_CHECKING:", 73 | ] 74 | 75 | [tool.black] 76 | line-length = 120 77 | target-version = ['py39'] 78 | 79 | [tool.isort] 80 | profile = "black" 81 | -------------------------------------------------------------------------------- /scripts/format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | readonly root_dir=$(git rev-parse --show-toplevel) 6 | readonly package="media_converter" 7 | 8 | "$root_dir/$package/ajt_common/format.sh" 9 | -------------------------------------------------------------------------------- /scripts/libwebp-dl.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | 3 | # https://developers.google.com/speed/webp/download 4 | 5 | set -euo pipefail 6 | 7 | readonly NC='\033[0m' 8 | readonly RED='\033[0;31m' 9 | readonly GREEN='\033[0;32m' 10 | 11 | readonly version=1.5.0 12 | readonly webp_windows="https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-${version}-windows-x64.zip" 13 | readonly webp_linux="https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-${version}-linux-x86-64.tar.gz" 14 | readonly webp_mac="https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-${version}-mac-x86-64.tar.gz" 15 | 16 | readonly root_dir=$(git rev-parse --show-toplevel) 17 | readonly package="media_converter" 18 | readonly support_dir=$root_dir/$package/support 19 | readonly tmp_dir=/tmp/ajt__download_temp 20 | 21 | get_libwebp() { 22 | local -r url=${1:?} file_path=$tmp_dir/${1##*/} 23 | if ! [[ -f $file_path ]]; then 24 | curl -s --output "$file_path" -- "$url" 25 | fi 26 | atool -f -X "$tmp_dir/" -- "$file_path" 27 | } 28 | 29 | check_dependencies() { 30 | if ! command -v atool >/dev/null; then 31 | echo -e "${RED}atool is not installed!${NC}" 32 | exit 1 33 | fi 34 | } 35 | 36 | main() { 37 | if ! [[ -d $support_dir ]]; then 38 | echo "can't find support dir" 39 | exit 1 40 | fi 41 | 42 | check_dependencies 43 | 44 | if [[ -f $support_dir/cwebp.lin && -f $support_dir/cwebp.exe && -f $support_dir/cwebp.mac ]]; then 45 | echo "cwebp is already downloaded." 46 | exit 47 | fi 48 | 49 | rm -rf -- "$tmp_dir" || true 50 | mkdir -p -- "$tmp_dir" 51 | 52 | echo "downloading cwebp..." 53 | for url in "$webp_windows" "$webp_linux" "$webp_mac"; do 54 | get_libwebp "$url" & 55 | done 56 | wait 57 | 58 | find "$tmp_dir"/*windows* -type f -name 'cwebp.exe' -exec mv -- {} "$support_dir/cwebp.exe" \; 59 | find "$tmp_dir"/*linux* -type f -name 'cwebp' -exec mv -- {} "$support_dir/cwebp.lin" \; 60 | find "$tmp_dir"/*mac* -type f -name 'cwebp' -exec mv -- {} "$support_dir/cwebp.mac" \; 61 | 62 | rm -rf -- "$tmp_dir" 63 | echo -e "${GREEN}downloaded cwebp.${NC}" 64 | } 65 | 66 | main "$@" 67 | -------------------------------------------------------------------------------- /scripts/package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | readonly NC='\033[0m' 6 | readonly RED='\033[0;31m' 7 | readonly GREEN='\033[0;32m' 8 | 9 | readonly root_dir=$(git rev-parse --show-toplevel) 10 | readonly package="media_converter" 11 | readonly name="AJT Media Converter" 12 | readonly support_dir=$root_dir/$package/support 13 | readonly zip_name=${package,,}.ankiaddon 14 | 15 | "$root_dir/$package/ajt_common/package.sh" \ 16 | --package "$package" \ 17 | --root "$package" \ 18 | --name "$name" \ 19 | --zip_name "$zip_name" \ 20 | "$@" 21 | 22 | if ! [[ -f "$zip_name" ]]; then 23 | echo -e "${RED}Missing file:${NC} $zip_name" 24 | exit 1 25 | fi 26 | 27 | "$root_dir/scripts/libwebp-dl.sh" 28 | ( cd -- "$root_dir/$package" && zip -ur "$root_dir/$zip_name" "${support_dir##*/}/cwebp"* ) 29 | echo -e "${GREEN}Added cwebp binaries.${NC}" 30 | --------------------------------------------------------------------------------