commit e43e43bf3af595cd80555588c65fff975b2d708d Author: bytedream Date: Thu Apr 28 19:27:52 2022 +0200 Initial commit diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100755 index 0000000..73f69e0 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/.name b/.idea/.name new file mode 100755 index 0000000..3cb9a9d --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +athnos \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100755 index 0000000..a55e7a1 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100755 index 0000000..4e7c8f6 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,26 @@ + + + + + mariadb + true + org.mariadb.jdbc.Driver + jdbc:mariadb://localhost:3306/clipbase + $ProjectFileDir$ + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:$PROJECT_DIR$/athnos.sqlite3 + $ProjectFileDir$ + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:$PROJECT_DIR$/test.sqlite3 + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/discord.xml b/.idea/discord.xml new file mode 100755 index 0000000..eeaed03 --- /dev/null +++ b/.idea/discord.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100755 index 0000000..dd4c951 --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100755 index 0000000..d1e22ec --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100755 index 0000000..ddb2951 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100755 index 0000000..892049e --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/webResources.xml b/.idea/webResources.xml new file mode 100755 index 0000000..7ede49a --- /dev/null +++ b/.idea/webResources.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..4abca6a --- /dev/null +++ b/LICENSE @@ -0,0 +1,675 @@ + + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100755 index 0000000..35e1039 --- /dev/null +++ b/README.md @@ -0,0 +1,180 @@ +# ⚠️ UNFINISHED PROJECT ⚠️ + +> This project were not finsihed because pyqt5 development was a huge pain and I didn't saw the need for such a software anymore. Nevertheless, it runs and works partly. + +# Athnos + +With **Athnos** you can store media files like videos, audios or images +(called `clip`) structured / sorted. + +Every clip has a link or file path to the (media) file, a name, description and can have several tags. +Additionally, a clip can have a `source`. For example, if the clip is a 10-second clip from a movie, the movie can be set as the source +and each time you select the clip, you can see where it came from. + +This makes **Athnos** the perfect tool for video creators who often use many and different clips/scenes, audio, images, etc. +in their videos and currently have dozens of different folders and cryptic-looking file names to somehow bring order to the pile of files. +But it's also useful for 'normal' people, like you and me, who just want to store their images or videos centrally. + +Download the latest executables: +- Linux + - [Linux x64 standalone / portable]() + - [Linux x64 installer (.deb)]() + +- Windows + - [Windows 10 x64 standalone / portable]() + - [Windows 10 x64 installer]() + +The installation and other stuff (besides [Todo](#todo) and [License](#licence)) below are just for the people, who want to +compile the code themselves and doesn't use the executables linked above. + +If you only have a 32 bit, arm, etc. processor, window 8 or another reason why you can't use the already compiled versions linked above, +you have to download the source code and execute / compile it yourself. The instructions are below. + +## Known issues + +### Linux +- `FileNotFoundError: [Errno 2] No such file or directory 'objcopy'` when [compiling](#compile) the code with pyinstaller + + Cause: PyInstaller doesn't find the `objcopy` command + + Solution: Run `apt install binutils`, which installs `objcopy` + +- `staticx: Couldn't find 'patchelf'. Is 'patchelf' installed?` when 're-building' the pyinstaller executable with staticx + + Cause: staticx doesn't find the `patchelf` module + + Solution: Run `apt install patchelf`, which installs `patchelf` + +- ```...dlopen: /lib/x86_64-linux-gnu/libc.so.6: version ``GLIBC_2.x' not found...``` when try to run + + Cause: This means that the file was compiled with a newer `glibc` version, than the one on your system. + Usually happens if you compile the file on your computer and try to execute it on another one with an older `glibc` version. + + Solution: If you compiled the file yourself, re-compile it and after that follow the instructions [here](#create-real-independent-file). + If you downloaded the file from here, download it again ([latest version]()) and if the is still there when running the new file, + open a new [issue]() + +- `[12345] Cannot open self /tmp/staticx-*/main or archive /tmp/staticx-*/main.pkg` when try to run + + Cause: When a pyinstaller executable was 're-built' with [staticx](#create-real-independent-file). + Usually happens if pyinstaller creates a executable, which can't be 're-build' correctly with staticx + + Solution: If you compiled the file yourself, use pyinstaller version 4.0, instead of the newest version an then [compile it again](#compile-linux) + If you downloaded the file from here, download it again ([latest version]()) and if the is still there when running the new file, + open a new [issue]() + +## Installation🎰 + +### Linux + +1. Install the required apt packages. **Note: The python3 version must be 3.6 or higher!** + ```bash + apt install git + apt install python3 + apt install python3-pip + apt install python3-pyqt5 + apt install python3-pyqt5.qtsql + ``` + +2. Then clone the repository and install its requirements + ```bash + git clone https://github.com/ByteDream/athnos.git + cd athnos/ + pip3 install -r requirements.txt + ``` + +### Windows +1. Download the latest python version from [here](https://www.python.org/downloads/) (at least version 3.6 or higher) and install it. + +2. Download the [repository](https://github.com/ByteDream/athnos/archive/master.zip) and unzip it (e.g. with [7zip](https://www.7-zip.org/)). + +3. Open the unzipped folder in the [cmd](https://www.minitool.com/news/how-to-open-a-file-folder-cmd.html). + +4. Install PyQt5 and the requirements + ```cmd + pip install pyqt5 + pip install -r requirements.txt + ``` + +## Run🚀 + +After the [setup](#installation) is done, you can run **athnos** via + +- `python3 main.py` on **Linux** +- `python main.py` on **Windows** (or just double click `main.py`) + + +## Compile🔨 + +If you want to compile independent executables, the [installation](#installation) has to be done first. + +

Linux

+ +1. Go into the `athnos` folder and execute the following commands + ```bash + pip3 install pyinstaller + pyinstaller --noconsole --onefile main.py + ``` + The path of the generated executable is `dist/main`. + + Note that if you share the file with other systems, + the other systems `glibc` version must be equal or higher than the one on your system, because some python libraries + packed in the `main` file rely on them. If you want to make the file 'real' independent, see the next paragraph + +##### Create real independent file + +--- + +If you want to share the executable file with another systems which has a lower `glibc` version +(can be obtained if you run the `ldd --version` command), you need to 'rebuild' the generated `main` file. +The following method currently **only works on 64-bit machines / with 64-bit executables** and has some disadvantages: +It takes longer to startup and causes higher cpu load on startup. An alternate method is, to re-run the steps above on the oldest +system you can find / with the oldest `glibc` version. +To build the real independent file, you have to execute the following commands +```bash +apt install patchelf +pip3 install staticx +staticx dist/main dist/static_main +``` +The now generated `dist/static_main` file is the independent file. + +### Windows +1. Open the [cmd](https://www.minitool.com/news/how-to-open-a-file-folder-cmd.html) in the `athnos` folder and execute the following commands + ```cmd + pip install pyinstaller + pyinstaller --noconsole --onefile main.py + ``` + +2. The generated executable `main.exe` is in the new created `dist` folder + + +**Note: If a new major python3 version has been released lately, and you've installed this version, +PyInstaller may not work. See [here](#https://pypi.org/project/pyinstaller/#main-advantages) for all supported python3 version of PyInstaller** + + +If there is any issue while compiling or when you try to launch the compiled file, look at the [known issues](#known-issues). +When your issue isn't listed there or the given solution not works, feel free to open a new [issue]() + + +Successfully compiled (staticx and non-staticx executables) on +- Pop!_OS 20.10 with `python3.8.6`, `pyqt 5.15.0`, `pyinstaller 4.0`, `staticx 0.12.0`, `glibc 2.32` +- Ubuntu 20.04 with `python3.8.5`, `pyqt 5.14.1`, `pyinstaller 4.0`, `staticx 0.12.0`, `glibc 2.31` +- Manjaro 20.2 with `python3.8.6`, `pyqt 5.15.2`, `pyinstaller 4.0`, `staticx 0.12.0`, `glibc 2.32` + + +## Additional components + + +## Todo + +- [ ] Tags for the source +- [ ] Colored tags +- [ ] Clip column hide +- [ ] Database driver download options +- [ ] Server software +- [ ] Custom style +- [ ] Optional plugins (e.g. direct download a youtube video by its link) + +## Licence + +This project is licensed under the GNU General Public License v3.0 (GPL-3.0) - see the [LICENSE](LICENSE) file for more details diff --git a/athnos.sqlite3 b/athnos.sqlite3 new file mode 100755 index 0000000..da5baa4 Binary files /dev/null and b/athnos.sqlite3 differ diff --git a/athnos/__init__.py b/athnos/__init__.py new file mode 100755 index 0000000..3061942 --- /dev/null +++ b/athnos/__init__.py @@ -0,0 +1,8 @@ +from .utils import Config as _Config, get_logger as _get_logger + + +# creates a global config +config = _Config() + +# creates a global logger +logger = _get_logger() diff --git a/athnos/cli/__init__.py b/athnos/cli/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/athnos/cli/main.py b/athnos/cli/main.py new file mode 100755 index 0000000..993ed1b --- /dev/null +++ b/athnos/cli/main.py @@ -0,0 +1,223 @@ +#!/usr/bin/python3 + +import argparse + +from dreamutils.file import recursive_directory_data +import os +import logging +import filetype +from typing import Dict, Union, Text +from json import dump +from ..database import Type +from zipfile import ZipFile, ZIP_DEFLATED + + +# creating the logger +logger = logging.getLogger('athnos cli') +# the logger format +formatter = logging.Formatter('%(levelname)s: %(message)s') +# creates and configures the loggers console output handler +console_handler = logging.StreamHandler() +console_handler.setFormatter(formatter) +# adding the console handler to the logger +logger.addHandler(console_handler) +# the logger level +logger.setLevel(logging.INFO) + + +class _AthnosHelpFormatter(argparse.RawDescriptionHelpFormatter): + """A better format / formatter for the help message""" + + def __init__(self, prog: Text): + super().__init__(prog) + + def _format_action_invocation(self, action): + # see https://stackoverflow.com/questions/23936145/python-argparse-help-message-disable-metavar-for-short-options + if not action.option_strings: + metavar, = self._metavar_formatter(action, action.dest)(1) + return metavar + else: + parts = [] + # if the Optional doesn't take a value, format is: + # -s, --long + if action.nargs == 0: + parts.extend(action.option_strings) + + # if the Optional takes a value, format is: + # -s ARGS, --long ARGS + # change to + # -s, --long ARGS + else: + default = action.dest.upper() + args_string = self._format_args(action, default) + for option_string in action.option_strings: + # parts.append('%s %s' % (option_string, args_string)) + parts.append('%s' % option_string) + parts[-1] += ' %s' % args_string + return ', '.join(parts) + + def _format_action(self, action: argparse.Action): + # sets the position of the strings in the help message + help_position = min(self._action_max_length + 2, self._max_help_position) + self._max_help_position = self._width - help_position + return super()._format_action(action) + + def _format_usage(self, usage, actions, groups, prefix=None): + # this is the help message. maybe there is a better way to do this, but I haven't found it (yet) + return f'Usage: {self._prog} [-h] [OPTIONS] path\n\n' + + +def main(): + parser = argparse.ArgumentParser(description='Creates a \'.json\' file, containing all files from a given path ' + 'to simplify importing a huge number of files', formatter_class=_AthnosHelpFormatter) + + option = parser.add_argument_group('OPTIONS') + option.add_argument('-d', '--description', action='store', default='', + help='The description for every clip') + option.add_argument('-o', '--out', action='store', default='clips.json', + help='The file to write all information in. Default is \'clips.json\'') + option.add_argument('-t', '--type', type=str, choices=['audio', 'video', 'image', 'other', 'ignore'], default='ignore', + help='Normally the command execution would stop and display an error if the file type can\'t figured out (ending is not specified or not supported) ' + 'but if this flag is specified all files which can\'t be assigned will be from the given type. ' + 'If the value is `ignore`, all not specified files will be ignored') + option.add_argument('-b', '--build', action='store_true', + help='If given a \'clips.zip\' will be created, containing all from the given path. ' + 'Be careful this flag, it might cause a very huge file!') + option.add_argument('-r', '--recursive', action='store_true', + help='If given and the given path is a directory, all files will be selected recursively') + option.add_argument('-q', '--quiet', action='store_true', + help='No output message besides errors will be displayed') + option.add_argument('-v', '--verbose', action='store_true', + help='Shows even more infos') + option.add_argument('--overwrite', action='store_true', + help='If given and the output file (default \'clips.json\' or specified with the `-o` flag) exists, it will be overwritten') + # parser.add_argument('-src', '--source', action='store_const') + parser.add_argument('path', action='store', help='Path of the directory to add the files') + + parsed = parser.parse_args() + + clips = {'move': False, 'copy': False, 'root_dir': os.path.realpath(parsed.path), 'clips': []} + + if parsed.quiet: + logger.setLevel(logging.CRITICAL) + if parsed.verbose: + if logger.level == logging.CRITICAL: + logger.critical('Cannot use \'--quiet\' and \'--verbose\' at the same time') + return + else: + logger.setLevel(logging.DEBUG) + + if parsed.build: + archive = ZipFile('clips.zip', 'w', compression=ZIP_DEFLATED) + logger.debug('Building zipfile is enabled') + else: + archive = None + + if os.path.exists(parsed.path): + # if the file from the '--out' flag already exists in this directory and if the '--overwrite' flag is not given + if os.path.isfile(parsed.out) and not parsed.overwrite: + logger.critical(f'Cannot write the clips into \'{parsed.out}\': The file already exists. Use the `--overwrite` flag to overwrite the file') + exit(1) + + files_to_skip = [os.path.realpath(parsed.out)] + + # set the default audio type + default_clip_type = Type.Clip.from_name(parsed.type) + if not default_clip_type: + default_clip_type = Type.Clip.OTHER + logger.debug(f'The default clip type is \'{parsed.type}\'') + + # get the real / full path of the given path + full_path = os.path.realpath(parsed.path) + logger.debug(f'Real path: {full_path}') + if os.path.isfile(full_path): + logger.debug('Path is a file') + # creating a athnos json object out of the file(path) + clip = _json_object(full_path, parsed.description, default_clip_type) + # if the file cannot be ready or something like this + if clip == OSError: + logger.critical(f'Cannot process the file: {clip.strerror}') + return + # appending the new clip json object to `clips` + else: + clips['clips'].append(clip) + elif os.path.isdir(full_path): + logger.debug('Path is a directory') + if parsed.recursive: + logger.debug('Getting files recursively') + files = recursive_directory_data(full_path, only_files=True) + else: + logger.debug('Getting directory files') + files = [os.path.join(full_path, file) for file in os.listdir(full_path) if os.path.isfile(file)] + + for file in files: + # checks first if the file in `files_to_skip` + if file in files_to_skip: + logger.warning(f'Skipping file {file}') + else: + # creating a athnos json object out of the file(path) + clip = _json_object(file, parsed.description, default_clip_type) + if clip != OSError: + logger.debug(f'Adding file {file}') + clips['clips'].append(clip) + # if `archive` is True and the file is not 'clips.zip', it will be written in the clips.zip file. + # if clips.zip tried to write itself into itself, a infinite recursion would be created. + # i recognized that, when the file already was around 26GB big... + if archive and file != os.path.realpath('clips.zip'): + logger.info(f'Adding {file} to archive') + try: + archive.write(file, arcname=f'clips/{file[len(full_path):]}') + except KeyboardInterrupt: + logger.info('Aborting process, removing clips.zip...') + archive.close() + os.remove('clips.zip') + logger.debug('Removed clips.zip') + exit(1) + else: + logger.critical('Given path does not exist') + return + + if not clips: + logger.critical('Could not proceed any file') + if archive: + logger.critical('Archive has no file, removing clips.zip...') + archive.close() + os.remove('clips.zip') + logger.debug('Removed clips.zip') + exit(1) + else: + # dumping the the clips json object into a file + dump(clips, open(parsed.out, 'w+'), indent=4) + logger.debug(f'Dumped all clips into \'{parsed.out}\'') + + if archive: + archive.close() + logger.info('Wrote archive') + + +def _json_object(file: str, description: str, default: Type.Clip = None) -> Union[Dict[str, Union[str, int, Dict[str, Union[str, int]]]], OSError, None]: + try: + if filetype.is_audio(file): + type_ = Type.Clip.AUDIO + elif filetype.is_video(file): + type_ = Type.Clip.VIDEO + elif filetype.is_image(file): + type_ = Type.Clip.IMAGE + elif default is not None: + type_ = default + else: + logger.critical(f'{file} is not a supported file type (audio, video, image). ' + f'Maybe the file has an incorrect file ending?\n' + f'See https://github.com/h2non/filetype.py#supported-types for all supported file endings (only audio, video and image) ' + f'or use the `-t` flag to specify what should happen with not correctly recognized files (use `-h` to get help)\n') + exit(0) + return + + return {'source': {'type': '', 'path': '', 'name': '', 'description': '', 'season': '', 'episode': ''}, + 'path': file, + 'type': type_.value, + 'name': os.path.splitext(os.path.basename(file))[0], + 'description': description} + # this gets caused if, for example, the given file is a socket file in linux + except OSError as e: + return e diff --git a/athnos/database.py b/athnos/database.py new file mode 100755 index 0000000..b46b25b --- /dev/null +++ b/athnos/database.py @@ -0,0 +1,824 @@ +from PyQt5 import QtSql +from enum import Enum +from typing import Any, Dict, Union, List, Tuple +from . import logger + + +default_databases = [{'title': 'SQLite3', 'driver': 'QSQLITE', 'name': 'athnos.sqlite3', 'user': None, 'password': None, 'ip': None, 'port': None}, + {'title': 'MariaDB', 'driver': 'QMARIADB', 'name': 'athnos', 'user': 'root', 'password': '', 'ip': '127.0.0.1', 'port': 3306}, + {'title': 'MySQL', 'driver': 'QMYSQL', 'name': 'athnos', 'user': 'root', 'password': '', 'ip': '127.0.0.1', 'port': 3306}, + {'title': 'PostgreSQL', 'driver': 'QPSQL', 'name': 'athnos', 'user': 'postgres', 'password': '', 'ip': '127.0.0.1', 'port': 5432}, + {'title': 'ODBC', 'driver': 'QODBC', 'name': 'athnos', 'user': 'postgres', 'password': '', 'ip': '127.0.0.1', 'port': 1433}, + {'title': 'IBM DB2', 'driver': 'QDB2', 'name': 'athnos', 'user': 'db2admin', 'password': 'db2admin', 'ip': '127.0.0.1', 'port': 50000}, + {'title': 'Borland InterBase', 'driver': 'QIBASE', 'name': 'athnos', 'user': '', 'password': '', 'ip': '127.0.0.1', 'port': 3050}] + + +class _Table: + """Base class of every table in `Database`""" + + def __init__(self, database, table_name: str, zero_id_value=None): + """ + Args: + database (Database): Database of the table + table_name: Name of the table + """ + self.database = database + self.table_name = table_name + self.zero_id_value = zero_id_value + + # checks if the table exists + query = self.new_query() + query.exec(f'SELECT 1 FROM {self.table_name}') + + if query.lastError().isValid(): + # raises an IOError if the table doesn't exist + raise IOError(query.lastError().text()) + else: + # adds all table columns to a list + self.columns = [] + column_record = self.database.record(table_name) + for column in range(column_record.count()): + # » reads the column names and adds it + self.columns.append(column_record.field(column).name()) + + # sets `self.item` and `self.rows`. + # `self.item` is a child class of `_Row`, `self.rows` is a child class of `self._Rows` + if issubclass(self.__class__, Database._Clip): + self.item = Clip + self.rows = ClipRows + elif issubclass(self.__class__, Database._Item): + self.item = Item + self.rows = ItemRows + elif issubclass(self.__class__, Database._Source): + self.item = Source + self.rows = SourceRows + else: + self.item = Tag + self.rows = TagRows + + def delete(self, id: int) -> None: + """ + Deletes the entry with the corresponding id from the database + + Args: + id: ID of the entry to delete + """ + query = self.new_query() + query.exec(f'DELETE FROM {self.table_name} WHERE id={id}') + if query.lastError().isValid(): + logger.warning(f'Error while deleting {id} from {self.table_name}: {query.lastError().text()}') + + def edit(self, id: int, **kwargs) -> None: + """ + Edits the entry with the corresponding id from the database + + Args: + id: ID of the entry to edit + **kwargs: New values for specific columns + + Raises: + ValueError: if one of the `**kwargs` is not in `self.columns` or is ``id`` + """ + self.make_sql_valid(kwargs) + query = self.new_query() + query.exec(f'UPDATE {self.table_name} SET {", ".join([f"{column}={value}" for column, value in kwargs.items()])} WHERE id={id}') + if query.lastError().isValid(): + logger.warning(f'Error while updating {id} in {self.table_name}: {query.lastError().text()}') + + def get(self, id: int): + """ + Get a database object by it's id + + Args: + id: The id of the object you want to get + + Returns: + [Clip, Item, Source, Tag]: The database object + + Raises: + QtSql.QSqlError: If the sql query wasn't executed correct + """ + if id == 0: + # returns a special value if `id` is 0 and `self.zero_id_value` is not None + if self.zero_id_value: + return self.zero_id_value + query = self.new_query() + query.exec(f'SELECT * FROM {self.table_name} WHERE id={id}') + self.validate_query(query, True) + return self.item(self.database, query) + + def get_all(self): + """ + Get all infos in the table + + Returns: + _Rows: The infos stored in a child row from ``_Rows`` + """ + query = self.new_query() + query.exec(f'SELECT * FROM {self.table_name}') + self.validate_query(query, True) + return self.rows(self.database, query=query) + + def get_all_by(self, **kwargs): + """ + Get items by its values + + Args: + **kwargs: The values from the items you want to get + + Returns: + The information stored in a child class of ``_Rows`` + """ + self.make_sql_valid(kwargs) + query = self.new_query() + query.exec(f'SELECT * FROM {self.table_name} WHERE {" AND ".join(f"{column_name}={value}" for column_name, value in kwargs.items())}') + self.validate_query(query, True) + return self.rows(self.database, query=query) + + def get_by(self, **kwargs): + """ + Get a item by its values + + Args: + **kwargs: The values from the item you want to get + + Returns: + The information stored in a child class of ``_Row`` + """ + self.make_sql_valid(kwargs) + query = self.new_query() + query.exec(f'SELECT * FROM {self.table_name} WHERE {" AND ".join(f"{column_name}={value}" for column_name, value in kwargs.items())}') + self.validate_query(query, True) + return self.item(self.database, query=query) + + def has(self, **kwargs) -> bool: + """ + Checks if the table has a entry, corresponding with the `**kwargs` + + Args: + include_missing: Includes all column names (besides `id`), which weren't specified in `**kwargs` and set their value to None / NULL + **kwargs: Table keys and their values to check if they exists in the table + + Returns: + If the `**kwargs` are in the table + """ + self.make_sql_valid(kwargs) + query = self.new_query() + query.exec(f'SELECT 1 FROM {self.table_name} WHERE ({" AND ".join(f"{key}={value}" for key, value in kwargs.items())})') + self.validate_query(query, True) + if query.isValid(): + return True + else: + return False + + def has_id(self, id: int) -> bool: + """ + Checks if the table has a given id + + Args: + id: ID to check if its in the table + + Returns: + If the id is in the table + """ + return self.has(id=id) + + def highest_id(self) -> int: + """ + Selects the highest / max id from the given `self.table_name` + + Returns: + The highest id + + """ + query = self.new_query() + query.exec(f'SELECT MAX(id) FROM {self.table_name}') + query.next() + + # the highest id + highest_id = query.value(0) + + if highest_id: + return highest_id + else: + return 0 + + def new_query(self) -> QtSql.QSqlQuery: + """ + Get a new sql query + + Returns: + The sql query + + """ + return QtSql.QSqlQuery(self.database) + + def make_sql_valid(self, to_correct: Union[Dict[Any, Any], List[Any]]) -> Union[Dict[Any, Any], List[Any]]: + """ + Makes all items in a dictionary or list valid to use in a sql query + + Args: + to_correct: The dict or list to make valid + + Returns: + The corrected dict or list + + Raises: + ValueError: If one of the items in not str, int or bool + """ + if isinstance(to_correct, dict): + for key, value in to_correct.items(): + if isinstance(value, str): + to_correct[key] = f'"{value}"' + elif isinstance(value, int): + to_correct[key] = str(value) + elif not isinstance(value, bool): + raise ValueError(f'The value {value} of key {key} must be type str, int or bool') + elif isinstance(to_correct, list): + for i, item in enumerate(to_correct): + if isinstance(item, str): + to_correct[i] = f'"{item}"' + elif isinstance(item, int): + to_correct[i] = str(item) + elif not isinstance(item, bool): + raise ValueError(f'{item} must be type str, int or bool') + + return to_correct + + def validate_query(self, query: QtSql.QSqlQuery, error=False) -> bool: + """ + Checks if the given query has errors and if it has, a log output will be generated + + Args: + query: Query to check + error: If given, instead of the log output, a error will be raised + + Returns: + If the query has errors or not + + Raises: + IOError: If the query has errors and `error` is True + """ + if query.lastError().isValid(): + message = f'Error while executing query \'{query.lastQuery()}\': {query.lastError().text()}' + if error: + raise IOError(message) + else: + logger.warning(message) + return False + return True + + +class Type: + """Enum for clip / source types""" + class Clip(Enum): + OTHER = 0 + AUDIO = 1 + IMAGE = 2 + VIDEO = 3 + + @staticmethod + def from_name(name: str): + """ + Returns a `Type.Clip` by its name + + Args: + name: Name of the value + + Returns: + Type.Clip: The corresponding type + """ + for type in Type.Clip: + if type.name.lower() == name.lower(): + return type + + class Source(Enum): + OTHER = 0 + AUDIO = 1 + MOVIE = 2 + SERIES = 3 + + @staticmethod + def from_name(name: str): + """ + Returns a `Type.Source` by its name + + Args: + name: Name of the value + + Returns: + Type.Source: The corresponding type + """ + for type in Type.Source: + if type.name.lower() == name.lower(): + return type + + +class Database(QtSql.QSqlDatabase): + + def __init__(self, database: QtSql.QSqlDatabase, auto_table=True): + super().__init__(database) + + self._auto_table = auto_table + + # if `auto_table` is True it will be checked if every table exists and if not + # it will be created + if auto_table: + tables = self.tables() + if 'clip' not in tables: + self.new_query().exec('CREATE TABLE clip (id int(10) DEFAULT NULL,' + 'source_id int(10) DEFAULT NULL,' + 'path text DEFAULT NULL,' + 'type smallint(1) DEFAULT NULL,' + 'name text DEFAULT NULL,' + 'description text DEFAULT NULL)') + logger.debug('Created clip table') + if 'item' not in tables: + self.new_query().exec('CREATE TABLE item (id int(10) DEFAULT NULL,' + 'clip_id int(10) DEFAULT NULL,' + 'tag_id int(10) DEFAULT NULL)') + logger.debug('Created item table') + if 'source' not in tables: + self.new_query().exec('CREATE TABLE source (id int(10) DEFAULT NULL,' + 'path text DEFAULT NULL,' + 'type smallint(1) DEFAULT NULL,' + 'name text DEFAULT NULL,' + 'season text DEFAULT NULL,' + 'episode text DEFAULt NULL,' + 'description text DEFAULT NULL)') + logger.debug('Created source table') + if 'tag' not in tables: + self.new_query().exec('CREATE TABLE tag (id int(10) DEFAULT NULL,' + 'name text DEFAULT NULL,' + 'color char(7) DEFAULT "#c8c8c8")') # <- will be needed in a future update + logger.debug('Created tag table') + + # initializes the table classes and makes it public available + self.Clip = self._Clip(self) + self.Item = self._Item(self) + self.Source = self._Source(self) + self.Tag = self._Tag(self) + + class _Clip(_Table): + """Class for the 'clip' table""" + + table_name = 'clip' + + def __init__(self, database): + super().__init__(database, Database._Clip.table_name, Clip(database, None, 0, 0, "", Type.Clip.OTHER.value, "", "")) + + def add_clip(self, path: str, source_id: int = None, type: Union[Type.Clip, int] = Type.Clip.OTHER, name: str = None, description: str = None): + """ + Adds a new clip to the database + + Args: + path: Path of the clip + source_id: Id of the clips source + type: Type of the clip to add + name: Name of the clip + description: Description of the clip + + Returns: + Clip: The new created clip + + Raises: + TypeError: If `type_` is int and ``Type.Clip`` has no value like `type_` + """ + + if isinstance(type, int): + type = Type.Clip(type) + + clip_id = self.highest_id() + 1 + + if source_id is None: + source_id = 0 + + # adds the new clip to the table + query = self.new_query() + query.exec(f'INSERT INTO {self.table_name} (id, source_id, path, type, name, description)' + f'VALUES ({clip_id}, {source_id}, "{path}", {type.value}, "{name}", "{description}")') + + if self.validate_query(query): + return Clip(self.database, None, clip_id, source_id, path, type.value, name, description) + else: + return Clip(self.database) + + def add_clips(self, clips: List[Dict[str, Union[str, int, Type.Clip]]]): + """ + Adds a new clip to the database + + Args: + clips: The clips to add. See `self.add_clip(...)` for more information about the elements + + Returns: + List[Clip]: A list of the new created clips + + """ + keys = ['path', 'source_id', 'type', 'name', 'description'] + + new_clips = [] + highest_id = self.highest_id() + + for i, clip in enumerate(clips): + clip['id'] = highest_id + i + 1 + for key in keys: + # adds the non existing key to the dict + if key not in clip: + if key == 'path': + raise IOError(f'path must be specified in {clip}') + elif key in ('name', 'description'): + clip[key] = '' + elif key == 'source_id': + clip[key] = 0 + elif key == 'type': + clip[key] = Type.Clip.OTHER + if key == 'type' and not isinstance(key, int): + clip[key] = Type.Clip(clip[key]) + new_clips.append(Clip(self.database, None, clip)) + + query = self.new_query() + # adds the new clips to the table + query.exec(f'INSERT INTO {self.table_name} (id, source_id, path, type, name, description) VALUES ' + f'({"), (".join([", ".join(self.make_sql_valid(clip["id"], [clip["source_id"], clip["path"], clip["type"].value, clip["name"], clip["description"]])) for clip in clips])})') + self.validate_query(query, True) + + + class _Item(_Table): + """Class for the 'item' table""" + + table_name = 'item' + + def __init__(self, database): + super().__init__(database, Database._Item.table_name, Item(database, None, 0, 0, 0)) + + def add_item(self, clip_id, tag_id): + """ + Adds a new item to the database + + Args: + tag_id: Id of the tag + clip_id: Id of the clip + + Returns: + Item_ The new created item + + """ + item_id = self.highest_id() + 1 + + # adds the new item to the table + query = self.new_query() + query.exec(f'INSERT INTO {self.table_name} (id, clip_id, tag_id)' + f'VALUES ({item_id}, {clip_id}, {tag_id})') + + if self.validate_query(query): + return Item(self.database, None, item_id, clip_id, tag_id) + else: + return Item(self.database) + + class _Source(_Table): + """Class for the 'source' table""" + + table_name = 'source' + + def __init__(self, database): + super().__init__(database, Database._Source.table_name, Source(database, None, 0, "", Type.Source.OTHER.value, "", "", "", "")) + + def add_source(self, path_or_url: str, type: Union[Type.Source, int], name: str, description: str = None, season: str = None, episode: str = None): + """ + Adds a new source to the database + + Args: + path_or_url: Path or url to the source + type: Type of the source to add + name: Name of the source + description: Description of the source + season: Season of the source. Is only used if type_ is `Type.Source.SERIES` + episode: Episode of the source. Is only used if type_ is `Type.Source.SERIES` + + Returns: + Source: The new created source + + Raises: + TypeError: If `type_` is int and ``Type.Source`` has no value like `type_` + """ + if isinstance(type, int): + type = Type.Source(type) + if type is not Type.Source.SERIES: + season = None + episode = None + + source_id = self.highest_id() + 1 + + # adds the new source to the table + query = self.new_query() + query.exec(f'INSERT INTO {self.table_name} (id, path, type, name, description, season, episode)' + f'VALUES ({source_id}, "{path_or_url}", {type.value}, "{name}", "{description}", "{season}", "{episode}")') + + if self.validate_query(query): + return source_id + else: + return 0 + + class _Tag(_Table): + """Class for the 'tag' table""" + + table_name = 'tag' + + def __init__(self, database): + super().__init__(database, Database._Tag.table_name, Tag(database, None, 0, "")) + + def add_tag(self, tag_name: str): + """ + Adds a new tag to the database + + Args: + tag_name: Name of the new tag + + Returns: + Tag: The new created tag + """ + tag_id = self.highest_id() + 1 + + # adds the new tag to the database + query = self.new_query() + query.exec(f'INSERT INTO {self.table_name} (id, name)' + f'VALUES ({tag_id}, "{tag_name}")') + + if self.validate_query(query): + return Tag(self.database, None, tag_id, tag_name) + else: + return Tag(self.database) + + def get_tags_by_clip_id(self, clip_id: int): + """ + Receive all tags for a specific clip + + Args: + clip_id: Id of the clip + + Returns: + ClipRows: All tags from the given clip ids + + """ + query = self.new_query() + query.exec(f'SELECT tag_id FROM {Item.table_name} WHERE clip_id={clip_id}') + if self.validate_query(query): + ids = [] + while query.next(): + ids.append(str(query.value(0))) + query = self.new_query() + query.exec(f'SELECT * FROM {Tag.table_name} WHERE id IN ({", ".join(ids)})') + if self.validate_query(query): + return TagRows(self.database, query=query) + return TagRows(self.database) + + def new_query(self) -> QtSql.QSqlQuery: + return QtSql.QSqlQuery(self) + + +class _Row: + """Class where all information about a row in a table is stored""" + + def __init__(self, database: Database, query: QtSql.QSqlQuery = None, *args): + self._database = database + self.query = query + + # makes the id better available + if args: + self.id = args[0] + else: + self.query.next() + self.id = query.value(0) + + +class Clip(_Row): + """Class where all information about a row in the 'clip' table is stored""" + + table_name = 'clip' + + def __init__(self, database: Database, query: QtSql.QSqlQuery = None, *args): + super().__init__(database, query, *args) + + if args: + self.source_id: int = args[1] + self.path: str = args[2] + self.type = Type.Clip(args[3]) + self.name: str = args[4] + self.description: str = args[5] + else: + self.source_id: int = self.query.value(1) + self.path: str = self.query.value(2) + self.type = Type.Clip(self.query.value(3)) + self.name: str = self.query.value(4) + self.description: str = self.query.value(5) + + def __hash__(self): + return hash((self.source_id, self.path, self.type, self.name, self.description)) + + def get_source(self): + """ + Get the clip's source + + Returns: + Source: The clip's source + """ + return self._database.Source.get(self.source_id) + + def get_tags(self): + """ + Get all tags with which the clip is tagged + + Returns: + TagRows: All tags from the clip + """ + return self._database.Tag.get_tags_by_clip_id(self.id) + + def remove_tag(self, tag_id: int) -> None: + """ + Removes a tag from the clip + + Args: + tag_id: Id of the tag to remove + """ + self._database.Item.delete(self._database.Item.get_by(clip_id=self.id, tag_id=tag_id).id) + + +class Item(_Row): + """Class where all information about a row in the 'item' table is stored""" + + table_name = 'item' + + def __init__(self, database: Database, query: QtSql.QSqlQuery = None, *args): + super().__init__(database, query, *args) + + if args: + self.clip_id: int = args[1] + self.tag_id: int = args[2] + else: + self.clip_id: int = self.query.value(1) + self.tag_id: int = self.query.value(2) + + def __hash__(self): + return hash((self.clip_id, self.tag_id)) + + +class Source(_Row): + """Class where all information about a row in the 'source' table is stored""" + + table_name = 'source' + + def __init__(self, database: Database, query: QtSql.QSqlQuery = None, *args): + super().__init__(database, query, *args) + + if args: + self.path: str = args[1] + self.type = Type.Source(args[2]) + self.name: str = args[3] + self.season: str = args[4] + self.episode: str = args[5] + self.description: str = args[6] + else: + self.path: str = self.query.value(1) + self.type = Type.Source(self.query.value(2)) + self.name: str = self.query.value(3) + self.season: str = self.query.value(4) + self.episode: str = self.query.value(5) + self.description: str = self.query.value(6) + + def __hash__(self): + return hash((self.path, self.type, self.name, self.season, self.episode, self.description)) + + def get_all_clips(self): + """ + Get all clips using this source + + Returns: + ClipRows: All clips using this source + """ + return self._database.Clip.get_all_by(source_id=self.id) + + +class Tag(_Row): + """Class where all information about a row in the 'tag' table is stored""" + + table_name = 'tag' + + def __init__(self, database: Database, query: QtSql.QSqlQuery = None, *args): + super().__init__(database, query, *args) + + if args: + self.name: str = args[1] + else: + self.name: str = self.query.value(1) + + def __hash__(self): + return hash((self.name,)) + + +class _Rows(list): + """Class where all information about multiple rows in a table are stored in""" + + def __init__(self, database: Database, row_type: _Row.__class__, rows: List[_Row.__class__] = None, query: QtSql.QSqlQuery = None): + super().__init__() + self._database = database + self.query = query + + if rows: + self.extend(rows) + else: + if query: + columns = query.record().count() + # iterate through the query to get all values + while query.next(): + self.append(row_type(database, *[query.value(count) for count in range(-1, columns)])) + else: + raise ValueError('rows and query cannot be None at the same time') + + def __hash__(self): + return hash((hash(item) for item in self)) + + def new_query(self) -> QtSql.QSqlQuery: + """ + Get a new sql query + + Returns: + The sql query + """ + return QtSql.QSqlQuery(self._database) + + +class ClipRows(_Rows): + """Class where all information about multiple rows in the 'clip' table are stored in""" + + table_name = Clip.table_name + + def __init__(self, database: Database, rows: List[Clip] = None, query: QtSql.QSqlQuery = None): + super().__init__(database, Clip, rows, query) + + self.table_name = Database._Clip.table_name + + +class ItemRows(_Rows): + """Class where all information about multiple rows in the 'item' table are stored in""" + + table_name = Item.table_name + + def __init__(self, database: Database, rows: List[Item] = None, query: QtSql.QSqlQuery = None): + super().__init__(database, Item, rows, query) + + self.table_name = Database._Item.table_name + + def get_clips(self) -> ClipRows: + """ + Get all clips which have one of the here saved clip_ids as a id + + Returns: + All corresponding clips + """ + query = self.new_query() + query.exec(f'SELECT * FROM {Database._Clip.table_name} WHERE id IN ({", ".join(item.tag_id for item in self)})') + return ClipRows(self._database, query=query) + + +class SourceRows(_Rows): + """Class where all information about multiple rows in the 'source' table are stored in""" + + table_name = Source.table_name + + def __init__(self, database: Database, rows: List[Source] = None, query: QtSql.QSqlQuery = None): + super().__init__(database, Source, rows, query) + + self.table_name = Database._Source.table_name + + def get_tags(self): + """ + Get all tags which have one of the here saved sources as source + + Returns: + TagRows: All corresponding tags + """ + query = self.new_query() + query.exec(f'SELECT * FROM {Tag.table_name} WHERE source_id IN ({", ".join(str(item.id) for item in self)})') + return TagRows(self._database, query=query) + + +class TagRows(_Rows): + """Class where all information about multiple rows in the 'tag' table are stored in""" + + table_name = Tag.table_name + + def __init__(self, database: Database, rows: List[Tag] = None, query: QtSql.QSqlQuery = None): + super().__init__(database, Tag, rows, query) + + self.table_name = Database._Tag.table_name + + def get_items(self) -> ItemRows: + """ + Get all clips which have one of the here saved tags as a tag + + Returns: + All corresponding clips + """ + query = self.new_query() + query.exec(f'SELECT clip_id FROM {Database._Item.table_name} WHERE tag_id IN ({", ".join(item.id for item in self)})') + return ItemRows(self._database, query=query) diff --git a/athnos/utils.py b/athnos/utils.py new file mode 100755 index 0000000..3aaa6f8 --- /dev/null +++ b/athnos/utils.py @@ -0,0 +1,190 @@ +from dreamutils.os import platform, Platform +from PyQt5.QtWidgets import QApplication +from typing import Any, Dict, Union, Mapping, overload, Iterable, Tuple +from pathlib import Path +import logging +import re +import sys +import urllib.error, urllib.request +import json + + +class AthnosPath: + + @staticmethod + def athnos_path() -> Path: + """Returns the path where all athnos stuff like clips or configurations are stored""" + if platform() == Platform.WINDOWS: + clip_path = Path.home().joinpath('AppData\\Roaming\\athnos') + else: + clip_path = Path.home().joinpath('.athnos/') + + return clip_path + + @classmethod + def clips_path(cls) -> Path: + """Returns the path where the clips should be stored""" + return cls.athnos_path().joinpath('clips') + + @classmethod + def config_path(cls) -> Path: + """Path to the config file""" + return cls.athnos_path().joinpath('config.json') + + @classmethod + def logging_path(cls) -> Path: + """Path to the log file""" + return cls.athnos_path().joinpath('athnos.log') + + +class Config(dict): + """Class to read / edit the configuration""" + + config_path = AthnosPath.config_path() + + def __init__(self, auto_write=True): + self.auto_write = auto_write + # creates the file if it doesn't exits + if not self.config_path.is_file(): + super().__init__() + self.config_path.parent.mkdir(parents=True, exist_ok=True) + self.set_default() + + # loads the config file + file = self.config_path.open('r') + super().__init__(json.load(file)) + + def __setitem__(self, key, value): + super().__setitem__(key, value) + if self.auto_write: + self.write() + + def set_default(self) -> None: + """Sets the default parameter of the file""" + json.dump({'check_for_updates': True, + 'update-notification': 'minor', + 'updates_ignore': [], + 'style': QApplication.style().objectName(), + 'database': {'title': 'SQLite3', 'driver': 'QSQLITE', 'name': 'athnos.sqlite3', 'user': None, 'password': None, 'ip': None, 'port': None}}, + open(self.config_path, 'w'), indent=4) + + def write(self) -> None: + """Writes the config file""" + json.dump(self, open(self.config_path, 'w'), indent=4) + + +def check_version(current_version: str) -> Union[Dict[str, Any], None]: + """Checks if a new version was released""" + from . import config, logger + + update_notification = config['update-notification'] + # checks if '-' is in `current-version`. + # might be possible if version is x.y.z-beta or something else + if '-' in current_version: + internal_version, internal_pre_release = current_version.split('-') + internal_version = internal_version.split('.') + else: + internal_version = current_version.split('.') + + if update_notification is not None: + try: + # fetch all release versions + latest = urllib.request.urlopen('https://api.github.com/repos/ByteDream/athnos/tags') + data = json.loads(latest.read().decode()) + + notification = False + + try: + notification_level = {'major': 0, 'minor': 1, 'patch': 2, 'all': 3}[update_notification] + full_break = False + + for release in data: + # searches the version number and the eventual pre release + release_regex = re.search(r'^v(?P(?:\d*\.){,2}\d*)-?(?P.*$)', release['name']) + version = release_regex.group('version').split('.') + + if release['name'] in config['updates_ignore']: + continue + + # if the version is not x.y.z + if len(version) == 2: + version.append('0') + + if version > internal_version: + for i in range(0, len(version)): + if version[i] > internal_version[i]: + if notification_level >= i: + notification = True + elif version[i] < internal_version[i]: + full_break = True + else: + break + + if full_break or notification_level: + break + except KeyError: + pass + + if notification: + return release + + except urllib.error.HTTPError as e: + logger.warning(f'Could not get releases (Status: {e.getcode()}): {e.read().decode()}') + except urllib.error.URLError as e: + logger.warning(f'Could not get releases: {e.reason}. ' + f'This error often occurs when a computer has no internet connection') + + +def get_logger(file_output=True, console_output=True, level=logging.DEBUG) -> logging.Logger: + """ + Creates a new logger + + Args: + file_output: If the logger should log to file or not + console_output: If the logger should log to the console or not + + Returns: + The new created logger + """ + logger = logging.getLogger('athnos') + if logger.hasHandlers(): + return logger + + # sets the logger output format + formatter = logging.Formatter('[%(asctime)s] - %(levelname)s: %(message)s', '%H:%M:%S') + # creates the logger handlers + + # configuring the logger + logger.setLevel(level) + + if console_output: + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + if file_output: + file_handler = logging.FileHandler(AthnosPath.logging_path()) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + return logger + + +def remove_prefix(string: str, prefix: str) -> str: + """ + Removes the given prefix from the given string + + Args: + string: string to cut the prefix off + prefix: Prefix to cut off + + Returns: + The string without the prefix + """ + # uses the internal `removeprefix` method if the python version is higher or equal then 3.9 + if sys.version_info >= (3, 9): + return string.removeprefix(prefix) + elif string.startswith(prefix): + return string[len(prefix):] + # returns the string, if its not starting with the prefix + else: + return string diff --git a/athnos/window/QCustomObject.py b/athnos/window/QCustomObject.py new file mode 100755 index 0000000..bb4904a --- /dev/null +++ b/athnos/window/QCustomObject.py @@ -0,0 +1,428 @@ +from PyQt5 import QtGui, QtCore, QtWidgets +from typing import Any, Dict, List, Union +from . import resources + + +# https://github.com/baoboa/pyqt5/blob/master/examples/layouts/flowlayout.py +class QFlowLayout(QtWidgets.QLayout): + def __init__(self, parent=None, margin=0, spacing=-1): + super().__init__(parent) + + if parent is not None: + self.setContentsMargins(margin, margin, margin, margin) + + self.setSpacing(spacing) + + self._items = [] + self.__pending_positions = {} + + def __del__(self): + item = self.takeAt(0) + while item: + item = self.takeAt(0) + + def addItem(self, a0: QtWidgets.QLayoutItem) -> None: + try: + position = self.__pending_positions[a0.widget()] + self._items.insert(position, a0) + del self.__pending_positions[a0.widget()] + except KeyError: + self._items.append(a0) + + def addWidget(self, w: QtWidgets.QWidget, position: int = None) -> None: + if position: + self.__pending_positions[w] = position + super().addWidget(w) + + def count(self): + return len(self._items) + + def expandingDirections(self): + return QtCore.Qt.Orientations(QtCore.Qt.Orientation(0)) + + def itemAt(self, index: int) -> QtWidgets.QLayoutItem: + if 0 <= index < len(self._items): + return self._items[index] + + return None + + def hasHeightForWidth(self): + return True + + def heightForWidth(self, width): + height = self._doLayout(QtCore.QRect(0, 0, width, 0), True) + return height + + def minimumSize(self): + size = QtCore.QSize() + + for item in self._items: + size = size.expandedTo(item.minimumSize()) + + margin, _, _, _ = self.getContentsMargins() + + size += QtCore.QSize(2 * margin, 2 * margin) + return size + + def removeItem(self, a0: QtWidgets.QLayoutItem) -> None: + a0.widget().deleteLater() + + def removeWidget(self, w: QtWidgets.QWidget) -> None: + w.deleteLater() + + def setGeometry(self, rect): + super().setGeometry(rect) + self._doLayout(rect, False) + + def sizeHint(self): + return self.minimumSize() + + def takeAt(self, index: int) -> QtWidgets.QLayoutItem: + if 0 <= index < len(self._items): + return self._items.pop(index) + + return None + + def _doLayout(self, rect, testOnly): + """This does the layout. Dont ask me how. Source: https://github.com/baoboa/pyqt5/blob/master/examples/layouts/flowlayout.py""" + x = rect.x() + y = rect.y() + line_height = 0 + + for item in self._items: + wid = item.widget() + space_x = self.spacing() + wid.style().layoutSpacing( + QtWidgets.QSizePolicy.PushButton, + QtWidgets.QSizePolicy.PushButton, + QtCore.Qt.Horizontal) + space_y = self.spacing() + wid.style().layoutSpacing( + QtWidgets.QSizePolicy.PushButton, + QtWidgets.QSizePolicy.PushButton, + QtCore.Qt.Vertical) + next_x = x + item.sizeHint().width() + space_x + if next_x - space_x > rect.right() and line_height > 0: + x = rect.x() + y = y + line_height + space_y + next_x = x + item.sizeHint().width() + space_x + line_height = 0 + + if not testOnly: + item.setGeometry(QtCore.QRect(QtCore.QPoint(x, y), item.sizeHint())) + + x = next_x + line_height = max(line_height, item.sizeHint().height()) + + return y + line_height - rect.y() + + +class QTagEdit(QtWidgets.QScrollArea): + """A tag based edit""" + + def __init__(self, parent: QtWidgets = None, tag_suggestions: List[str] = []): + super().__init__(parent) + + # setup the ui stuff + self.setWidgetResizable(True) + + self._main_widget = QtWidgets.QWidget() + self._layout = QFlowLayout(self._main_widget) + + self._tag_input = QtWidgets.QLineEdit() + # self._tag_input.setPlaceholderText('Type in a new tag and hit enter...') + self._tag_input.setFixedWidth(10) + self._tag_input.setStyleSheet('border: 0px') + self._tag_input.setContentsMargins(0, 5, 0, 0) + self._tag_input.keyReleaseEvent = self.__tag_input_key_release_event + + self._layout.addWidget(self._tag_input) + + self._tag_input.palette().color(QtGui.QPalette.Background) + tag_input_color = self._tag_input.palette().color(QtGui.QPalette.Background) + self.setStyleSheet(f'background-color: rgb({tag_input_color.red()}, {tag_input_color.green()}, {tag_input_color.blue()})') + + self.setWidget(self._main_widget) + + # setup all other things + self.__font_calculator = QtGui.QFontMetrics(QtWidgets.QApplication.font()) + self.__tags = [] + self.__tag_suggestions = tag_suggestions + self._tag_suggestions = False + self._check_for_doubles = True + + def focusInEvent(self, a0: QtGui.QFocusEvent) -> None: + """Sets the focus always to `self._tag_input`""" + self._tag_input.setFocus() + + def addTag(self, text: str) -> bool: + """ + Adds a new tag + + Args: + text: The text of the new tag + + Returns: + If the tag was added successfully + """ + # if `self._check_for_doubles` is True, it checks if the tag already exists + if self._check_for_doubles and text.lower() in (tag.lower() for tag in self.__tags): + self.onDoubledTag(text) + return False + else: + # a new tag + tag = self.__QTagFrame(self, text) + # tag.setStyleSheet('border: 0px; margin: 0px; padding: 0px') + + self.__tags.append(text) + for tag_name in self.__tag_suggestions: + # if the tag is in `self.__tag_suggestions` it will be removed from there + if tag_name.lower() == text: + self.__tag_suggestions.remove(tag_name) + self.enableTagSuggestions(self._tag_suggestions) + break + # insert the tag before the line edit + self._layout.addWidget(tag, -1) + return True + + def clear(self, input=True) -> None: + """ + Clears all tags + + Args: + input: If True, the current text in the line edit will be cleared as well + """ + for i in range(self._layout.count()): + widget = self._layout.itemAt(i).widget() + if type(widget) == QtWidgets.QLineEdit and input: + widget.clear() + else: + self.removeTag(widget) + + def enableCheckForDoubles(self, check_for_doubles) -> None: + """ + Enables if a new tag, when its going to be added, should be checked if it already exists + + Args: + check_for_doubles: True if double checking should be active, False if not + """ + self._check_for_doubles = check_for_doubles + + def enableTagSuggestions(self, tag_suggestions: bool) -> None: + """ + Enables whenever a new tag is typed in that suggestions from `self.__tag_suggestions(...)` should be showing or not. + They can be added on the class initialization or via `setTagSuggestions` + + Args: + tag_suggestions: If tag suggestions should be active or not + """ + if tag_suggestions: + # sets the completer for the line edit + completer = QtWidgets.QCompleter(self.__tag_suggestions, self) + completer.setCaseSensitivity(QtCore.Qt.CaseSensitive) + self._tag_input.setCompleter(completer) + else: + self._tag_input.setCompleter(None) + + def onDoubledTag(self, text: str) -> None: + """ + This method gets called if `self._check_for_doubles` is True (can be set via `enableCheckForDoubles(...)`) + and a new tag which already exists are going to be added. + This method is actually there to display an error message + + Args: + text: The text of the new tag + """ + button = QtWidgets.QMessageBox() + + # if `self.tag_input.keyReleaseEvent`is not overridden, it would trigger itself when the enter key is pressed + # to close the popup and open it again. idk why + def reset(a0: QtGui.QKeyEvent): + if a0.key() in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter): + button.destroy() + self._tag_input.keyReleaseEvent = self.__tag_input_key_release_event + + self._tag_input.keyReleaseEvent = reset + + # shows the warning + button.warning(self, 'Tag already exists', f'The tag {text} already exists') + + def removeTag(self, tag) -> None: + """ + Removes a tag + + Args: + tag (QTagEdit.__QTagFrame): The tag to remove + """ + self.__tags.remove(tag.text()) + self._layout.removeWidget(tag) + + def setTags(self, tags: List[str]) -> None: + """ + Replaces all current tags with tag from the `tags` argument + + Args: + tags: The new tags to be set + """ + self.clear() + for tag in tags: + self.addTag(tag) + + def setTagSuggestions(self, suggenstions: List[str]) -> None: + """ + Sets the tag suggestions. They will be used if `self._tag_suggestions` is True (can be set via `enableTagSuggestions(...)`) + and will be shown if a new tag is typed in + + Args: + suggenstions: The new tag suggestions + """ + self.__tag_suggestions = suggenstions + self.enableTagSuggestions(self._tag_suggestions) + + def tags(self) -> Union[List[str], List]: + """ + Returns all tag names + + Returns: + All tag names + """ + return self.__tags + + def __tag_input_key_release_event(self, a0: QtGui.QKeyEvent) -> None: + """ + The `keyReleaseEvent(...)` of the line edit. Whenever return / enter is pressed, the current text in the line edit + will be added as new tag. It also expands the width of the line edit if the text in it is over an specific limit + """ + if a0.key() in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return): + # adds the tag + if self.addTag(self._tag_input.text()): + self._tag_input.clear() + self._tag_input.setFixedWidth(10) + return + elif a0.key() in (QtCore.Qt.Key_Backspace, QtCore.Qt.Key_Delete): + # calculates the current line edit text width + width = self.__font_calculator.width(self._tag_input.text()) + if width + 10 < self._tag_input.width(): + self._tag_input.setFixedWidth(width + 10) + else: + # calculates the current line edit text width + width = self.__font_calculator.width(self._tag_input.text()) + # this resizes the tag input if the text in it will be longer than it's width. + # not the best way, but it does what it does + if width + 20 > self._tag_input.width(): + self._tag_input.setFixedWidth(width + 20) + + class __QTagFrame(QtWidgets.QFrame): + """The tag class for the QTagEdit tags""" + + def __init__(self, parent, text: str): + super().__init__() + + # setup the ui stuff + self.setSizePolicy(QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)) + self.setLayout(QtWidgets.QHBoxLayout()) + self.setContentsMargins(3, 0, 3, 0) + + # the tag name label + self._name = QtWidgets.QLabel() + self._name.setText(text) + self._name.setStyleSheet('background: transparent') + + # the tag delete button + self._delete_button = QtWidgets.QPushButton() + self._delete_button.setStyleSheet('QPushButton{background: transparent; image: url(:/tags/delete); border: 0px}' + 'QPushButton:hover {image: url(:/tags/delete_hover)}') + self._delete_button.clicked.connect(self.onDeleteButtonClick) + + self.layout().addWidget(self._name) + self.layout().addWidget(self._delete_button) + + # setup all other things + self.__parent = parent + self._text = text + + def onDeleteButtonClick(self) -> None: + """This will get triggered if the delete button on a tag is pressed""" + self.__parent.removeTag(self) + self.__parent._tag_input.setFocus() + + def paintEvent(self, a0: QtGui.QPaintEvent) -> None: + """Styles the tag""" + painter = QtGui.QPainter(self) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + painter.setPen(QtGui.QPen(QtGui.QColor(200, 200, 200, 255), 1, QtCore.Qt.SolidLine)) + painter.setBrush(QtGui.QBrush(QtGui.QColor(200, 200, 200, 255), QtCore.Qt.SolidPattern)) + + # draws the tag 'filling' + painter.drawRoundedRect(0, 0, self.width() - 5, self.height(), self.height() / 2, self.height() / 2) + + def text(self) -> str: + """ + Returns the current tag text + + Returns: + The tag text + + """ + return self._text + + +class QTableSortFilterProxyModel(QtCore.QSortFilterProxyModel): + """The normal `QtCore.QSortFilterProxyModel` class but with table / multiple column support""" + + def __init__(self, overwrite_filters=False): + super().__init__() + + self.overwrite_filters = overwrite_filters + + # all filters + self.filters: Dict[int, QtCore.QRegExp] = {} + + def clearFilter(self) -> None: + """Removes all filter""" + self.filters = {} + + # https://stackoverflow.com/questions/60425164/adding-a-checkbox-column-to-a-custom-qsortfilterproxymodel + #def data(self, index, role: int = 0): + # return QtCore.Qt.Checked + + def setRegExpFilter(self, column: int, regExp: QtCore.QRegExp) -> None: + """ + Adds a new regex filter to a specific column + + Args: + column: The column to add the filter to + regExp: The regex filter + + Raises: + KeyError: If the column has already a filter + """ + if column in self.filters and not self.overwrite_filters: + raise KeyError('The column already has a filter') + self.filters[column] = regExp + + def removeRegExpFilter(self, column: int) -> None: + """ + Removes the regex filter for a specific column + + Args: + column: The column to remove the regex filter from + + Raises: + KeyError: If the column has no filter + """ + if column not in self.filters: + raise KeyError('The column has no filter') + del self.filters[column] + + def filterAcceptsRow(self, source_row: int, source_parent: QtCore.QModelIndex) -> bool: + # if one or more filters are set + if self.filters: + model = self.sourceModel() + # loops through the given source model and checks if a regex filter is installed for one column + for i in range(model.columnCount()): + # if the current column has a filter and it matches with the index data / text + if i in self.filters and self.filters[i].indexIn(str(model.data(model.index(source_row, i)))) != -1: + return True + return False + else: + # if there are no filters, the normal `filterAcceptsRow(...)` gets called + return super().filterAcceptsRow(source_row, source_parent) diff --git a/athnos/window/__init__.py b/athnos/window/__init__.py new file mode 100755 index 0000000..b865d48 --- /dev/null +++ b/athnos/window/__init__.py @@ -0,0 +1,49 @@ +from ..database import Database +from PyQt5.QtWidgets import QWidget + + +class _Window(QWidget): + """The base class for every qt specific window""" + + def __init__(self, window): + super().__init__() + self.window = window + self.setupUi(self.window) + self.after_setup_ui() + + def setupUi(self, window) -> None: + """setupUi method which creates the basic window, generated from a .ui file with the pyuic5 command line tool""" + pass + + def after_setup_ui(self) -> None: + """ + Here should stay every ui specific code which should be executed before opening the window and after calling `self.setupUi`. + This is to prevent to overload the `self.__init__` method + """ + pass + + def show(self) -> None: + """Show / executes the window""" + try: + self.window.exec() + except AttributeError: + self.window.show() + + def close(self) -> None: + """Closes the window""" + self.window.close() + + +class _DatabaseWindow(_Window): + """Base class for every window besides `window.Window` which want to use the database""" + + def __init__(self, window): + # the import is here, because if it would be imported above, a circular import would be created + from .window import _database + + if not _database: + raise IOError('Database or main window is not initialized') + else: + self.database: Database = _database + + super().__init__(window) diff --git a/athnos/window/add.py b/athnos/window/add.py new file mode 100755 index 0000000..60fe895 --- /dev/null +++ b/athnos/window/add.py @@ -0,0 +1,543 @@ +from pathlib import Path +from PyQt5 import QtCore, QtGui, QtWidgets +from . import _DatabaseWindow +import filetype +from ..database import Type +from .. import logger +from .QCustomObject import QTagEdit + + +class _Add(_DatabaseWindow): + """Base class for every add window""" + + def __init__(self, window): + super().__init__(window) + + old_key_press_event = self.window.keyPressEvent + + def new_key_press_event(a0: QtGui.QKeyEvent): + if a0.key() in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter) and not self.file_or_url_edit.hasFocus(): + return + else: + old_key_press_event(a0) + + # the normal key press event would open the file choose dialog / press the file_chooser button + # everytime enter his when a ``QLineEdit`` is focused + self.window.keyPressEvent = new_key_press_event + + def after_setup_ui(self) -> None: + # file chooser + self.file_or_url_edit.textChanged.connect(self.on_text_changed) + self.file_chooser.clicked.connect(self.on_choose_file) + + # save / new buttons + self.save_button.clicked.connect(self.on_save) + self.new_button.clicked.connect(self.on_new) + + def on_text_changed(self, value: str) -> None: + """This will get called if the file or url line edit text is changing""" + pass + + def on_choose_file(self) -> None: + """Opens a file choose dialog""" + # all unique media file types + all_preferred_types = [extension.extension for clip_type in [filetype.audio_matchers, filetype.image_matchers, filetype.video_matchers] for extension in clip_type] + file = QtWidgets.QFileDialog.getOpenFileName(self.window, directory=str(Path().home()), + filter=f'Preferred media file(*.{" *.".join(all_preferred_types)});;All files(*)')[0] + if file: + # checks the file type and sets the index if the type box after the type + match = filetype.guess(file) + if match in filetype.audio_matchers: + self.type_box.setCurrentIndex(0) + elif match in filetype.image_matchers: + self.type_box.setCurrentIndex(1) + elif match in filetype.video_matchers: + self.type_box.setCurrentIndex(2) + else: + self.type_box.setCurrentIndex(3) + # changes the file or url text edit text + self.file_or_url_edit.setText(file) + + def on_save(self) -> None: + """This will get called if the 'Save' button is clicked""" + pass + + def on_new(self) -> None: + """This will get called if the 'New' button is clicked""" + pass + + +class AddClip(_Add): + + def __init__(self, window): + super().__init__(window) + + # all tags which are currently in the database + self._all_tags = [tag.name for tag in self.database.Tag.get_all()] + self._source_id = 0 + + def setupUi(self, Form): + Form.setObjectName("Form") + Form.resize(400, 469) + self.verticalLayout_2 = QtWidgets.QVBoxLayout(Form) + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.add_clip = QtWidgets.QLabel(Form) + self.add_clip.setAlignment(QtCore.Qt.AlignCenter) + self.add_clip.setObjectName("add_clip") + self.verticalLayout_2.addWidget(self.add_clip) + self.name_description_frame = QtWidgets.QFrame(Form) + self.name_description_frame.setFrameShape(QtWidgets.QFrame.StyledPanel) + self.name_description_frame.setFrameShadow(QtWidgets.QFrame.Raised) + self.name_description_frame.setObjectName("name_description_frame") + self.verticalLayout = QtWidgets.QVBoxLayout(self.name_description_frame) + self.verticalLayout.setObjectName("verticalLayout") + self.file_or_url_hlayout = QtWidgets.QHBoxLayout() + self.file_or_url_hlayout.setObjectName("file_or_url_hlayout") + self.file_or_url_edit = QtWidgets.QLineEdit(self.name_description_frame) + self.file_or_url_edit.setText("") + self.file_or_url_edit.setClearButtonEnabled(False) + self.file_or_url_edit.setObjectName("file_or_url_edit") + self.file_or_url_hlayout.addWidget(self.file_or_url_edit) + self.file_chooser = QtWidgets.QPushButton(self.name_description_frame) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.file_chooser.sizePolicy().hasHeightForWidth()) + self.file_chooser.setSizePolicy(sizePolicy) + self.file_chooser.setSizeIncrement(QtCore.QSize(0, 0)) + self.file_chooser.setCheckable(False) + self.file_chooser.setObjectName("file_chooser") + self.file_or_url_hlayout.addWidget(self.file_chooser) + self.verticalLayout.addLayout(self.file_or_url_hlayout) + self.line = QtWidgets.QFrame(self.name_description_frame) + self.line.setFrameShape(QtWidgets.QFrame.HLine) + self.line.setFrameShadow(QtWidgets.QFrame.Sunken) + self.line.setObjectName("line") + self.verticalLayout.addWidget(self.line) + self.name_hlayout = QtWidgets.QHBoxLayout() + self.name_hlayout.setObjectName("name_hlayout") + self.name_label = QtWidgets.QLabel(self.name_description_frame) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.name_label.sizePolicy().hasHeightForWidth()) + self.name_label.setSizePolicy(sizePolicy) + self.name_label.setMinimumSize(QtCore.QSize(70, 0)) + self.name_label.setBaseSize(QtCore.QSize(0, 0)) + self.name_label.setObjectName("name_label") + self.name_hlayout.addWidget(self.name_label) + self.name_edit = QtWidgets.QLineEdit(self.name_description_frame) + self.name_edit.setStyleSheet("") + self.name_edit.setReadOnly(True) + self.name_edit.setClearButtonEnabled(False) + self.name_edit.setObjectName("name_edit") + self.name_hlayout.addWidget(self.name_edit) + self.verticalLayout.addLayout(self.name_hlayout) + self.description_hlayout = QtWidgets.QHBoxLayout() + self.description_hlayout.setObjectName("description_hlayout") + self.description_label = QtWidgets.QLabel(self.name_description_frame) + self.description_label.setMinimumSize(QtCore.QSize(70, 0)) + self.description_label.setBaseSize(QtCore.QSize(0, 0)) + self.description_label.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) + self.description_label.setObjectName("description_label") + self.description_hlayout.addWidget(self.description_label) + self.description_edit = QtWidgets.QTextEdit(self.name_description_frame) + self.description_edit.setEnabled(True) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.description_edit.sizePolicy().hasHeightForWidth()) + self.description_edit.setSizePolicy(sizePolicy) + self.description_edit.setReadOnly(True) + self.description_edit.setObjectName("description_edit") + self.description_hlayout.addWidget(self.description_edit) + self.verticalLayout.addLayout(self.description_hlayout) + self.type_hlayout = QtWidgets.QHBoxLayout() + self.type_hlayout.setObjectName("type_hlayout") + self.type_label = QtWidgets.QLabel(self.name_description_frame) + self.type_label.setMinimumSize(QtCore.QSize(70, 0)) + self.type_label.setBaseSize(QtCore.QSize(0, 0)) + self.type_label.setObjectName("type_label") + self.type_hlayout.addWidget(self.type_label) + self.type_box = QtWidgets.QComboBox(self.name_description_frame) + self.type_box.setEnabled(False) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.type_box.sizePolicy().hasHeightForWidth()) + self.type_box.setSizePolicy(sizePolicy) + self.type_box.setFocusPolicy(QtCore.Qt.WheelFocus) + self.type_box.setObjectName("type_box") + self.type_box.addItem("") + self.type_box.addItem("") + self.type_box.addItem("") + self.type_box.addItem("") + self.type_hlayout.addWidget(self.type_box) + self.verticalLayout.addLayout(self.type_hlayout) + self.line_3 = QtWidgets.QFrame(self.name_description_frame) + self.line_3.setFrameShape(QtWidgets.QFrame.HLine) + self.line_3.setFrameShadow(QtWidgets.QFrame.Sunken) + self.line_3.setObjectName("line_3") + self.verticalLayout.addWidget(self.line_3) + self.tags_hlayout = QtWidgets.QHBoxLayout() + self.tags_hlayout.setObjectName("tags_hlayout") + self.tags_label = QtWidgets.QLabel(self.name_description_frame) + self.tags_label.setMinimumSize(QtCore.QSize(70, 0)) + self.tags_label.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) + self.tags_label.setObjectName("tags_label") + self.tags_hlayout.addWidget(self.tags_label) + self.tags_edit = QtWidgets.QScrollArea(self.name_description_frame) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.tags_edit.sizePolicy().hasHeightForWidth()) + self.tags_edit.setSizePolicy(sizePolicy) + self.tags_edit.setWidgetResizable(True) + self.tags_edit.setObjectName("tags_edit") + self.scrollAreaWidgetContents = QtWidgets.QWidget() + self.scrollAreaWidgetContents.setGeometry(QtCore.QRect(0, 0, 290, 83)) + self.scrollAreaWidgetContents.setObjectName("scrollAreaWidgetContents") + self.tags_edit.setWidget(self.scrollAreaWidgetContents) + self.tags_hlayout.addWidget(self.tags_edit) + self.verticalLayout.addLayout(self.tags_hlayout) + self.line_2 = QtWidgets.QFrame(self.name_description_frame) + self.line_2.setFrameShape(QtWidgets.QFrame.HLine) + self.line_2.setFrameShadow(QtWidgets.QFrame.Sunken) + self.line_2.setObjectName("line_2") + self.verticalLayout.addWidget(self.line_2) + self.horizontalLayout = QtWidgets.QHBoxLayout() + self.horizontalLayout.setObjectName("horizontalLayout") + self.choose_source_button = QtWidgets.QPushButton(self.name_description_frame) + self.choose_source_button.setEnabled(False) + self.choose_source_button.setMaximumSize(QtCore.QSize(100, 16777215)) + self.choose_source_button.setObjectName("choose_source_button") + self.horizontalLayout.addWidget(self.choose_source_button) + self.choose_source_label = QtWidgets.QLabel(self.name_description_frame) + self.choose_source_label.setEnabled(False) + self.choose_source_label.setObjectName("choose_source_label") + self.horizontalLayout.addWidget(self.choose_source_label) + self.verticalLayout.addLayout(self.horizontalLayout) + self.verticalLayout_2.addWidget(self.name_description_frame) + self.save_new_hlayout = QtWidgets.QHBoxLayout() + self.save_new_hlayout.setObjectName("save_new_hlayout") + self.save_button = QtWidgets.QPushButton(Form) + self.save_button.setEnabled(False) + self.save_button.setFlat(False) + self.save_button.setObjectName("save_button") + self.save_new_hlayout.addWidget(self.save_button) + self.new_button = QtWidgets.QPushButton(Form) + self.new_button.setCheckable(False) + self.new_button.setChecked(False) + self.new_button.setAutoRepeat(False) + self.new_button.setAutoExclusive(False) + self.new_button.setObjectName("new_button") + self.save_new_hlayout.addWidget(self.new_button) + self.verticalLayout_2.addLayout(self.save_new_hlayout) + + self.retranslateUi(Form) + self.type_box.setCurrentIndex(3) + QtCore.QMetaObject.connectSlotsByName(Form) + + def retranslateUi(self, Form): + _translate = QtCore.QCoreApplication.translate + Form.setWindowTitle(_translate("Form", "Form")) + self.add_clip.setText(_translate("Form", "Add clip")) + self.file_or_url_edit.setPlaceholderText(_translate("Form", "File or url...")) + self.file_chooser.setText(_translate("Form", "Choose file")) + self.name_label.setText(_translate("Form", "Name")) + self.description_label.setText(_translate("Form", "Description")) + self.type_label.setText(_translate("Form", "Type")) + self.type_box.setItemText(0, _translate("Form", "Audio")) + self.type_box.setItemText(1, _translate("Form", "Image")) + self.type_box.setItemText(2, _translate("Form", "Video")) + self.type_box.setItemText(3, _translate("Form", "Other")) + self.tags_label.setText(_translate("Form", "Tags")) + self.choose_source_button.setText(_translate("Form", "Select source")) + self.choose_source_label.setText(_translate("Form", "No source selected")) + self.save_button.setText(_translate("Form", "Save")) + self.save_button.setShortcut(_translate("Form", "Ctrl+S")) + self.new_button.setText(_translate("Form", "New")) + + def after_setup_ui(self) -> None: + super().after_setup_ui() + + # replaces the `self.tags_edit` scroll area with an `QCustomObject.QTagEdit` + self.tags_edit.deleteLater() + self.tags_edit.hide() + self.tags_edit = QTagEdit() + # configures the tage edit + self.tags_edit.setEnabled(False) + self.tags_edit.enableTagSuggestions(True) + self.tags_edit.setTagSuggestions([tag.name for tag in self.database.Tag.get_all()]) + self.tags_hlayout.addWidget(self.tags_edit) + + self.choose_source_button.clicked.connect(self.on_select_source) + + def on_text_changed(self, text: str) -> None: + if text.strip() == '': + is_value = False + else: + is_value = True + + self.name_edit.setReadOnly(not is_value) + self.description_edit.setReadOnly(not is_value) + self.type_box.setEnabled(is_value) + self.tags_edit.setEnabled(is_value) + self.choose_source_button.setEnabled(is_value) + self.choose_source_label.setEnabled(is_value) + self.save_button.setEnabled(is_value) + + def on_select_source(self) -> None: + """Opens a new `source.ShowSource` window to select the clips source""" + + # this is imported here, because it would cause a circular import if it's imported above + from .source import ShowSource + + source = self.database.Source.get(ShowSource(QtWidgets.QDialog(self.window, QtCore.Qt.WindowSystemMenuHint)).select()) + self._source_id = source.id + self.choose_source_label.setText(source.name) + + def on_save(self) -> None: + if not self._source_id: + reply = QtWidgets.QMessageBox.question(self.window, + 'No source is given', 'No source for the clip is given. Are you sure to proceed?', + buttons=QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) + if reply == QtWidgets.QMessageBox.No: + return + + type = Type.Clip.from_name(self.type_box.currentText().lower()) + if not type: + return + + id = self.database.Clip.add_clip(self.file_or_url_edit.text(), self._source_id, type.value, self.name_edit.text(), self.description_edit.toPlainText()).id + for tag in self.tags_edit.tags(): + if tag in self._all_tags: + self.database.Item.add_item(id, self.database.Tag.get_by(name=tag).id) + else: + self.database.Item.add_item(id, self.database.Tag.add_tag(tag).id) + logger.debug(f'Added new tag - Name: {tag}') + + logger.debug(f'Added new clip - file / url: "{self.file_or_url_edit.text()}", source id: "{self._source_id}", type: "{type.value}", ' + f'name: "{self.name_edit.text()}", description: "{self.description_edit.toPlainText()}", tags: {", ".join(self.tags_edit.tags())}') + + self.on_new() + + def on_new(self) -> None: + self.file_or_url_edit.setText('') + self.name_edit.setText('') + self.on_text_changed('') + self.type_box.setCurrentIndex(3) + self.tags_edit.clear() + + +class AddSource(_Add): + + def setupUi(self, Form): + Form.setObjectName("Form") + Form.resize(452, 400) + self.verticalLayout = QtWidgets.QVBoxLayout(Form) + self.verticalLayout.setObjectName("verticalLayout") + self.add_source_label = QtWidgets.QLabel(Form) + self.add_source_label.setAlignment(QtCore.Qt.AlignCenter) + self.add_source_label.setObjectName("add_source_label") + self.verticalLayout.addWidget(self.add_source_label) + self.frame = QtWidgets.QFrame(Form) + self.frame.setFrameShape(QtWidgets.QFrame.StyledPanel) + self.frame.setFrameShadow(QtWidgets.QFrame.Raised) + self.frame.setObjectName("frame") + self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.frame) + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.file_or_url_hlayout = QtWidgets.QHBoxLayout() + self.file_or_url_hlayout.setObjectName("file_or_url_hlayout") + self.file_or_url_edit = QtWidgets.QLineEdit(self.frame) + self.file_or_url_edit.setText("") + self.file_or_url_edit.setObjectName("file_or_url_edit") + self.file_or_url_hlayout.addWidget(self.file_or_url_edit) + self.file_chooser = QtWidgets.QPushButton(self.frame) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.file_chooser.sizePolicy().hasHeightForWidth()) + self.file_chooser.setSizePolicy(sizePolicy) + self.file_chooser.setSizeIncrement(QtCore.QSize(0, 0)) + self.file_chooser.setObjectName("file_chooser") + self.file_or_url_hlayout.addWidget(self.file_chooser) + self.verticalLayout_2.addLayout(self.file_or_url_hlayout) + self.line_3 = QtWidgets.QFrame(self.frame) + self.line_3.setFrameShape(QtWidgets.QFrame.HLine) + self.line_3.setFrameShadow(QtWidgets.QFrame.Sunken) + self.line_3.setObjectName("line_3") + self.verticalLayout_2.addWidget(self.line_3) + self.type_hlayout = QtWidgets.QHBoxLayout() + self.type_hlayout.setObjectName("type_hlayout") + self.type_label = QtWidgets.QLabel(self.frame) + self.type_label.setMinimumSize(QtCore.QSize(70, 0)) + self.type_label.setObjectName("type_label") + self.type_hlayout.addWidget(self.type_label) + self.type_box = QtWidgets.QComboBox(self.frame) + self.type_box.setEnabled(False) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.type_box.sizePolicy().hasHeightForWidth()) + self.type_box.setSizePolicy(sizePolicy) + self.type_box.setObjectName("type_box") + self.type_box.addItem("") + self.type_box.addItem("") + self.type_box.addItem("") + self.type_box.addItem("") + self.type_hlayout.addWidget(self.type_box) + self.verticalLayout_2.addLayout(self.type_hlayout) + self.from_hlayout = QtWidgets.QHBoxLayout() + self.from_hlayout.setObjectName("from_hlayout") + self.name_label = QtWidgets.QLabel(self.frame) + self.name_label.setMinimumSize(QtCore.QSize(70, 0)) + self.name_label.setObjectName("name_label") + self.from_hlayout.addWidget(self.name_label) + self.name_edit = QtWidgets.QLineEdit(self.frame) + self.name_edit.setReadOnly(True) + self.name_edit.setObjectName("name_edit") + self.from_hlayout.addWidget(self.name_edit) + self.season_label = QtWidgets.QLabel(self.frame) + self.season_label.setObjectName("season_label") + self.from_hlayout.addWidget(self.season_label) + self.season_edit = QtWidgets.QLineEdit(self.frame) + self.season_edit.setMaximumSize(QtCore.QSize(50, 16777215)) + self.season_edit.setReadOnly(True) + self.season_edit.setObjectName("season_edit") + self.from_hlayout.addWidget(self.season_edit) + self.episode_label = QtWidgets.QLabel(self.frame) + self.episode_label.setObjectName("episode_label") + self.from_hlayout.addWidget(self.episode_label) + self.episode_edit = QtWidgets.QLineEdit(self.frame) + self.episode_edit.setMaximumSize(QtCore.QSize(50, 16777215)) + self.episode_edit.setReadOnly(True) + self.episode_edit.setObjectName("episode_edit") + self.from_hlayout.addWidget(self.episode_edit) + self.verticalLayout_2.addLayout(self.from_hlayout) + self.description_hlayout = QtWidgets.QHBoxLayout() + self.description_hlayout.setObjectName("description_hlayout") + self.description_label = QtWidgets.QLabel(self.frame) + self.description_label.setMinimumSize(QtCore.QSize(70, 0)) + self.description_label.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) + self.description_label.setObjectName("description_label") + self.description_hlayout.addWidget(self.description_label) + self.description_edit = QtWidgets.QTextEdit(self.frame) + self.description_edit.setReadOnly(True) + self.description_edit.setObjectName("description_edit") + self.description_hlayout.addWidget(self.description_edit) + self.verticalLayout_2.addLayout(self.description_hlayout) + self.verticalLayout.addWidget(self.frame) + self.save_new_hlayout = QtWidgets.QHBoxLayout() + self.save_new_hlayout.setObjectName("save_new_hlayout") + self.save_button = QtWidgets.QPushButton(Form) + self.save_button.setEnabled(False) + self.save_button.setObjectName("save_button") + self.save_new_hlayout.addWidget(self.save_button) + self.new_button = QtWidgets.QPushButton(Form) + self.new_button.setObjectName("new_button") + self.save_new_hlayout.addWidget(self.new_button) + self.verticalLayout.addLayout(self.save_new_hlayout) + + self.retranslateUi(Form) + self.type_box.setCurrentIndex(2) + QtCore.QMetaObject.connectSlotsByName(Form) + + def retranslateUi(self, Form): + _translate = QtCore.QCoreApplication.translate + Form.setWindowTitle(_translate("Form", "Form")) + self.add_source_label.setText(_translate("Form", "Add source")) + self.file_or_url_edit.setPlaceholderText(_translate("Form", "File or url...")) + self.file_chooser.setText(_translate("Form", "Choose file")) + self.file_chooser.setShortcut(_translate("Form", "Ctrl+O")) + self.type_label.setText(_translate("Form", "Type")) + self.type_box.setItemText(0, _translate("Form", "Audio")) + self.type_box.setItemText(1, _translate("Form", "Movie")) + self.type_box.setItemText(2, _translate("Form", "Series")) + self.type_box.setItemText(3, _translate("Form", "Other")) + self.name_label.setText(_translate("Form", "From")) + self.season_label.setText(_translate("Form", "Season")) + self.episode_label.setText(_translate("Form", "Episode")) + self.description_label.setText(_translate("Form", "Description")) + self.save_button.setText(_translate("Form", "Save")) + self.save_button.setShortcut(_translate("Form", "Ctrl+S")) + self.new_button.setText(_translate("Form", "New")) + self.new_button.setShortcut(_translate("Form", "Ctrl+N")) + + def after_setup_ui(self) -> None: + super().after_setup_ui() + + self.type_box.currentIndexChanged.connect(self.on_type_changed) + + def on_text_changed(self, text: str): + if text.strip() == '': + is_value = False + else: + is_value = True + + self.name_edit.setReadOnly(not is_value) + self.season_edit.setReadOnly(not is_value) + self.episode_edit.setReadOnly(not is_value) + self.description_edit.setReadOnly(not is_value) + self.type_box.setEnabled(is_value) + self.save_button.setEnabled(is_value) + + def on_type_changed(self, index: int) -> None: + """Modifies the name / from, season and episode line edits, based on the `index`""" + if index in [0, 1, 3]: # audio / movie + # hides the season and episode label / line edit and sets the text of the name / from label to 'Name' + self.name_label.setText('Name') + self.season_label.hide() + self.season_edit.hide() + self.episode_label.hide() + self.episode_edit.hide() + elif index == 2: # series + # shows the season and episode label / line edit and sets the text of the name / from label to 'From' + self.name_label.setText('From') + self.season_label.show() + self.season_edit.show() + self.episode_label.show() + self.episode_edit.show() + + def on_save(self) -> None: + type = Type.Source.from_name(self.type_box.currentText().lower()) + if not type: + return + + file_or_url = self.file_or_url_edit.text() + name = self.name_edit.text() + description = self.description_edit.toPlainText() + season = self.season_edit.text() + episode = self.episode_edit.text() + + # it checks if some of the data is already in the database + if type == Type.Source.SERIES and self.database.Source.has(type=type.value, name=name, season=season, episode=episode): + has_same = True + elif type != Type.Source.SERIES and self.database.Source.has(name=name): + has_same = True + else: + has_same = False + + if has_same: + # if some of the data were in the database it checks if the exact same data is in the database + if self.database.Source.has(path=file_or_url, type=type.value, name=name, description=description, season=season, episode=episode): + QtWidgets.QMessageBox.warning(self.window, 'The source already exist', 'A source with the exact same data (name, description, type, etc.) already exist') + return + elif type == Type.Source.SERIES: + proceed = QtWidgets.QMessageBox.question(self.window, 'The source may already exist', 'A source with some same data (name, season and episode at least) already exist. ' + 'Do you want to proceed?', QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) + if not proceed: + return + + self.database.Source.add_source(self.file_or_url_edit.text(), type.value, self.name_edit.text(), self.description_edit.toPlainText(), self.season_edit.text(), self.episode_edit.text()) + logger.debug(f'Added new source - file / url: "{self.file_or_url_edit.text()}", name: "{self.name_edit.text()}", description: "{self.description_edit.toPlainText()}", ' + f'season: "{self.season_edit.text()}", episode: "{self.episode_edit.text()}"') + self.on_new() + + def on_new(self) -> None: + self.file_or_url_edit.setText('') + self.type_box.setCurrentIndex(2) + self.name_edit.setText('') + self.season_edit.setText('') + self.episode_edit.setText('') + self.description_edit.setText('') diff --git a/athnos/window/connect.py b/athnos/window/connect.py new file mode 100755 index 0000000..cc33675 --- /dev/null +++ b/athnos/window/connect.py @@ -0,0 +1,426 @@ +from . import _DatabaseWindow +from .. import config +from ..database import default_databases, Database +from PyQt5 import QtCore, QtGui, QtWidgets, QtSql +import os +from copy import copy +from . import style +from typing import Union + + +class Connect(_DatabaseWindow): + """Class to connect to another database""" + + def __init__(self, window): + self.file = '' + self.database = None + self.current_index = -1 + self.is_database_file_new = False + + super().__init__(window) + + self.on_database_index_change(self.database_type.currentIndex()) + + def setupUi(self, Form): + Form.setObjectName("Form") + Form.resize(400, 490) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(Form.sizePolicy().hasHeightForWidth()) + Form.setSizePolicy(sizePolicy) + self.verticalLayout = QtWidgets.QVBoxLayout(Form) + self.verticalLayout.setSizeConstraint(QtWidgets.QLayout.SetMinimumSize) + self.verticalLayout.setObjectName("verticalLayout") + self.database_type_hlayout = QtWidgets.QHBoxLayout() + self.database_type_hlayout.setObjectName("database_type_hlayout") + self.database_label = QtWidgets.QLabel(Form) + self.database_label.setObjectName("database_label") + self.database_type_hlayout.addWidget(self.database_label) + spacerItem = QtWidgets.QSpacerItem(13, 20, QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Minimum) + self.database_type_hlayout.addItem(spacerItem) + self.database_type = QtWidgets.QComboBox(Form) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.database_type.sizePolicy().hasHeightForWidth()) + self.database_type.setSizePolicy(sizePolicy) + self.database_type.setObjectName("database_type") + self.database_type_hlayout.addWidget(self.database_type) + self.verticalLayout.addLayout(self.database_type_hlayout) + self.line = QtWidgets.QFrame(Form) + self.line.setFrameShape(QtWidgets.QFrame.HLine) + self.line.setFrameShadow(QtWidgets.QFrame.Sunken) + self.line.setObjectName("line") + self.verticalLayout.addWidget(self.line) + self.database_file_hlayout = QtWidgets.QHBoxLayout() + self.database_file_hlayout.setObjectName("database_file_hlayout") + self.database_file = QtWidgets.QPushButton(Form) + self.database_file.setMinimumSize(QtCore.QSize(118, 0)) + self.database_file.setMaximumSize(QtCore.QSize(130, 16777215)) + self.database_file.setObjectName("database_file") + self.database_file_hlayout.addWidget(self.database_file) + self.file_name_label = QtWidgets.QLabel(Form) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.file_name_label.sizePolicy().hasHeightForWidth()) + self.file_name_label.setSizePolicy(sizePolicy) + self.file_name_label.setObjectName("file_name_label") + self.database_file_hlayout.addWidget(self.file_name_label) + self.file_utils_hlayout = QtWidgets.QHBoxLayout() + self.file_utils_hlayout.setSpacing(0) + self.file_utils_hlayout.setObjectName("file_utils_hlayout") + self.line_2 = QtWidgets.QFrame(Form) + self.line_2.setFrameShape(QtWidgets.QFrame.VLine) + self.line_2.setFrameShadow(QtWidgets.QFrame.Sunken) + self.line_2.setObjectName("line_2") + self.file_utils_hlayout.addWidget(self.line_2) + self.delete_file_button = QtWidgets.QPushButton(Form) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.delete_file_button.sizePolicy().hasHeightForWidth()) + self.delete_file_button.setSizePolicy(sizePolicy) + self.delete_file_button.setStyleSheet("") + self.delete_file_button.setText("") + icon = QtGui.QIcon() + icon.addPixmap(QtGui.QPixmap(":/file_url/trash"), QtGui.QIcon.Normal, QtGui.QIcon.Off) + self.delete_file_button.setIcon(icon) + self.delete_file_button.setIconSize(QtCore.QSize(18, 18)) + self.delete_file_button.setAutoDefault(False) + self.delete_file_button.setDefault(False) + self.delete_file_button.setFlat(True) + self.delete_file_button.setObjectName("delete_file_button") + self.file_utils_hlayout.addWidget(self.delete_file_button) + self.add_file_button = QtWidgets.QPushButton(Form) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.add_file_button.sizePolicy().hasHeightForWidth()) + self.add_file_button.setSizePolicy(sizePolicy) + self.add_file_button.setStyleSheet("") + self.add_file_button.setText("") + icon1 = QtGui.QIcon() + icon1.addPixmap(QtGui.QPixmap(":/file_url/add_file"), QtGui.QIcon.Normal, QtGui.QIcon.Off) + self.add_file_button.setIcon(icon1) + self.add_file_button.setIconSize(QtCore.QSize(18, 18)) + self.add_file_button.setFlat(True) + self.add_file_button.setObjectName("add_file_button") + self.file_utils_hlayout.addWidget(self.add_file_button) + self.database_file_hlayout.addLayout(self.file_utils_hlayout) + self.verticalLayout.addLayout(self.database_file_hlayout) + self.database_settings_frame = QtWidgets.QFrame(Form) + self.database_settings_frame.setFrameShape(QtWidgets.QFrame.StyledPanel) + self.database_settings_frame.setFrameShadow(QtWidgets.QFrame.Raised) + self.database_settings_frame.setObjectName("database_settings_frame") + self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.database_settings_frame) + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.database_ip_layout = QtWidgets.QHBoxLayout() + self.database_ip_layout.setObjectName("database_ip_layout") + self.database_ip_label = QtWidgets.QLabel(self.database_settings_frame) + self.database_ip_label.setMinimumSize(QtCore.QSize(100, 0)) + self.database_ip_label.setObjectName("database_ip_label") + self.database_ip_layout.addWidget(self.database_ip_label) + self.database_ip_edit = QtWidgets.QLineEdit(self.database_settings_frame) + self.database_ip_edit.setObjectName("database_ip_edit") + self.database_ip_layout.addWidget(self.database_ip_edit) + self.verticalLayout_2.addLayout(self.database_ip_layout) + self.user_hlayout = QtWidgets.QHBoxLayout() + self.user_hlayout.setObjectName("user_hlayout") + self.user_label = QtWidgets.QLabel(self.database_settings_frame) + self.user_label.setMinimumSize(QtCore.QSize(100, 0)) + self.user_label.setObjectName("user_label") + self.user_hlayout.addWidget(self.user_label) + self.user_edit = QtWidgets.QLineEdit(self.database_settings_frame) + self.user_edit.setObjectName("user_edit") + self.user_hlayout.addWidget(self.user_edit) + self.verticalLayout_2.addLayout(self.user_hlayout) + self.password_hlayout = QtWidgets.QHBoxLayout() + self.password_hlayout.setObjectName("password_hlayout") + self.password_label = QtWidgets.QLabel(self.database_settings_frame) + self.password_label.setMinimumSize(QtCore.QSize(100, 0)) + self.password_label.setObjectName("password_label") + self.password_hlayout.addWidget(self.password_label) + self.password_edit = QtWidgets.QLineEdit(self.database_settings_frame) + self.password_edit.setInputMethodHints(QtCore.Qt.ImhHiddenText|QtCore.Qt.ImhNoAutoUppercase|QtCore.Qt.ImhNoPredictiveText|QtCore.Qt.ImhSensitiveData) + self.password_edit.setEchoMode(QtWidgets.QLineEdit.Password) + self.password_edit.setObjectName("password_edit") + self.password_hlayout.addWidget(self.password_edit) + self.verticalLayout_2.addLayout(self.password_hlayout) + self.port_hlayout = QtWidgets.QHBoxLayout() + self.port_hlayout.setObjectName("port_hlayout") + self.port_label = QtWidgets.QLabel(self.database_settings_frame) + self.port_label.setMinimumSize(QtCore.QSize(100, 0)) + self.port_label.setObjectName("port_label") + self.port_hlayout.addWidget(self.port_label) + self.port_edit = QtWidgets.QLineEdit(self.database_settings_frame) + self.port_edit.setFrame(True) + self.port_edit.setClearButtonEnabled(False) + self.port_edit.setObjectName("port_edit") + self.port_hlayout.addWidget(self.port_edit) + self.verticalLayout_2.addLayout(self.port_hlayout) + self.database_name_hlayout = QtWidgets.QHBoxLayout() + self.database_name_hlayout.setObjectName("database_name_hlayout") + self.database_name_label = QtWidgets.QLabel(self.database_settings_frame) + self.database_name_label.setMinimumSize(QtCore.QSize(100, 0)) + self.database_name_label.setObjectName("database_name_label") + self.database_name_hlayout.addWidget(self.database_name_label) + self.database_name_edit = QtWidgets.QLineEdit(self.database_settings_frame) + self.database_name_edit.setPlaceholderText("") + self.database_name_edit.setObjectName("database_name_edit") + self.database_name_hlayout.addWidget(self.database_name_edit) + self.verticalLayout_2.addLayout(self.database_name_hlayout) + self.show_databases_button = QtWidgets.QPushButton(self.database_settings_frame) + self.show_databases_button.setObjectName("show_databases_button") + self.verticalLayout_2.addWidget(self.show_databases_button) + self.databases_view = QtWidgets.QListView(self.database_settings_frame) + self.databases_view.setInputMethodHints(QtCore.Qt.ImhNone) + self.databases_view.setObjectName("databases_view") + self.verticalLayout_2.addWidget(self.databases_view) + self.verticalLayout.addWidget(self.database_settings_frame) + self.test_save_hlayout = QtWidgets.QHBoxLayout() + self.test_save_hlayout.setObjectName("test_save_hlayout") + self.test_connection_button = QtWidgets.QPushButton(Form) + self.test_connection_button.setToolTip("") + self.test_connection_button.setStatusTip("") + self.test_connection_button.setWhatsThis("") + self.test_connection_button.setObjectName("test_connection_button") + self.test_save_hlayout.addWidget(self.test_connection_button) + self.save_button = QtWidgets.QPushButton(Form) + self.save_button.setObjectName("save_button") + self.test_save_hlayout.addWidget(self.save_button) + self.verticalLayout.addLayout(self.test_save_hlayout) + + self.retranslateUi(Form) + QtCore.QMetaObject.connectSlotsByName(Form) + + def retranslateUi(self, Form): + _translate = QtCore.QCoreApplication.translate + Form.setWindowTitle(_translate("Form", "Connect to database")) + self.database_label.setText(_translate("Form", "Database")) + self.database_file.setText(_translate("Form", "Database file")) + self.file_name_label.setText(_translate("Form", "No file selected")) + self.database_ip_label.setText(_translate("Form", "Database IP")) + self.database_ip_edit.setText(_translate("Form", "127.0.0.1")) + self.user_label.setText(_translate("Form", "User")) + self.user_edit.setText(_translate("Form", "root")) + self.password_label.setText(_translate("Form", "Password")) + self.port_label.setText(_translate("Form", "Port")) + self.port_edit.setText(_translate("Form", "3306")) + self.database_name_label.setText(_translate("Form", "Database name")) + self.database_name_edit.setText(_translate("Form", "athnos")) + self.show_databases_button.setText(_translate("Form", "Show available names")) + self.test_connection_button.setText(_translate("Form", "Test connection")) + self.save_button.setText(_translate("Form", "Save")) + + def after_setup_ui(self) -> None: + item_model = QtGui.QStandardItemModel() + # loads all sql driver to the database combobox + for i, driver in enumerate(default_databases): + item = QtGui.QStandardItem(driver['title']) + # if the driver is not available, the index gets disabled + if not QtSql.QSqlDatabase.isDriverAvailable(driver['driver']): + item.setEnabled(False) + item_model.setItem(i, 0, item) + + # sets the item model to the database type combobox + self.database_type.setModel(item_model) + + # gets called if another database driver is chosen + self.database_type.currentIndexChanged.connect(self.on_database_index_change) + + # database file specific + self.database_file.clicked.connect(self.on_choose_database_file) + self.delete_file_button.clicked.connect(self.on_remove_database_file) + self.add_file_button.clicked.connect(self.on_new_database_file) + + # database settings + self.port_edit.setValidator(QtGui.QIntValidator(0, 65535)) + + self.show_databases_button.clicked.connect(self.on_show_databases) + self.database_name_proxy_model = QtCore.QSortFilterProxyModel() + self.database_name_edit.textChanged.connect(lambda: self.database_name_proxy_model.setFilterRegExp(QtCore.QRegExp(f'^{self.database_name_edit.text()}')) if not self.databases_view.isHidden() else None) + self.databases_view.doubleClicked.connect(lambda a0: self.database_name_edit.setText(a0.data(QtCore.Qt.DisplayRole))) + + # test connection / save buttons + self.test_connection_button.clicked.connect(self.on_test_connection) + self.save_button.clicked.connect(self.on_save) + + # chooses the default database + self.on_database_index_change(-1 if len(default_databases) == 0 else 0) + + def on_database_index_change(self, index: int) -> None: + """Changes the text edit etc. settings, depending on the `index`""" + if index == -1: + self.database_settings_frame.setEnabled(False) + self.database_ip_edit.setText('') + self.database_name_edit.setText('') + self.user_edit.setText('') + self.password_edit.setText('') + self.port_edit.setText('') + return + + self.current_index = index + database = default_databases[index] + + # sets the content of `self.database_file_hlayout` enabled or disabled, according to if database['file'] is True or False + for i in range(self.database_file_hlayout.count()): + item = self.database_file_hlayout.itemAt(i) + try: + item.widget().setEnabled(database['ip'] is None) + except AttributeError: + for j in range(item.layout().count()): + item.layout().itemAt(j).widget().setEnabled(database['ip'] is None) + + self.databases_view.setModel(None) + + # currently only sqlite3 + if database['ip'] is None: + self.file_name_label.setText('No file selected') + self.database_settings_frame.setEnabled(False) + self.database_ip_edit.setText('') + self.database_name_edit.setText('') + self.user_edit.setText('') + self.password_edit.setText('') + self.port_edit.setText('') + else: + self.file_name_label.setText('No file selected') + self.database_settings_frame.setEnabled(True) + self.database_ip_edit.setText('127.0.0.1') + self.database_name_edit.setText('athnos') + self.user_edit.setText(database['user']) + self.password_edit.setText(database['password']) + self.port_edit.setText(str(database['port'])) + + def on_choose_database_file(self) -> None: + """Opens a file choose dialog""" + if default_databases[self.current_index]['driver'] == 'QSQLITE': + file_name = 'SQLite3 database' + file_endings = ['sqlite', 'sqlite3'] + else: + file_endings = ['*'] + file_name = 'Database file' + + file = QtWidgets.QFileDialog.getOpenFileName(self.window, filter=f'{file_name}({" *.".join(file_endings)});;All files(*.*)')[0] + if file: + self.file = file + self.file_name_label.setText(file) + + def on_new_database_file(self) -> None: + """Opens a create file dialog for a database file""" + if default_databases[self.current_index]['driver'] == 'QSQLITE': + file_name = 'SQLite3 database' + file_endings = ['sqlite', 'sqlite3'] + else: + file_endings = ['*'] + file_name = 'Database file' + + file = QtWidgets.QFileDialog.getSaveFileName(self.window, filter=f'{file_name}({" *.".join(file_endings)});;All files(*.*)')[0] + if file: + self.file = file + self.file_name_label.setText(file) + self.is_database_file_new = True + + def on_remove_database_file(self) -> None: + """Removes the database file (internal)""" + self.file = None + self.file_name_label.setText('No file selected') + + def on_show_databases(self) -> None: + """Shows all available databases (names)""" + if self.database_type.currentText().lower() in ('mariadb', 'mysql', 'postgresql'): + result = QtWidgets.QMessageBox.question(self.window, 'Connect to database', + 'To show all databases / database names, I have to connect with the ip. ' + 'Proceed?') + if result == QtWidgets.QMessageBox.Yes: + databases_model = QtGui.QStandardItemModel() + db = self.on_test_connection(True, False) + if db: + if self.database_type.currentText().lower() == 'postgresql': + databases = db.exec('SELECT datname FROM pg_database') + else: + databases = db.exec('SHOW DATABASES') + i = 0 + while databases.next(): + databases_model.setItem(i, 0, QtGui.QStandardItem(databases.value(0))) + i += 1 + + self.database_name_proxy_model.setSourceModel(databases_model) + self.databases_view.setModel(self.database_name_proxy_model) + self.database_name_edit.setText('') + else: + QtWidgets.QMessageBox.warning(self.window, 'Not supported', 'Currently database / name detection is only available for ' + 'MariaDB, MySQL and PostgreSQL') + + def on_test_connection(self, return_db=False, specify_database=True) -> Union[QtSql.QSqlDatabase, None]: + """Test the connection to the database with the current settings""" + database = default_databases[self.current_index] + + db = QtSql.QSqlDatabase(database['driver']) + + # only sqlite3 which uses files to store the data in it has no ip + if not database['ip']: + if self.file: + if os.path.isfile(self.file) or self.is_database_file_new: + db.setDatabaseName(self.file) + message = None + else: + message = f'The file {self.file} does not exist' + else: + message = 'No file is selected' + + if message: + # the connection would be successful, even if the file does not exist + QtWidgets.QMessageBox.warning(self.window, 'Connection failed', f'Could not connect to the database: {message}', + QtWidgets.QMessageBox.Ok) + return + else: + if specify_database: + db.setDatabaseName(self.database_name_edit.text()) + + db.setHostName(self.database_ip_edit.text()) + db.setUserName(self.user_edit.text()) + db.setPassword(self.password_edit.text()) + db.setPort(int(self.port_edit.text())) + + if db.open(): + if specify_database: + is_table = 'test' in db.tables() + # creates a test table to check if the database integrity + db.exec('CREATE TABLE IF NOT EXISTS test (test varchar(1))') + if not db.lastError().isValid(): + if specify_database and not is_table: + db.exec('DROP TABLE test') + if return_db: + return db + else: + QtWidgets.QMessageBox.information(self.window, 'Connection successful', 'Connected successful to the database', QtWidgets.QMessageBox.Ok) + db.close() + return + QtWidgets.QMessageBox.warning(self.window, 'Connection failed', f'Could not connect to the database: {db.lastError().text()}', QtWidgets.QMessageBox.Ok) + + return + + def on_save(self) -> None: + """Saves the connection""" + # the connection gets tested first + db = self.on_test_connection(True) + if db: + self.database = Database(db, self.database._auto_table) + # writes the connection in the config + for item in default_databases: + if item['driver'] == db.driverName(): + item = copy(item) + if self.file: + item['name'] = self.file + else: + item['name'] = self.database_ip_edit.text() + config['database'] = item + break + + self.close() + + def show(self) -> Union[QtSql.QSqlDatabase, None]: + super().show() + return self.database diff --git a/athnos/window/importexport.py b/athnos/window/importexport.py new file mode 100755 index 0000000..6cae589 --- /dev/null +++ b/athnos/window/importexport.py @@ -0,0 +1,125 @@ +from PyQt5 import QtWidgets, QtSql, QtCore, QtGui +from .window import Window +from ..database import Database +from .. import logger + + +class _ImportExport: + + def __init__(self, main_window: Window, database_file: str): + """ + Args: + main_window Window: The instance of the main window + """ + self.main_window = main_window + + self.old_database = self.main_window.database + self.old_model = self.main_window.model + + db = QtSql.QSqlDatabase('QSQLITE') + db.setDatabaseName(database_file) + + # tries to open a database connection + if not db.open(): + raise IOError(db.lastError().text()) + + self.main_window.window.setWindowTitle(f'Athnos [{self.__class__.__name__}]') + self.main_window.window.setStyleSheet('#main_splitter {border: 1px solid #08F7FE;}') + + self.main_window.database = Database(db, False) + self.main_window.load_model() + + self.extra_button_hlayout = QtWidgets.QHBoxLayout() + self.cancel_button = QtWidgets.QPushButton('Cancel') + self.cancel_button.clicked.connect(lambda: self.on_cancel()) + + self.extra_button_hlayout.addWidget(self.cancel_button) + + self.main_window.verticalLayout.addLayout(self.extra_button_hlayout) + + def on_cancel(self) -> None: + """Gets called if the cancel button is pressed""" + result = QtWidgets.QMessageBox.warning(self.main_window.window, 'Cancel', 'Do you really want to cancel? All changes won\'t be saved!', + QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel, QtWidgets.QMessageBox.Cancel) + if result == QtWidgets.QMessageBox.Ok: + self.reset() + else: + return + + def reset(self) -> None: + """Resets the modified main window""" + # deletes all widgets in the added layout + for i in range(self.extra_button_hlayout.count()): + self.extra_button_hlayout.itemAt(i).widget().deleteLater() + + # removes the added layout from the main window + for i in range(self.main_window.verticalLayout.count()): + item = self.main_window.verticalLayout.itemAt(i) + if item.layout() == self.extra_button_hlayout: + self.main_window.verticalLayout.removeItem(item) + break + + self.main_window.window.setStyleSheet('') + self.main_window.window.setWindowTitle('Athnos') + self.main_window.database = self.old_database + self.main_window.load_model() + + +class Import(_ImportExport): + + def __init__(self, main_window: Window): + file = QtWidgets.QFileDialog.getOpenFileName(filter='sqlite3 database (*.sqlite3 *.sqlite);;zip file (*.zip);;All files (*.*)')[0] + if not file: + self.main_window.close() + return + super().__init__(main_window, file) + + self.import_button = QtWidgets.QPushButton('Import') + self.import_button.clicked.connect(self.on_import) + + self.extra_button_hlayout.insertWidget(0, self.import_button) + + def on_import(self) -> None: + """Imports all database entries from the database to import into the current database""" + # creates dicts for every table with the hash of the item as key and the item itself as value + clips = {hash(clip):clip for clip in self.main_window.database.Clip.get_all()} + items = {hash(item):item for item in self.main_window.database.Item.get_all()} + sources = {hash(source):source for source in self.main_window.database.Source.get_all()} + tags = {hash(tag):tag for tag in self.main_window.database.Tag.get_all()} + + for old_table, new_table in {self.old_database.Clip.get_all(): clips, + self.old_database.Item.get_all(): items, + self.old_database.Source.get_all(): sources, + self.old_database.Tag.get_all(): tags}.items(): + for item in old_table: + # every item is hashed and checked if it (the hash) already exists + item_hash = hash(item) + if item_hash in new_table: + # if the item already exists, it's will be deleted + logger.warning(f'Skipping {item.table_name} with id {item.id} - already exists in the database to be imported') + del new_table[item_hash] + + if clips: + query = self.old_database.Clip.new_query() + query.exec(f'INSERT INTO {self.old_database.Clip.table_name} (source_id, path, type, name, description) VALUES ' + f'({"), (".join([", ".join(self.old_database.Item.make_sql_valid([clip.source_id, clip.path, clip.type.value, clip.name, clip.description])) for clip in clips.values()])})') + self.old_database.Clip.validate_query(query) + if items: + query = self.old_database.Item.new_query() + query.exec(f'INSERT INTO {self.old_database.Item.table_name} (clip_id, tag_id) VALUES ' + f'({"), (".join([", ".join(self.old_database.Item.make_sql_valid([item.clip_id, item.tag_id])) for item in items.values()])})') + self.old_database.Item.validate_query(query) + if sources: + query = self.old_database.Source.new_query() + query.exec(f'INSERT INTO {self.old_database.Source.table_name} (path, type, name, season, episode, description) VALUES ' + f'({"), (".join([", ".join(self.old_database.Source.make_sql_valid([source.path, source.type.value, source.name, source.season, source.episode, source.description])) for source in sources.values()])})') + self.old_database.Source.validate_query(query) + if tags: + query = self.old_database.Tag.new_query() + query.exec(f'INSERT INTO {self.old_database.Tag.table_name} (name) VALUES ' + f'({"), (".join([", ".join(self.old_database.Tag.make_sql_valid([tag.name])) for tag in tags.values()])})') + self.old_database.Tag.validate_query(query) + + logger.debug(f'Imported {len(clips)} clips, {len(items)} items, {len(sources)} sources, {len(tags)} tags to the database') + + self.reset() diff --git a/athnos/window/resources.py b/athnos/window/resources.py new file mode 100755 index 0000000..b82eb2b --- /dev/null +++ b/athnos/window/resources.py @@ -0,0 +1,669 @@ +# -*- coding: utf-8 -*- + +# Resource object code +# +# Created by: The Resource Compiler for PyQt5 (Qt v5.14.2) +# +# WARNING! All changes made in this file will be lost! + +from PyQt5 import QtCore + +qt_resource_data = b"\ +\x00\x00\x0d\x92\ +\x3c\ +\x3f\x78\x6d\x6c\x20\x76\x65\x72\x73\x69\x6f\x6e\x3d\x22\x31\x2e\ +\x30\x22\x20\x65\x6e\x63\x6f\x64\x69\x6e\x67\x3d\x22\x55\x54\x46\ +\x2d\x38\x22\x20\x73\x74\x61\x6e\x64\x61\x6c\x6f\x6e\x65\x3d\x22\ +\x6e\x6f\x22\x3f\x3e\x0a\x3c\x73\x76\x67\x0a\x20\x20\x20\x78\x6d\ +\x6c\x6e\x73\x3a\x64\x63\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x70\ +\x75\x72\x6c\x2e\x6f\x72\x67\x2f\x64\x63\x2f\x65\x6c\x65\x6d\x65\ +\x6e\x74\x73\x2f\x31\x2e\x31\x2f\x22\x0a\x20\x20\x20\x78\x6d\x6c\ +\x6e\x73\x3a\x63\x63\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x63\x72\ +\x65\x61\x74\x69\x76\x65\x63\x6f\x6d\x6d\x6f\x6e\x73\x2e\x6f\x72\ +\x67\x2f\x6e\x73\x23\x22\x0a\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\ +\x72\x64\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\x2e\ +\x77\x33\x2e\x6f\x72\x67\x2f\x31\x39\x39\x39\x2f\x30\x32\x2f\x32\ +\x32\x2d\x72\x64\x66\x2d\x73\x79\x6e\x74\x61\x78\x2d\x6e\x73\x23\ +\x22\x0a\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x73\x76\x67\x3d\x22\ +\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\x2e\x77\x33\x2e\x6f\x72\ +\x67\x2f\x32\x30\x30\x30\x2f\x73\x76\x67\x22\x0a\x20\x20\x20\x78\ +\x6d\x6c\x6e\x73\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\ +\x2e\x77\x33\x2e\x6f\x72\x67\x2f\x32\x30\x30\x30\x2f\x73\x76\x67\ +\x22\x0a\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x73\x6f\x64\x69\x70\ +\x6f\x64\x69\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x73\x6f\x64\x69\ +\x70\x6f\x64\x69\x2e\x73\x6f\x75\x72\x63\x65\x66\x6f\x72\x67\x65\ +\x2e\x6e\x65\x74\x2f\x44\x54\x44\x2f\x73\x6f\x64\x69\x70\x6f\x64\ +\x69\x2d\x30\x2e\x64\x74\x64\x22\x0a\x20\x20\x20\x78\x6d\x6c\x6e\ +\x73\x3a\x69\x6e\x6b\x73\x63\x61\x70\x65\x3d\x22\x68\x74\x74\x70\ +\x3a\x2f\x2f\x77\x77\x77\x2e\x69\x6e\x6b\x73\x63\x61\x70\x65\x2e\ +\x6f\x72\x67\x2f\x6e\x61\x6d\x65\x73\x70\x61\x63\x65\x73\x2f\x69\ +\x6e\x6b\x73\x63\x61\x70\x65\x22\x0a\x20\x20\x20\x76\x65\x72\x73\ +\x69\x6f\x6e\x3d\x22\x31\x2e\x30\x22\x0a\x20\x20\x20\x77\x69\x64\ +\x74\x68\x3d\x22\x39\x30\x2e\x30\x30\x30\x30\x30\x30\x70\x74\x22\ +\x0a\x20\x20\x20\x68\x65\x69\x67\x68\x74\x3d\x22\x39\x30\x2e\x30\ +\x30\x30\x30\x30\x30\x70\x74\x22\x0a\x20\x20\x20\x76\x69\x65\x77\ +\x42\x6f\x78\x3d\x22\x30\x20\x30\x20\x39\x30\x2e\x30\x30\x30\x30\ +\x30\x30\x20\x39\x30\x2e\x30\x30\x30\x30\x30\x30\x22\x0a\x20\x20\ +\x20\x70\x72\x65\x73\x65\x72\x76\x65\x41\x73\x70\x65\x63\x74\x52\ +\x61\x74\x69\x6f\x3d\x22\x78\x4d\x69\x64\x59\x4d\x69\x64\x20\x6d\ +\x65\x65\x74\x22\x0a\x20\x20\x20\x69\x64\x3d\x22\x73\x76\x67\x36\ +\x22\x0a\x20\x20\x20\x73\x6f\x64\x69\x70\x6f\x64\x69\x3a\x64\x6f\ +\x63\x6e\x61\x6d\x65\x3d\x22\x68\x6f\x76\x65\x72\x2e\x73\x76\x67\ +\x22\x0a\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x76\x65\ +\x72\x73\x69\x6f\x6e\x3d\x22\x31\x2e\x30\x2e\x31\x20\x28\x31\x2e\ +\x30\x2e\x31\x2b\x72\x37\x34\x29\x22\x3e\x0a\x20\x20\x3c\x6d\x65\ +\x74\x61\x64\x61\x74\x61\x0a\x20\x20\x20\x20\x20\x69\x64\x3d\x22\ +\x6d\x65\x74\x61\x64\x61\x74\x61\x31\x32\x22\x3e\x0a\x20\x20\x20\ +\x20\x3c\x72\x64\x66\x3a\x52\x44\x46\x3e\x0a\x20\x20\x20\x20\x20\ +\x20\x3c\x63\x63\x3a\x57\x6f\x72\x6b\x0a\x20\x20\x20\x20\x20\x20\ +\x20\x20\x20\x72\x64\x66\x3a\x61\x62\x6f\x75\x74\x3d\x22\x22\x3e\ +\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x3c\x64\x63\x3a\x66\x6f\x72\ +\x6d\x61\x74\x3e\x69\x6d\x61\x67\x65\x2f\x73\x76\x67\x2b\x78\x6d\ +\x6c\x3c\x2f\x64\x63\x3a\x66\x6f\x72\x6d\x61\x74\x3e\x0a\x20\x20\ +\x20\x20\x20\x20\x20\x20\x3c\x64\x63\x3a\x74\x79\x70\x65\x0a\x20\ +\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x72\x64\x66\x3a\x72\x65\ +\x73\x6f\x75\x72\x63\x65\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x70\ +\x75\x72\x6c\x2e\x6f\x72\x67\x2f\x64\x63\x2f\x64\x63\x6d\x69\x74\ +\x79\x70\x65\x2f\x53\x74\x69\x6c\x6c\x49\x6d\x61\x67\x65\x22\x20\ +\x2f\x3e\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x3c\x64\x63\x3a\x74\ +\x69\x74\x6c\x65\x3e\x3c\x2f\x64\x63\x3a\x74\x69\x74\x6c\x65\x3e\ +\x0a\x20\x20\x20\x20\x20\x20\x3c\x2f\x63\x63\x3a\x57\x6f\x72\x6b\ +\x3e\x0a\x20\x20\x20\x20\x3c\x2f\x72\x64\x66\x3a\x52\x44\x46\x3e\ +\x0a\x20\x20\x3c\x2f\x6d\x65\x74\x61\x64\x61\x74\x61\x3e\x0a\x20\ +\x20\x3c\x64\x65\x66\x73\x0a\x20\x20\x20\x20\x20\x69\x64\x3d\x22\ +\x64\x65\x66\x73\x31\x30\x22\x20\x2f\x3e\x0a\x20\x20\x3c\x73\x6f\ +\x64\x69\x70\x6f\x64\x69\x3a\x6e\x61\x6d\x65\x64\x76\x69\x65\x77\ +\x0a\x20\x20\x20\x20\x20\x70\x61\x67\x65\x63\x6f\x6c\x6f\x72\x3d\ +\x22\x23\x66\x66\x66\x66\x66\x66\x22\x0a\x20\x20\x20\x20\x20\x62\ +\x6f\x72\x64\x65\x72\x63\x6f\x6c\x6f\x72\x3d\x22\x23\x36\x36\x36\ +\x36\x36\x36\x22\x0a\x20\x20\x20\x20\x20\x62\x6f\x72\x64\x65\x72\ +\x6f\x70\x61\x63\x69\x74\x79\x3d\x22\x31\x22\x0a\x20\x20\x20\x20\ +\x20\x6f\x62\x6a\x65\x63\x74\x74\x6f\x6c\x65\x72\x61\x6e\x63\x65\ +\x3d\x22\x31\x30\x22\x0a\x20\x20\x20\x20\x20\x67\x72\x69\x64\x74\ +\x6f\x6c\x65\x72\x61\x6e\x63\x65\x3d\x22\x31\x30\x22\x0a\x20\x20\ +\x20\x20\x20\x67\x75\x69\x64\x65\x74\x6f\x6c\x65\x72\x61\x6e\x63\ +\x65\x3d\x22\x31\x30\x22\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\ +\x63\x61\x70\x65\x3a\x70\x61\x67\x65\x6f\x70\x61\x63\x69\x74\x79\ +\x3d\x22\x30\x22\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\ +\x70\x65\x3a\x70\x61\x67\x65\x73\x68\x61\x64\x6f\x77\x3d\x22\x32\ +\x22\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\ +\x77\x69\x6e\x64\x6f\x77\x2d\x77\x69\x64\x74\x68\x3d\x22\x31\x39\ +\x32\x30\x22\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\ +\x65\x3a\x77\x69\x6e\x64\x6f\x77\x2d\x68\x65\x69\x67\x68\x74\x3d\ +\x22\x31\x30\x31\x35\x22\x0a\x20\x20\x20\x20\x20\x69\x64\x3d\x22\ +\x6e\x61\x6d\x65\x64\x76\x69\x65\x77\x38\x22\x0a\x20\x20\x20\x20\ +\x20\x73\x68\x6f\x77\x67\x72\x69\x64\x3d\x22\x66\x61\x6c\x73\x65\ +\x22\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\ +\x7a\x6f\x6f\x6d\x3d\x22\x31\x2e\x30\x35\x33\x32\x39\x34\x35\x22\ +\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x63\ +\x78\x3d\x22\x35\x30\x33\x2e\x38\x30\x36\x30\x33\x22\x0a\x20\x20\ +\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x63\x79\x3d\x22\ +\x37\x33\x2e\x33\x38\x31\x38\x31\x37\x22\x0a\x20\x20\x20\x20\x20\ +\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x77\x69\x6e\x64\x6f\x77\x2d\ +\x78\x3d\x22\x30\x22\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\ +\x61\x70\x65\x3a\x77\x69\x6e\x64\x6f\x77\x2d\x79\x3d\x22\x30\x22\ +\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x77\ +\x69\x6e\x64\x6f\x77\x2d\x6d\x61\x78\x69\x6d\x69\x7a\x65\x64\x3d\ +\x22\x31\x22\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\ +\x65\x3a\x63\x75\x72\x72\x65\x6e\x74\x2d\x6c\x61\x79\x65\x72\x3d\ +\x22\x73\x76\x67\x36\x22\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\ +\x63\x61\x70\x65\x3a\x64\x6f\x63\x75\x6d\x65\x6e\x74\x2d\x72\x6f\ +\x74\x61\x74\x69\x6f\x6e\x3d\x22\x30\x22\x20\x2f\x3e\x0a\x20\x20\ +\x3c\x67\x0a\x20\x20\x20\x20\x20\x74\x72\x61\x6e\x73\x66\x6f\x72\ +\x6d\x3d\x22\x74\x72\x61\x6e\x73\x6c\x61\x74\x65\x28\x30\x2e\x30\ +\x30\x30\x30\x30\x30\x2c\x39\x30\x2e\x30\x30\x30\x30\x30\x30\x29\ +\x20\x73\x63\x61\x6c\x65\x28\x30\x2e\x31\x30\x30\x30\x30\x30\x2c\ +\x2d\x30\x2e\x31\x30\x30\x30\x30\x30\x29\x22\x0a\x20\x20\x20\x20\ +\x20\x66\x69\x6c\x6c\x3d\x22\x23\x65\x37\x34\x63\x33\x63\x22\x0a\ +\x20\x20\x20\x20\x20\x73\x74\x72\x6f\x6b\x65\x3d\x22\x6e\x6f\x6e\ +\x65\x22\x0a\x20\x20\x20\x20\x20\x69\x64\x3d\x22\x67\x34\x22\x0a\ +\x20\x20\x20\x20\x20\x73\x74\x79\x6c\x65\x3d\x22\x66\x69\x6c\x6c\ +\x3a\x23\x66\x66\x30\x30\x30\x30\x22\x3e\x0a\x20\x20\x20\x20\x3c\ +\x70\x61\x74\x68\x0a\x20\x20\x20\x20\x20\x20\x20\x64\x3d\x22\x4d\ +\x33\x38\x31\x20\x38\x30\x30\x20\x63\x2d\x31\x34\x34\x20\x2d\x33\ +\x30\x20\x2d\x32\x35\x34\x20\x2d\x31\x34\x35\x20\x2d\x32\x38\x32\ +\x20\x2d\x32\x39\x33\x20\x2d\x34\x32\x20\x2d\x32\x33\x32\x20\x31\ +\x37\x36\x20\x2d\x34\x35\x30\x20\x34\x30\x38\x20\x2d\x34\x30\x38\ +\x20\x32\x33\x30\x20\x34\x33\x20\x33\x36\x31\x20\x32\x38\x31\x20\ +\x32\x37\x30\x20\x34\x39\x33\x20\x2d\x36\x34\x20\x31\x34\x38\x20\ +\x2d\x32\x33\x38\x20\x32\x34\x30\x20\x2d\x33\x39\x36\x20\x32\x30\ +\x38\x7a\x20\x6d\x31\x36\x20\x2d\x32\x35\x32\x20\x6c\x35\x33\x20\ +\x2d\x35\x32\x20\x35\x34\x20\x35\x33\x20\x63\x35\x35\x20\x35\x34\ +\x20\x38\x37\x20\x36\x34\x20\x39\x34\x20\x32\x37\x20\x33\x20\x2d\ +\x31\x32\x20\x2d\x31\x35\x20\x2d\x33\x38\x20\x2d\x34\x39\x20\x2d\ +\x37\x32\x20\x6c\x2d\x35\x33\x20\x2d\x35\x34\x20\x35\x33\x20\x2d\ +\x35\x34\x20\x63\x35\x34\x20\x2d\x35\x35\x20\x36\x34\x20\x2d\x38\ +\x37\x20\x32\x37\x20\x2d\x39\x34\x20\x2d\x31\x32\x20\x2d\x33\x20\ +\x2d\x33\x38\x20\x31\x35\x20\x2d\x37\x32\x20\x34\x39\x20\x6c\x2d\ +\x35\x34\x20\x35\x33\x20\x2d\x35\x34\x20\x2d\x35\x33\x20\x63\x2d\ +\x33\x34\x20\x2d\x33\x34\x20\x2d\x36\x30\x20\x2d\x35\x32\x20\x2d\ +\x37\x32\x20\x2d\x34\x39\x20\x2d\x33\x37\x20\x37\x20\x2d\x32\x37\ +\x20\x33\x39\x20\x32\x37\x20\x39\x34\x20\x6c\x35\x33\x20\x35\x34\ +\x20\x2d\x35\x32\x20\x35\x33\x20\x63\x2d\x34\x39\x20\x34\x39\x20\ +\x2d\x36\x31\x20\x37\x34\x20\x2d\x34\x35\x20\x39\x30\x20\x31\x36\ +\x20\x31\x36\x20\x34\x31\x20\x34\x20\x39\x30\x20\x2d\x34\x35\x7a\ +\x22\x0a\x20\x20\x20\x20\x20\x20\x20\x69\x64\x3d\x22\x70\x61\x74\ +\x68\x32\x22\x0a\x20\x20\x20\x20\x20\x20\x20\x73\x74\x79\x6c\x65\ +\x3d\x22\x66\x69\x6c\x6c\x3a\x23\x66\x66\x30\x30\x30\x30\x22\x20\ +\x2f\x3e\x0a\x20\x20\x3c\x2f\x67\x3e\x0a\x20\x20\x3c\x70\x61\x74\ +\x68\x0a\x20\x20\x20\x20\x20\x73\x74\x79\x6c\x65\x3d\x22\x66\x69\ +\x6c\x6c\x3a\x23\x66\x66\x66\x66\x66\x66\x3b\x73\x74\x72\x6f\x6b\ +\x65\x2d\x77\x69\x64\x74\x68\x3a\x30\x2e\x31\x31\x38\x36\x37\x35\ +\x22\x0a\x20\x20\x20\x20\x20\x64\x3d\x22\x6d\x20\x34\x32\x2e\x36\ +\x32\x32\x36\x31\x2c\x37\x39\x2e\x35\x34\x31\x38\x39\x33\x20\x63\ +\x20\x2d\x33\x2e\x31\x31\x36\x31\x36\x35\x2c\x2d\x31\x2e\x30\x32\ +\x39\x36\x31\x32\x20\x2d\x33\x2e\x30\x35\x38\x38\x2c\x2d\x33\x2e\ +\x33\x36\x36\x38\x30\x38\x20\x30\x2e\x31\x38\x38\x32\x35\x34\x2c\ +\x2d\x37\x2e\x36\x36\x39\x39\x38\x39\x20\x31\x2e\x33\x39\x36\x34\ +\x36\x39\x2c\x2d\x31\x2e\x38\x35\x30\x36\x38\x32\x20\x32\x2e\x37\ +\x31\x36\x37\x39\x39\x2c\x2d\x33\x2e\x32\x38\x37\x33\x38\x37\x20\ +\x37\x2e\x32\x38\x31\x38\x35\x33\x2c\x2d\x37\x2e\x39\x32\x33\x36\ +\x38\x36\x20\x6c\x20\x33\x2e\x38\x38\x34\x39\x33\x33\x2c\x2d\x33\ +\x2e\x39\x34\x35\x35\x36\x32\x20\x2d\x34\x2e\x35\x36\x34\x32\x34\ +\x31\x2c\x2d\x34\x2e\x36\x35\x38\x33\x39\x34\x20\x63\x20\x2d\x36\ +\x2e\x30\x38\x35\x35\x35\x2c\x2d\x36\x2e\x32\x31\x31\x30\x38\x35\ +\x20\x2d\x37\x2e\x34\x33\x39\x37\x30\x39\x2c\x2d\x37\x2e\x37\x37\ +\x35\x39\x33\x31\x20\x2d\x38\x2e\x35\x31\x32\x35\x35\x39\x2c\x2d\ +\x39\x2e\x38\x33\x36\x39\x38\x36\x20\x2d\x31\x2e\x31\x31\x34\x30\ +\x39\x31\x2c\x2d\x32\x2e\x31\x34\x30\x32\x38\x32\x20\x2d\x31\x2e\ +\x30\x33\x35\x30\x35\x33\x2c\x2d\x33\x2e\x35\x35\x33\x39\x36\x34\ +\x20\x30\x2e\x32\x36\x30\x37\x30\x38\x2c\x2d\x34\x2e\x36\x36\x33\ +\x30\x38\x38\x20\x30\x2e\x37\x30\x38\x37\x38\x34\x2c\x2d\x30\x2e\ +\x36\x30\x36\x36\x39\x32\x20\x31\x2e\x35\x38\x35\x35\x37\x34\x2c\ +\x2d\x30\x2e\x38\x34\x37\x31\x20\x32\x2e\x34\x35\x33\x36\x34\x37\ +\x2c\x2d\x30\x2e\x36\x37\x32\x37\x36\x36\x20\x31\x2e\x32\x38\x32\ +\x35\x34\x32\x2c\x30\x2e\x32\x35\x37\x35\x37\x32\x20\x32\x2e\x38\ +\x39\x33\x39\x38\x39\x2c\x31\x2e\x31\x37\x37\x35\x34\x36\x20\x34\ +\x2e\x38\x36\x39\x36\x32\x31\x2c\x32\x2e\x37\x38\x30\x30\x36\x35\ +\x20\x31\x2e\x34\x32\x38\x36\x35\x32\x2c\x31\x2e\x31\x35\x38\x38\ +\x34\x20\x31\x2e\x37\x35\x38\x39\x37\x34\x2c\x31\x2e\x34\x36\x39\ +\x39\x39\x39\x20\x36\x2e\x39\x32\x34\x37\x30\x31\x2c\x36\x2e\x35\ +\x32\x32\x39\x38\x32\x20\x6c\x20\x34\x2e\x35\x39\x38\x33\x32\x33\ +\x2c\x34\x2e\x34\x39\x37\x39\x36\x34\x20\x33\x2e\x39\x34\x36\x32\ +\x39\x36\x2c\x2d\x33\x2e\x38\x38\x35\x36\x35\x35\x20\x63\x20\x35\ +\x2e\x36\x38\x37\x34\x38\x38\x2c\x2d\x35\x2e\x36\x30\x30\x30\x39\ +\x20\x37\x2e\x35\x30\x35\x32\x36\x2c\x2d\x37\x2e\x31\x38\x38\x37\ +\x36\x31\x20\x39\x2e\x38\x33\x34\x37\x30\x31\x2c\x2d\x38\x2e\x35\ +\x39\x35\x31\x39\x37\x20\x30\x2e\x39\x39\x35\x37\x32\x37\x2c\x2d\ +\x30\x2e\x36\x30\x31\x31\x38\x35\x20\x32\x2e\x35\x36\x34\x37\x37\ +\x35\x2c\x2d\x31\x2e\x31\x39\x30\x33\x38\x32\x20\x33\x2e\x31\x36\ +\x36\x37\x30\x39\x2c\x2d\x31\x2e\x31\x38\x39\x31\x33\x38\x20\x31\ +\x2e\x33\x32\x39\x35\x38\x39\x2c\x30\x2e\x30\x30\x32\x37\x20\x32\ +\x2e\x32\x35\x38\x34\x37\x31\x2c\x30\x2e\x39\x32\x38\x31\x32\x36\ +\x20\x32\x2e\x36\x33\x36\x30\x30\x38\x2c\x32\x2e\x36\x32\x36\x30\ +\x36\x33\x20\x30\x2e\x31\x38\x34\x34\x32\x34\x2c\x30\x2e\x38\x32\ +\x39\x34\x33\x34\x20\x30\x2e\x31\x31\x30\x32\x39\x2c\x31\x2e\x32\ +\x34\x30\x37\x37\x36\x20\x2d\x30\x2e\x34\x32\x34\x38\x38\x36\x2c\ +\x32\x2e\x33\x35\x37\x34\x39\x39\x20\x2d\x31\x2e\x31\x30\x34\x36\ +\x39\x2c\x32\x2e\x33\x30\x35\x31\x20\x2d\x32\x2e\x38\x35\x31\x38\ +\x31\x34\x2c\x34\x2e\x33\x30\x32\x35\x34\x34\x20\x2d\x31\x30\x2e\ +\x36\x36\x35\x38\x37\x36\x2c\x31\x32\x2e\x31\x39\x34\x30\x33\x33\ +\x20\x6c\x20\x2d\x32\x2e\x34\x39\x37\x31\x31\x38\x2c\x32\x2e\x35\ +\x32\x31\x38\x36\x31\x20\x32\x2e\x35\x35\x36\x32\x37\x36\x2c\x32\ +\x2e\x35\x38\x31\x31\x37\x35\x20\x63\x20\x37\x2e\x34\x37\x38\x39\ +\x35\x2c\x37\x2e\x35\x35\x31\x37\x39\x39\x20\x39\x2e\x31\x38\x30\ +\x34\x34\x37\x2c\x39\x2e\x35\x33\x33\x37\x30\x39\x20\x31\x30\x2e\ +\x34\x34\x32\x30\x32\x36\x2c\x31\x32\x2e\x31\x36\x32\x39\x31\x32\ +\x20\x31\x2e\x31\x31\x38\x30\x38\x33\x2c\x32\x2e\x33\x33\x30\x31\ +\x34\x37\x20\x30\x2e\x38\x32\x34\x33\x34\x33\x2c\x33\x2e\x37\x33\ +\x32\x36\x33\x31\x20\x2d\x30\x2e\x39\x34\x38\x33\x39\x35\x2c\x34\ +\x2e\x35\x32\x38\x31\x39\x20\x2d\x31\x2e\x34\x36\x38\x33\x31\x37\ +\x2c\x30\x2e\x36\x35\x38\x39\x34\x31\x20\x2d\x32\x2e\x34\x36\x33\ +\x37\x33\x2c\x30\x2e\x35\x31\x37\x39\x35\x38\x20\x2d\x34\x2e\x32\ +\x34\x36\x37\x39\x31\x2c\x2d\x30\x2e\x36\x30\x31\x34\x38\x32\x20\ +\x2d\x32\x2e\x32\x33\x30\x38\x34\x33\x2c\x2d\x31\x2e\x34\x30\x30\ +\x35\x36\x36\x20\x2d\x33\x2e\x31\x39\x35\x34\x35\x2c\x2d\x32\x2e\ +\x32\x35\x33\x34\x37\x20\x2d\x39\x2e\x32\x30\x30\x36\x38\x33\x2c\ +\x2d\x38\x2e\x31\x33\x35\x32\x32\x34\x20\x6c\x20\x2d\x34\x2e\x35\ +\x39\x39\x37\x35\x33\x2c\x2d\x34\x2e\x35\x30\x35\x31\x37\x33\x20\ +\x2d\x34\x2e\x35\x33\x38\x32\x34\x33\x2c\x34\x2e\x34\x33\x37\x39\ +\x32\x31\x20\x63\x20\x2d\x35\x2e\x31\x32\x35\x35\x30\x39\x2c\x35\ +\x2e\x30\x31\x32\x32\x30\x35\x20\x2d\x35\x2e\x34\x33\x36\x38\x35\ +\x34\x2c\x35\x2e\x33\x30\x35\x31\x37\x31\x20\x2d\x36\x2e\x38\x39\ +\x36\x37\x33\x32\x2c\x36\x2e\x34\x38\x39\x36\x31\x39\x20\x2d\x32\ +\x2e\x37\x39\x33\x39\x38\x2c\x32\x2e\x32\x36\x36\x38\x35\x32\x20\ +\x2d\x34\x2e\x35\x36\x39\x38\x35\x35\x2c\x33\x2e\x30\x33\x37\x36\ +\x36\x37\x20\x2d\x35\x2e\x39\x34\x38\x37\x37\x39\x2c\x32\x2e\x35\ +\x38\x32\x30\x35\x36\x20\x7a\x22\x0a\x20\x20\x20\x20\x20\x69\x64\ +\x3d\x22\x70\x61\x74\x68\x31\x36\x22\x0a\x20\x20\x20\x20\x20\x74\ +\x72\x61\x6e\x73\x66\x6f\x72\x6d\x3d\x22\x73\x63\x61\x6c\x65\x28\ +\x30\x2e\x37\x35\x29\x22\x20\x2f\x3e\x0a\x3c\x2f\x73\x76\x67\x3e\ +\x0a\ +\x00\x00\x0d\x4d\ +\x3c\ +\x3f\x78\x6d\x6c\x20\x76\x65\x72\x73\x69\x6f\x6e\x3d\x22\x31\x2e\ +\x30\x22\x20\x65\x6e\x63\x6f\x64\x69\x6e\x67\x3d\x22\x55\x54\x46\ +\x2d\x38\x22\x20\x73\x74\x61\x6e\x64\x61\x6c\x6f\x6e\x65\x3d\x22\ +\x6e\x6f\x22\x3f\x3e\x0a\x3c\x73\x76\x67\x0a\x20\x20\x20\x78\x6d\ +\x6c\x6e\x73\x3a\x64\x63\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x70\ +\x75\x72\x6c\x2e\x6f\x72\x67\x2f\x64\x63\x2f\x65\x6c\x65\x6d\x65\ +\x6e\x74\x73\x2f\x31\x2e\x31\x2f\x22\x0a\x20\x20\x20\x78\x6d\x6c\ +\x6e\x73\x3a\x63\x63\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x63\x72\ +\x65\x61\x74\x69\x76\x65\x63\x6f\x6d\x6d\x6f\x6e\x73\x2e\x6f\x72\ +\x67\x2f\x6e\x73\x23\x22\x0a\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\ +\x72\x64\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\x2e\ +\x77\x33\x2e\x6f\x72\x67\x2f\x31\x39\x39\x39\x2f\x30\x32\x2f\x32\ +\x32\x2d\x72\x64\x66\x2d\x73\x79\x6e\x74\x61\x78\x2d\x6e\x73\x23\ +\x22\x0a\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x73\x76\x67\x3d\x22\ +\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\x2e\x77\x33\x2e\x6f\x72\ +\x67\x2f\x32\x30\x30\x30\x2f\x73\x76\x67\x22\x0a\x20\x20\x20\x78\ +\x6d\x6c\x6e\x73\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\ +\x2e\x77\x33\x2e\x6f\x72\x67\x2f\x32\x30\x30\x30\x2f\x73\x76\x67\ +\x22\x0a\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x73\x6f\x64\x69\x70\ +\x6f\x64\x69\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x73\x6f\x64\x69\ +\x70\x6f\x64\x69\x2e\x73\x6f\x75\x72\x63\x65\x66\x6f\x72\x67\x65\ +\x2e\x6e\x65\x74\x2f\x44\x54\x44\x2f\x73\x6f\x64\x69\x70\x6f\x64\ +\x69\x2d\x30\x2e\x64\x74\x64\x22\x0a\x20\x20\x20\x78\x6d\x6c\x6e\ +\x73\x3a\x69\x6e\x6b\x73\x63\x61\x70\x65\x3d\x22\x68\x74\x74\x70\ +\x3a\x2f\x2f\x77\x77\x77\x2e\x69\x6e\x6b\x73\x63\x61\x70\x65\x2e\ +\x6f\x72\x67\x2f\x6e\x61\x6d\x65\x73\x70\x61\x63\x65\x73\x2f\x69\ +\x6e\x6b\x73\x63\x61\x70\x65\x22\x0a\x20\x20\x20\x76\x65\x72\x73\ +\x69\x6f\x6e\x3d\x22\x31\x2e\x30\x22\x0a\x20\x20\x20\x77\x69\x64\ +\x74\x68\x3d\x22\x39\x30\x2e\x30\x30\x30\x30\x30\x30\x70\x74\x22\ +\x0a\x20\x20\x20\x68\x65\x69\x67\x68\x74\x3d\x22\x39\x30\x2e\x30\ +\x30\x30\x30\x30\x30\x70\x74\x22\x0a\x20\x20\x20\x76\x69\x65\x77\ +\x42\x6f\x78\x3d\x22\x30\x20\x30\x20\x39\x30\x2e\x30\x30\x30\x30\ +\x30\x30\x20\x39\x30\x2e\x30\x30\x30\x30\x30\x30\x22\x0a\x20\x20\ +\x20\x70\x72\x65\x73\x65\x72\x76\x65\x41\x73\x70\x65\x63\x74\x52\ +\x61\x74\x69\x6f\x3d\x22\x78\x4d\x69\x64\x59\x4d\x69\x64\x20\x6d\ +\x65\x65\x74\x22\x0a\x20\x20\x20\x69\x64\x3d\x22\x73\x76\x67\x36\ +\x22\x0a\x20\x20\x20\x73\x6f\x64\x69\x70\x6f\x64\x69\x3a\x64\x6f\ +\x63\x6e\x61\x6d\x65\x3d\x22\x69\x63\x6f\x6e\x73\x38\x2d\x6d\x61\ +\x63\x6f\x73\x2d\x73\x63\x68\x6c\x69\x65\xc3\x9f\x65\x6e\x2d\x39\ +\x30\x5f\x31\x5f\x2e\x73\x76\x67\x22\x0a\x20\x20\x20\x69\x6e\x6b\ +\x73\x63\x61\x70\x65\x3a\x76\x65\x72\x73\x69\x6f\x6e\x3d\x22\x31\ +\x2e\x30\x2e\x31\x20\x28\x31\x2e\x30\x2e\x31\x2b\x72\x37\x34\x29\ +\x22\x3e\x0a\x20\x20\x3c\x6d\x65\x74\x61\x64\x61\x74\x61\x0a\x20\ +\x20\x20\x20\x20\x69\x64\x3d\x22\x6d\x65\x74\x61\x64\x61\x74\x61\ +\x31\x32\x22\x3e\x0a\x20\x20\x20\x20\x3c\x72\x64\x66\x3a\x52\x44\ +\x46\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c\x63\x63\x3a\x57\x6f\x72\ +\x6b\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x72\x64\x66\x3a\x61\ +\x62\x6f\x75\x74\x3d\x22\x22\x3e\x0a\x20\x20\x20\x20\x20\x20\x20\ +\x20\x3c\x64\x63\x3a\x66\x6f\x72\x6d\x61\x74\x3e\x69\x6d\x61\x67\ +\x65\x2f\x73\x76\x67\x2b\x78\x6d\x6c\x3c\x2f\x64\x63\x3a\x66\x6f\ +\x72\x6d\x61\x74\x3e\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x3c\x64\ +\x63\x3a\x74\x79\x70\x65\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\ +\x20\x20\x72\x64\x66\x3a\x72\x65\x73\x6f\x75\x72\x63\x65\x3d\x22\ +\x68\x74\x74\x70\x3a\x2f\x2f\x70\x75\x72\x6c\x2e\x6f\x72\x67\x2f\ +\x64\x63\x2f\x64\x63\x6d\x69\x74\x79\x70\x65\x2f\x53\x74\x69\x6c\ +\x6c\x49\x6d\x61\x67\x65\x22\x20\x2f\x3e\x0a\x20\x20\x20\x20\x20\ +\x20\x3c\x2f\x63\x63\x3a\x57\x6f\x72\x6b\x3e\x0a\x20\x20\x20\x20\ +\x3c\x2f\x72\x64\x66\x3a\x52\x44\x46\x3e\x0a\x20\x20\x3c\x2f\x6d\ +\x65\x74\x61\x64\x61\x74\x61\x3e\x0a\x20\x20\x3c\x64\x65\x66\x73\ +\x0a\x20\x20\x20\x20\x20\x69\x64\x3d\x22\x64\x65\x66\x73\x31\x30\ +\x22\x20\x2f\x3e\x0a\x20\x20\x3c\x73\x6f\x64\x69\x70\x6f\x64\x69\ +\x3a\x6e\x61\x6d\x65\x64\x76\x69\x65\x77\x0a\x20\x20\x20\x20\x20\ +\x70\x61\x67\x65\x63\x6f\x6c\x6f\x72\x3d\x22\x23\x66\x66\x66\x66\ +\x66\x66\x22\x0a\x20\x20\x20\x20\x20\x62\x6f\x72\x64\x65\x72\x63\ +\x6f\x6c\x6f\x72\x3d\x22\x23\x36\x36\x36\x36\x36\x36\x22\x0a\x20\ +\x20\x20\x20\x20\x62\x6f\x72\x64\x65\x72\x6f\x70\x61\x63\x69\x74\ +\x79\x3d\x22\x31\x22\x0a\x20\x20\x20\x20\x20\x6f\x62\x6a\x65\x63\ +\x74\x74\x6f\x6c\x65\x72\x61\x6e\x63\x65\x3d\x22\x31\x30\x22\x0a\ +\x20\x20\x20\x20\x20\x67\x72\x69\x64\x74\x6f\x6c\x65\x72\x61\x6e\ +\x63\x65\x3d\x22\x31\x30\x22\x0a\x20\x20\x20\x20\x20\x67\x75\x69\ +\x64\x65\x74\x6f\x6c\x65\x72\x61\x6e\x63\x65\x3d\x22\x31\x30\x22\ +\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x70\ +\x61\x67\x65\x6f\x70\x61\x63\x69\x74\x79\x3d\x22\x30\x22\x0a\x20\ +\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x70\x61\x67\ +\x65\x73\x68\x61\x64\x6f\x77\x3d\x22\x32\x22\x0a\x20\x20\x20\x20\ +\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x77\x69\x6e\x64\x6f\x77\ +\x2d\x77\x69\x64\x74\x68\x3d\x22\x31\x39\x32\x30\x22\x0a\x20\x20\ +\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x77\x69\x6e\x64\ +\x6f\x77\x2d\x68\x65\x69\x67\x68\x74\x3d\x22\x31\x30\x31\x35\x22\ +\x0a\x20\x20\x20\x20\x20\x69\x64\x3d\x22\x6e\x61\x6d\x65\x64\x76\ +\x69\x65\x77\x38\x22\x0a\x20\x20\x20\x20\x20\x73\x68\x6f\x77\x67\ +\x72\x69\x64\x3d\x22\x66\x61\x6c\x73\x65\x22\x0a\x20\x20\x20\x20\ +\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x7a\x6f\x6f\x6d\x3d\x22\ +\x35\x2e\x39\x35\x38\x33\x33\x33\x33\x22\x0a\x20\x20\x20\x20\x20\ +\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x63\x78\x3d\x22\x35\x31\x2e\ +\x35\x32\x39\x39\x35\x22\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\ +\x63\x61\x70\x65\x3a\x63\x79\x3d\x22\x35\x33\x2e\x31\x36\x38\x33\ +\x32\x38\x22\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\ +\x65\x3a\x77\x69\x6e\x64\x6f\x77\x2d\x78\x3d\x22\x30\x22\x0a\x20\ +\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x77\x69\x6e\ +\x64\x6f\x77\x2d\x79\x3d\x22\x30\x22\x0a\x20\x20\x20\x20\x20\x69\ +\x6e\x6b\x73\x63\x61\x70\x65\x3a\x77\x69\x6e\x64\x6f\x77\x2d\x6d\ +\x61\x78\x69\x6d\x69\x7a\x65\x64\x3d\x22\x31\x22\x0a\x20\x20\x20\ +\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x63\x75\x72\x72\x65\ +\x6e\x74\x2d\x6c\x61\x79\x65\x72\x3d\x22\x73\x76\x67\x36\x22\x20\ +\x2f\x3e\x0a\x20\x20\x3c\x67\x0a\x20\x20\x20\x20\x20\x74\x72\x61\ +\x6e\x73\x66\x6f\x72\x6d\x3d\x22\x74\x72\x61\x6e\x73\x6c\x61\x74\ +\x65\x28\x30\x2e\x30\x30\x30\x30\x30\x30\x2c\x39\x30\x2e\x30\x30\ +\x30\x30\x30\x30\x29\x20\x73\x63\x61\x6c\x65\x28\x30\x2e\x31\x30\ +\x30\x30\x30\x30\x2c\x2d\x30\x2e\x31\x30\x30\x30\x30\x30\x29\x22\ +\x0a\x20\x20\x20\x20\x20\x66\x69\x6c\x6c\x3d\x22\x23\x65\x37\x34\ +\x63\x33\x63\x22\x0a\x20\x20\x20\x20\x20\x73\x74\x72\x6f\x6b\x65\ +\x3d\x22\x6e\x6f\x6e\x65\x22\x0a\x20\x20\x20\x20\x20\x69\x64\x3d\ +\x22\x67\x34\x22\x3e\x0a\x20\x20\x20\x20\x3c\x70\x61\x74\x68\x0a\ +\x20\x20\x20\x20\x20\x20\x20\x64\x3d\x22\x4d\x33\x38\x31\x20\x38\ +\x30\x30\x20\x63\x2d\x31\x34\x34\x20\x2d\x33\x30\x20\x2d\x32\x35\ +\x34\x20\x2d\x31\x34\x35\x20\x2d\x32\x38\x32\x20\x2d\x32\x39\x33\ +\x20\x2d\x34\x32\x20\x2d\x32\x33\x32\x20\x31\x37\x36\x20\x2d\x34\ +\x35\x30\x20\x34\x30\x38\x20\x2d\x34\x30\x38\x20\x32\x33\x30\x20\ +\x34\x33\x20\x33\x36\x31\x20\x32\x38\x31\x20\x32\x37\x30\x20\x34\ +\x39\x33\x20\x2d\x36\x34\x20\x31\x34\x38\x20\x2d\x32\x33\x38\x20\ +\x32\x34\x30\x20\x2d\x33\x39\x36\x20\x32\x30\x38\x7a\x20\x6d\x31\ +\x36\x20\x2d\x32\x35\x32\x20\x6c\x35\x33\x20\x2d\x35\x32\x20\x35\ +\x34\x20\x35\x33\x20\x63\x35\x35\x20\x35\x34\x20\x38\x37\x20\x36\ +\x34\x20\x39\x34\x20\x32\x37\x20\x33\x20\x2d\x31\x32\x20\x2d\x31\ +\x35\x20\x2d\x33\x38\x20\x2d\x34\x39\x20\x2d\x37\x32\x20\x6c\x2d\ +\x35\x33\x20\x2d\x35\x34\x20\x35\x33\x20\x2d\x35\x34\x20\x63\x35\ +\x34\x20\x2d\x35\x35\x20\x36\x34\x20\x2d\x38\x37\x20\x32\x37\x20\ +\x2d\x39\x34\x20\x2d\x31\x32\x20\x2d\x33\x20\x2d\x33\x38\x20\x31\ +\x35\x20\x2d\x37\x32\x20\x34\x39\x20\x6c\x2d\x35\x34\x20\x35\x33\ +\x20\x2d\x35\x34\x20\x2d\x35\x33\x20\x63\x2d\x33\x34\x20\x2d\x33\ +\x34\x20\x2d\x36\x30\x20\x2d\x35\x32\x20\x2d\x37\x32\x20\x2d\x34\ +\x39\x20\x2d\x33\x37\x20\x37\x20\x2d\x32\x37\x20\x33\x39\x20\x32\ +\x37\x20\x39\x34\x20\x6c\x35\x33\x20\x35\x34\x20\x2d\x35\x32\x20\ +\x35\x33\x20\x63\x2d\x34\x39\x20\x34\x39\x20\x2d\x36\x31\x20\x37\ +\x34\x20\x2d\x34\x35\x20\x39\x30\x20\x31\x36\x20\x31\x36\x20\x34\ +\x31\x20\x34\x20\x39\x30\x20\x2d\x34\x35\x7a\x22\x0a\x20\x20\x20\ +\x20\x20\x20\x20\x69\x64\x3d\x22\x70\x61\x74\x68\x32\x22\x0a\x20\ +\x20\x20\x20\x20\x20\x20\x73\x74\x79\x6c\x65\x3d\x22\x66\x69\x6c\ +\x6c\x3a\x23\x66\x66\x66\x66\x66\x66\x22\x20\x2f\x3e\x0a\x20\x20\ +\x3c\x2f\x67\x3e\x0a\x20\x20\x3c\x70\x61\x74\x68\x0a\x20\x20\x20\ +\x20\x20\x73\x74\x79\x6c\x65\x3d\x22\x66\x69\x6c\x6c\x3a\x23\x38\ +\x30\x38\x30\x38\x30\x3b\x73\x74\x72\x6f\x6b\x65\x2d\x77\x69\x64\ +\x74\x68\x3a\x30\x2e\x31\x31\x38\x36\x37\x35\x22\x0a\x20\x20\x20\ +\x20\x20\x64\x3d\x22\x6d\x20\x34\x32\x2e\x36\x32\x32\x36\x31\x2c\ +\x37\x39\x2e\x35\x34\x31\x38\x39\x33\x20\x63\x20\x2d\x33\x2e\x31\ +\x31\x36\x31\x36\x35\x2c\x2d\x31\x2e\x30\x32\x39\x36\x31\x32\x20\ +\x2d\x33\x2e\x30\x35\x38\x38\x2c\x2d\x33\x2e\x33\x36\x36\x38\x30\ +\x38\x20\x30\x2e\x31\x38\x38\x32\x35\x34\x2c\x2d\x37\x2e\x36\x36\ +\x39\x39\x38\x39\x20\x31\x2e\x33\x39\x36\x34\x36\x39\x2c\x2d\x31\ +\x2e\x38\x35\x30\x36\x38\x32\x20\x32\x2e\x37\x31\x36\x37\x39\x39\ +\x2c\x2d\x33\x2e\x32\x38\x37\x33\x38\x37\x20\x37\x2e\x32\x38\x31\ +\x38\x35\x33\x2c\x2d\x37\x2e\x39\x32\x33\x36\x38\x36\x20\x6c\x20\ +\x33\x2e\x38\x38\x34\x39\x33\x33\x2c\x2d\x33\x2e\x39\x34\x35\x35\ +\x36\x32\x20\x2d\x34\x2e\x35\x36\x34\x32\x34\x31\x2c\x2d\x34\x2e\ +\x36\x35\x38\x33\x39\x34\x20\x63\x20\x2d\x36\x2e\x30\x38\x35\x35\ +\x35\x2c\x2d\x36\x2e\x32\x31\x31\x30\x38\x35\x20\x2d\x37\x2e\x34\ +\x33\x39\x37\x30\x39\x2c\x2d\x37\x2e\x37\x37\x35\x39\x33\x31\x20\ +\x2d\x38\x2e\x35\x31\x32\x35\x35\x39\x2c\x2d\x39\x2e\x38\x33\x36\ +\x39\x38\x36\x20\x2d\x31\x2e\x31\x31\x34\x30\x39\x31\x2c\x2d\x32\ +\x2e\x31\x34\x30\x32\x38\x32\x20\x2d\x31\x2e\x30\x33\x35\x30\x35\ +\x33\x2c\x2d\x33\x2e\x35\x35\x33\x39\x36\x34\x20\x30\x2e\x32\x36\ +\x30\x37\x30\x38\x2c\x2d\x34\x2e\x36\x36\x33\x30\x38\x38\x20\x30\ +\x2e\x37\x30\x38\x37\x38\x34\x2c\x2d\x30\x2e\x36\x30\x36\x36\x39\ +\x32\x20\x31\x2e\x35\x38\x35\x35\x37\x34\x2c\x2d\x30\x2e\x38\x34\ +\x37\x31\x20\x32\x2e\x34\x35\x33\x36\x34\x37\x2c\x2d\x30\x2e\x36\ +\x37\x32\x37\x36\x36\x20\x31\x2e\x32\x38\x32\x35\x34\x32\x2c\x30\ +\x2e\x32\x35\x37\x35\x37\x32\x20\x32\x2e\x38\x39\x33\x39\x38\x39\ +\x2c\x31\x2e\x31\x37\x37\x35\x34\x36\x20\x34\x2e\x38\x36\x39\x36\ +\x32\x31\x2c\x32\x2e\x37\x38\x30\x30\x36\x35\x20\x31\x2e\x34\x32\ +\x38\x36\x35\x32\x2c\x31\x2e\x31\x35\x38\x38\x34\x20\x31\x2e\x37\ +\x35\x38\x39\x37\x34\x2c\x31\x2e\x34\x36\x39\x39\x39\x39\x20\x36\ +\x2e\x39\x32\x34\x37\x30\x31\x2c\x36\x2e\x35\x32\x32\x39\x38\x32\ +\x20\x6c\x20\x34\x2e\x35\x39\x38\x33\x32\x33\x2c\x34\x2e\x34\x39\ +\x37\x39\x36\x34\x20\x33\x2e\x39\x34\x36\x32\x39\x36\x2c\x2d\x33\ +\x2e\x38\x38\x35\x36\x35\x35\x20\x63\x20\x35\x2e\x36\x38\x37\x34\ +\x38\x38\x2c\x2d\x35\x2e\x36\x30\x30\x30\x39\x20\x37\x2e\x35\x30\ +\x35\x32\x36\x2c\x2d\x37\x2e\x31\x38\x38\x37\x36\x31\x20\x39\x2e\ +\x38\x33\x34\x37\x30\x31\x2c\x2d\x38\x2e\x35\x39\x35\x31\x39\x37\ +\x20\x30\x2e\x39\x39\x35\x37\x32\x37\x2c\x2d\x30\x2e\x36\x30\x31\ +\x31\x38\x35\x20\x32\x2e\x35\x36\x34\x37\x37\x35\x2c\x2d\x31\x2e\ +\x31\x39\x30\x33\x38\x32\x20\x33\x2e\x31\x36\x36\x37\x30\x39\x2c\ +\x2d\x31\x2e\x31\x38\x39\x31\x33\x38\x20\x31\x2e\x33\x32\x39\x35\ +\x38\x39\x2c\x30\x2e\x30\x30\x32\x37\x20\x32\x2e\x32\x35\x38\x34\ +\x37\x31\x2c\x30\x2e\x39\x32\x38\x31\x32\x36\x20\x32\x2e\x36\x33\ +\x36\x30\x30\x38\x2c\x32\x2e\x36\x32\x36\x30\x36\x33\x20\x30\x2e\ +\x31\x38\x34\x34\x32\x34\x2c\x30\x2e\x38\x32\x39\x34\x33\x34\x20\ +\x30\x2e\x31\x31\x30\x32\x39\x2c\x31\x2e\x32\x34\x30\x37\x37\x36\ +\x20\x2d\x30\x2e\x34\x32\x34\x38\x38\x36\x2c\x32\x2e\x33\x35\x37\ +\x34\x39\x39\x20\x2d\x31\x2e\x31\x30\x34\x36\x39\x2c\x32\x2e\x33\ +\x30\x35\x31\x20\x2d\x32\x2e\x38\x35\x31\x38\x31\x34\x2c\x34\x2e\ +\x33\x30\x32\x35\x34\x34\x20\x2d\x31\x30\x2e\x36\x36\x35\x38\x37\ +\x36\x2c\x31\x32\x2e\x31\x39\x34\x30\x33\x33\x20\x6c\x20\x2d\x32\ +\x2e\x34\x39\x37\x31\x31\x38\x2c\x32\x2e\x35\x32\x31\x38\x36\x31\ +\x20\x32\x2e\x35\x35\x36\x32\x37\x36\x2c\x32\x2e\x35\x38\x31\x31\ +\x37\x35\x20\x63\x20\x37\x2e\x34\x37\x38\x39\x35\x2c\x37\x2e\x35\ +\x35\x31\x37\x39\x39\x20\x39\x2e\x31\x38\x30\x34\x34\x37\x2c\x39\ +\x2e\x35\x33\x33\x37\x30\x39\x20\x31\x30\x2e\x34\x34\x32\x30\x32\ +\x36\x2c\x31\x32\x2e\x31\x36\x32\x39\x31\x32\x20\x31\x2e\x31\x31\ +\x38\x30\x38\x33\x2c\x32\x2e\x33\x33\x30\x31\x34\x37\x20\x30\x2e\ +\x38\x32\x34\x33\x34\x33\x2c\x33\x2e\x37\x33\x32\x36\x33\x31\x20\ +\x2d\x30\x2e\x39\x34\x38\x33\x39\x35\x2c\x34\x2e\x35\x32\x38\x31\ +\x39\x20\x2d\x31\x2e\x34\x36\x38\x33\x31\x37\x2c\x30\x2e\x36\x35\ +\x38\x39\x34\x31\x20\x2d\x32\x2e\x34\x36\x33\x37\x33\x2c\x30\x2e\ +\x35\x31\x37\x39\x35\x38\x20\x2d\x34\x2e\x32\x34\x36\x37\x39\x31\ +\x2c\x2d\x30\x2e\x36\x30\x31\x34\x38\x32\x20\x2d\x32\x2e\x32\x33\ +\x30\x38\x34\x33\x2c\x2d\x31\x2e\x34\x30\x30\x35\x36\x36\x20\x2d\ +\x33\x2e\x31\x39\x35\x34\x35\x2c\x2d\x32\x2e\x32\x35\x33\x34\x37\ +\x20\x2d\x39\x2e\x32\x30\x30\x36\x38\x33\x2c\x2d\x38\x2e\x31\x33\ +\x35\x32\x32\x34\x20\x6c\x20\x2d\x34\x2e\x35\x39\x39\x37\x35\x33\ +\x2c\x2d\x34\x2e\x35\x30\x35\x31\x37\x33\x20\x2d\x34\x2e\x35\x33\ +\x38\x32\x34\x33\x2c\x34\x2e\x34\x33\x37\x39\x32\x31\x20\x63\x20\ +\x2d\x35\x2e\x31\x32\x35\x35\x30\x39\x2c\x35\x2e\x30\x31\x32\x32\ +\x30\x35\x20\x2d\x35\x2e\x34\x33\x36\x38\x35\x34\x2c\x35\x2e\x33\ +\x30\x35\x31\x37\x31\x20\x2d\x36\x2e\x38\x39\x36\x37\x33\x32\x2c\ +\x36\x2e\x34\x38\x39\x36\x31\x39\x20\x2d\x32\x2e\x37\x39\x33\x39\ +\x38\x2c\x32\x2e\x32\x36\x36\x38\x35\x32\x20\x2d\x34\x2e\x35\x36\ +\x39\x38\x35\x35\x2c\x33\x2e\x30\x33\x37\x36\x36\x37\x20\x2d\x35\ +\x2e\x39\x34\x38\x37\x37\x39\x2c\x32\x2e\x35\x38\x32\x30\x35\x36\ +\x20\x7a\x22\x0a\x20\x20\x20\x20\x20\x69\x64\x3d\x22\x70\x61\x74\ +\x68\x31\x36\x22\x0a\x20\x20\x20\x20\x20\x74\x72\x61\x6e\x73\x66\ +\x6f\x72\x6d\x3d\x22\x73\x63\x61\x6c\x65\x28\x30\x2e\x37\x35\x29\ +\x22\x20\x2f\x3e\x0a\x3c\x2f\x73\x76\x67\x3e\x0a\ +\x00\x00\x09\xc1\ +\x3c\ +\x3f\x78\x6d\x6c\x20\x76\x65\x72\x73\x69\x6f\x6e\x3d\x22\x31\x2e\ +\x30\x22\x20\x65\x6e\x63\x6f\x64\x69\x6e\x67\x3d\x22\x69\x73\x6f\ +\x2d\x38\x38\x35\x39\x2d\x31\x22\x3f\x3e\x0d\x0a\x3c\x21\x2d\x2d\ +\x20\x47\x65\x6e\x65\x72\x61\x74\x6f\x72\x3a\x20\x41\x64\x6f\x62\ +\x65\x20\x49\x6c\x6c\x75\x73\x74\x72\x61\x74\x6f\x72\x20\x31\x39\ +\x2e\x30\x2e\x30\x2c\x20\x53\x56\x47\x20\x45\x78\x70\x6f\x72\x74\ +\x20\x50\x6c\x75\x67\x2d\x49\x6e\x20\x2e\x20\x53\x56\x47\x20\x56\ +\x65\x72\x73\x69\x6f\x6e\x3a\x20\x36\x2e\x30\x30\x20\x42\x75\x69\ +\x6c\x64\x20\x30\x29\x20\x20\x2d\x2d\x3e\x0d\x0a\x3c\x73\x76\x67\ +\x20\x76\x65\x72\x73\x69\x6f\x6e\x3d\x22\x31\x2e\x31\x22\x20\x69\ +\x64\x3d\x22\x4c\x61\x79\x65\x72\x5f\x31\x22\x20\x78\x6d\x6c\x6e\ +\x73\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\x2e\x77\x33\ +\x2e\x6f\x72\x67\x2f\x32\x30\x30\x30\x2f\x73\x76\x67\x22\x20\x78\ +\x6d\x6c\x6e\x73\x3a\x78\x6c\x69\x6e\x6b\x3d\x22\x68\x74\x74\x70\ +\x3a\x2f\x2f\x77\x77\x77\x2e\x77\x33\x2e\x6f\x72\x67\x2f\x31\x39\ +\x39\x39\x2f\x78\x6c\x69\x6e\x6b\x22\x20\x78\x3d\x22\x30\x70\x78\ +\x22\x20\x79\x3d\x22\x30\x70\x78\x22\x0d\x0a\x09\x20\x76\x69\x65\ +\x77\x42\x6f\x78\x3d\x22\x30\x20\x30\x20\x35\x31\x32\x20\x35\x31\ +\x32\x22\x20\x73\x74\x79\x6c\x65\x3d\x22\x65\x6e\x61\x62\x6c\x65\ +\x2d\x62\x61\x63\x6b\x67\x72\x6f\x75\x6e\x64\x3a\x6e\x65\x77\x20\ +\x30\x20\x30\x20\x35\x31\x32\x20\x35\x31\x32\x3b\x22\x20\x78\x6d\ +\x6c\x3a\x73\x70\x61\x63\x65\x3d\x22\x70\x72\x65\x73\x65\x72\x76\ +\x65\x22\x3e\x0d\x0a\x3c\x67\x3e\x0d\x0a\x09\x3c\x67\x3e\x0d\x0a\ +\x09\x09\x3c\x67\x3e\x0d\x0a\x09\x09\x09\x3c\x70\x61\x74\x68\x20\ +\x64\x3d\x22\x4d\x34\x37\x32\x2e\x31\x37\x38\x2c\x33\x34\x2e\x36\ +\x32\x48\x33\x39\x2e\x38\x32\x32\x43\x31\x37\x2e\x38\x36\x34\x2c\ +\x33\x34\x2e\x36\x32\x2c\x30\x2c\x35\x32\x2e\x34\x38\x34\x2c\x30\ +\x2c\x37\x34\x2e\x34\x34\x32\x63\x30\x2c\x31\x37\x2e\x39\x35\x35\ +\x2c\x30\x2c\x33\x34\x35\x2e\x32\x33\x34\x2c\x30\x2c\x33\x36\x33\ +\x2e\x31\x31\x36\x0d\x0a\x09\x09\x09\x09\x63\x30\x2c\x32\x31\x2e\ +\x39\x35\x38\x2c\x31\x37\x2e\x38\x36\x34\x2c\x33\x39\x2e\x38\x32\ +\x32\x2c\x33\x39\x2e\x38\x32\x32\x2c\x33\x39\x2e\x38\x32\x32\x68\ +\x34\x33\x32\x2e\x33\x35\x36\x63\x32\x31\x2e\x39\x35\x38\x2c\x30\ +\x2c\x33\x39\x2e\x38\x32\x32\x2d\x31\x37\x2e\x38\x36\x34\x2c\x33\ +\x39\x2e\x38\x32\x32\x2d\x33\x39\x2e\x38\x32\x32\x63\x30\x2d\x31\ +\x37\x2e\x38\x39\x2c\x30\x2d\x33\x34\x35\x2e\x31\x36\x37\x2c\x30\ +\x2d\x33\x36\x33\x2e\x31\x31\x36\x0d\x0a\x09\x09\x09\x09\x43\x35\ +\x31\x32\x2c\x35\x32\x2e\x34\x38\x34\x2c\x34\x39\x34\x2e\x31\x33\ +\x36\x2c\x33\x34\x2e\x36\x32\x2c\x34\x37\x32\x2e\x31\x37\x38\x2c\ +\x33\x34\x2e\x36\x32\x7a\x20\x4d\x34\x37\x37\x2e\x38\x36\x37\x2c\ +\x34\x33\x37\x2e\x35\x35\x37\x63\x30\x2c\x33\x2e\x31\x33\x37\x2d\ +\x32\x2e\x35\x35\x32\x2c\x35\x2e\x36\x38\x39\x2d\x35\x2e\x36\x38\ +\x39\x2c\x35\x2e\x36\x38\x39\x48\x33\x39\x2e\x38\x32\x32\x0d\x0a\ +\x09\x09\x09\x09\x63\x2d\x33\x2e\x31\x33\x37\x2c\x30\x2d\x35\x2e\ +\x36\x38\x39\x2d\x32\x2e\x35\x35\x32\x2d\x35\x2e\x36\x38\x39\x2d\ +\x35\x2e\x36\x38\x39\x56\x31\x35\x33\x2e\x38\x33\x38\x68\x34\x34\ +\x33\x2e\x37\x33\x33\x56\x34\x33\x37\x2e\x35\x35\x37\x7a\x20\x4d\ +\x34\x37\x37\x2e\x38\x36\x37\x2c\x31\x31\x39\x2e\x37\x30\x35\x48\ +\x33\x34\x2e\x31\x33\x33\x56\x37\x34\x2e\x34\x34\x32\x63\x30\x2d\ +\x33\x2e\x31\x33\x37\x2c\x32\x2e\x35\x35\x32\x2d\x35\x2e\x36\x38\ +\x39\x2c\x35\x2e\x36\x38\x39\x2d\x35\x2e\x36\x38\x39\x0d\x0a\x09\ +\x09\x09\x09\x68\x34\x33\x32\x2e\x33\x35\x36\x63\x33\x2e\x31\x33\ +\x37\x2c\x30\x2c\x35\x2e\x36\x38\x39\x2c\x32\x2e\x35\x35\x32\x2c\ +\x35\x2e\x36\x38\x39\x2c\x35\x2e\x36\x38\x39\x56\x31\x31\x39\x2e\ +\x37\x30\x35\x7a\x22\x2f\x3e\x0d\x0a\x09\x09\x09\x3c\x63\x69\x72\ +\x63\x6c\x65\x20\x63\x78\x3d\x22\x37\x31\x2e\x38\x30\x35\x22\x20\ +\x63\x79\x3d\x22\x39\x34\x2e\x32\x33\x22\x20\x72\x3d\x22\x31\x32\ +\x2e\x38\x33\x36\x22\x2f\x3e\x0d\x0a\x09\x09\x09\x3c\x63\x69\x72\ +\x63\x6c\x65\x20\x63\x78\x3d\x22\x31\x31\x32\x2e\x39\x35\x39\x22\ +\x20\x63\x79\x3d\x22\x39\x34\x2e\x32\x33\x22\x20\x72\x3d\x22\x31\ +\x32\x2e\x38\x33\x36\x22\x2f\x3e\x0d\x0a\x09\x09\x09\x3c\x63\x69\ +\x72\x63\x6c\x65\x20\x63\x78\x3d\x22\x31\x35\x34\x2e\x31\x31\x32\ +\x22\x20\x63\x79\x3d\x22\x39\x34\x2e\x32\x33\x22\x20\x72\x3d\x22\ +\x31\x32\x2e\x38\x33\x36\x22\x2f\x3e\x0d\x0a\x09\x09\x09\x3c\x70\ +\x61\x74\x68\x20\x64\x3d\x22\x4d\x38\x31\x2e\x35\x38\x31\x2c\x33\ +\x33\x34\x2e\x34\x34\x32\x63\x34\x2e\x34\x34\x2c\x31\x33\x2e\x37\ +\x30\x31\x2c\x32\x32\x2e\x38\x37\x32\x2c\x31\x36\x2e\x31\x30\x31\ +\x2c\x33\x30\x2e\x36\x33\x36\x2c\x33\x2e\x39\x6c\x31\x30\x2e\x34\ +\x35\x34\x2d\x31\x36\x2e\x34\x33\x32\x6c\x31\x30\x2e\x34\x35\x34\ +\x2c\x31\x36\x2e\x34\x33\x32\x0d\x0a\x09\x09\x09\x09\x63\x37\x2e\ +\x37\x34\x34\x2c\x31\x32\x2e\x31\x36\x39\x2c\x32\x36\x2e\x31\x39\ +\x35\x2c\x39\x2e\x38\x30\x31\x2c\x33\x30\x2e\x36\x33\x36\x2d\x33\ +\x2e\x39\x6c\x31\x39\x2e\x38\x35\x37\x2d\x36\x31\x2e\x32\x37\x38\ +\x63\x32\x2e\x39\x30\x36\x2d\x38\x2e\x39\x36\x37\x2d\x32\x2e\x30\ +\x30\x38\x2d\x31\x38\x2e\x35\x39\x31\x2d\x31\x30\x2e\x39\x37\x35\ +\x2d\x32\x31\x2e\x34\x39\x37\x0d\x0a\x09\x09\x09\x09\x63\x2d\x38\ +\x2e\x39\x37\x31\x2d\x32\x2e\x39\x30\x36\x2d\x31\x38\x2e\x35\x39\ +\x31\x2c\x32\x2e\x30\x30\x39\x2d\x32\x31\x2e\x34\x39\x37\x2c\x31\ +\x30\x2e\x39\x37\x35\x6c\x2d\x38\x2e\x36\x38\x31\x2c\x32\x36\x2e\ +\x37\x39\x31\x6c\x2d\x35\x2e\x33\x39\x33\x2d\x38\x2e\x34\x37\x38\ +\x63\x2d\x36\x2e\x36\x39\x35\x2d\x31\x30\x2e\x35\x32\x2d\x32\x32\ +\x2e\x30\x39\x34\x2d\x31\x30\x2e\x35\x33\x38\x2d\x32\x38\x2e\x38\ +\x30\x31\x2c\x30\x6c\x2d\x35\x2e\x33\x39\x32\x2c\x38\x2e\x34\x37\ +\x38\x0d\x0a\x09\x09\x09\x09\x6c\x2d\x38\x2e\x36\x38\x31\x2d\x32\ +\x36\x2e\x37\x39\x63\x2d\x32\x2e\x39\x30\x36\x2d\x38\x2e\x39\x36\ +\x36\x2d\x31\x32\x2e\x35\x32\x36\x2d\x31\x33\x2e\x38\x38\x32\x2d\ +\x32\x31\x2e\x34\x39\x37\x2d\x31\x30\x2e\x39\x37\x35\x63\x2d\x38\ +\x2e\x39\x36\x37\x2c\x32\x2e\x39\x30\x36\x2d\x31\x33\x2e\x38\x38\ +\x2c\x31\x32\x2e\x35\x32\x39\x2d\x31\x30\x2e\x39\x37\x35\x2c\x32\ +\x31\x2e\x34\x39\x36\x4c\x38\x31\x2e\x35\x38\x31\x2c\x33\x33\x34\ +\x2e\x34\x34\x32\x7a\x22\x2f\x3e\x0d\x0a\x09\x09\x09\x3c\x70\x61\ +\x74\x68\x20\x64\x3d\x22\x4d\x32\x31\x34\x2e\x39\x31\x31\x2c\x33\ +\x33\x34\x2e\x34\x34\x32\x63\x34\x2e\x34\x34\x36\x2c\x31\x33\x2e\ +\x37\x32\x2c\x32\x32\x2e\x39\x30\x35\x2c\x31\x36\x2e\x30\x35\x32\ +\x2c\x33\x30\x2e\x36\x33\x36\x2c\x33\x2e\x39\x4c\x32\x35\x36\x2c\ +\x33\x32\x31\x2e\x39\x31\x31\x6c\x31\x30\x2e\x34\x35\x34\x2c\x31\ +\x36\x2e\x34\x33\x32\x63\x37\x2e\x37\x36\x32\x2c\x31\x32\x2e\x32\ +\x2c\x32\x36\x2e\x31\x39\x36\x2c\x39\x2e\x38\x2c\x33\x30\x2e\x36\ +\x33\x36\x2d\x33\x2e\x39\x0d\x0a\x09\x09\x09\x09\x6c\x31\x39\x2e\ +\x38\x35\x35\x2d\x36\x31\x2e\x32\x37\x38\x63\x32\x2e\x39\x30\x36\ +\x2d\x38\x2e\x39\x36\x37\x2d\x32\x2e\x30\x30\x38\x2d\x31\x38\x2e\ +\x35\x39\x2d\x31\x30\x2e\x39\x37\x35\x2d\x32\x31\x2e\x34\x39\x36\ +\x63\x2d\x38\x2e\x39\x37\x34\x2d\x32\x2e\x39\x30\x37\x2d\x31\x38\ +\x2e\x35\x39\x31\x2c\x32\x2e\x30\x30\x38\x2d\x32\x31\x2e\x34\x39\ +\x37\x2c\x31\x30\x2e\x39\x37\x35\x6c\x2d\x38\x2e\x36\x38\x31\x2c\ +\x32\x36\x2e\x37\x39\x6c\x2d\x35\x2e\x33\x39\x32\x2d\x38\x2e\x34\ +\x37\x38\x0d\x0a\x09\x09\x09\x09\x63\x2d\x36\x2e\x36\x39\x35\x2d\ +\x31\x30\x2e\x35\x32\x2d\x32\x32\x2e\x30\x39\x34\x2d\x31\x30\x2e\ +\x35\x33\x38\x2d\x32\x38\x2e\x38\x30\x31\x2c\x30\x6c\x2d\x35\x2e\ +\x33\x39\x32\x2c\x38\x2e\x34\x37\x38\x6c\x2d\x38\x2e\x36\x38\x31\ +\x2d\x32\x36\x2e\x37\x39\x63\x2d\x32\x2e\x39\x30\x35\x2d\x38\x2e\ +\x39\x36\x36\x2d\x31\x32\x2e\x35\x32\x37\x2d\x31\x33\x2e\x38\x38\ +\x32\x2d\x32\x31\x2e\x34\x39\x36\x2d\x31\x30\x2e\x39\x37\x35\x0d\ +\x0a\x09\x09\x09\x09\x63\x2d\x38\x2e\x39\x36\x37\x2c\x32\x2e\x39\ +\x30\x36\x2d\x31\x33\x2e\x38\x38\x2c\x31\x32\x2e\x35\x32\x39\x2d\ +\x31\x30\x2e\x39\x37\x35\x2c\x32\x31\x2e\x34\x39\x36\x4c\x32\x31\ +\x34\x2e\x39\x31\x31\x2c\x33\x33\x34\x2e\x34\x34\x32\x7a\x22\x2f\ +\x3e\x0d\x0a\x09\x09\x09\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x4d\ +\x33\x34\x38\x2e\x32\x34\x31\x2c\x33\x33\x34\x2e\x34\x34\x32\x63\ +\x34\x2e\x34\x34\x36\x2c\x31\x33\x2e\x37\x32\x2c\x32\x32\x2e\x39\ +\x30\x35\x2c\x31\x36\x2e\x30\x35\x31\x2c\x33\x30\x2e\x36\x33\x36\ +\x2c\x33\x2e\x39\x6c\x31\x30\x2e\x34\x35\x34\x2d\x31\x36\x2e\x34\ +\x33\x32\x6c\x31\x30\x2e\x34\x35\x34\x2c\x31\x36\x2e\x34\x33\x32\ +\x63\x37\x2e\x37\x36\x32\x2c\x31\x32\x2e\x32\x2c\x32\x36\x2e\x31\ +\x39\x35\x2c\x39\x2e\x38\x2c\x33\x30\x2e\x36\x33\x36\x2d\x33\x2e\ +\x39\x0d\x0a\x09\x09\x09\x09\x6c\x31\x39\x2e\x38\x35\x35\x2d\x36\ +\x31\x2e\x32\x37\x38\x63\x32\x2e\x39\x30\x36\x2d\x38\x2e\x39\x36\ +\x37\x2d\x32\x2e\x30\x30\x38\x2d\x31\x38\x2e\x35\x39\x2d\x31\x30\ +\x2e\x39\x37\x35\x2d\x32\x31\x2e\x34\x39\x36\x63\x2d\x38\x2e\x39\ +\x37\x33\x2d\x32\x2e\x39\x30\x37\x2d\x31\x38\x2e\x35\x39\x31\x2c\ +\x32\x2e\x30\x30\x38\x2d\x32\x31\x2e\x34\x39\x36\x2c\x31\x30\x2e\ +\x39\x37\x35\x6c\x2d\x38\x2e\x36\x38\x31\x2c\x32\x36\x2e\x37\x39\ +\x6c\x2d\x35\x2e\x33\x39\x32\x2d\x38\x2e\x34\x37\x38\x0d\x0a\x09\ +\x09\x09\x09\x63\x2d\x36\x2e\x36\x39\x35\x2d\x31\x30\x2e\x35\x32\ +\x2d\x32\x32\x2e\x30\x39\x35\x2d\x31\x30\x2e\x35\x33\x38\x2d\x32\ +\x38\x2e\x38\x30\x31\x2c\x30\x6c\x2d\x35\x2e\x33\x39\x33\x2c\x38\ +\x2e\x34\x37\x38\x6c\x2d\x38\x2e\x36\x38\x31\x2d\x32\x36\x2e\x37\ +\x39\x31\x63\x2d\x32\x2e\x39\x30\x36\x2d\x38\x2e\x39\x36\x36\x2d\ +\x31\x32\x2e\x35\x33\x31\x2d\x31\x33\x2e\x38\x38\x31\x2d\x32\x31\ +\x2e\x34\x39\x37\x2d\x31\x30\x2e\x39\x37\x35\x0d\x0a\x09\x09\x09\ +\x09\x63\x2d\x38\x2e\x39\x36\x37\x2c\x32\x2e\x39\x30\x36\x2d\x31\ +\x33\x2e\x38\x38\x2c\x31\x32\x2e\x35\x33\x2d\x31\x30\x2e\x39\x37\ +\x34\x2c\x32\x31\x2e\x34\x39\x37\x4c\x33\x34\x38\x2e\x32\x34\x31\ +\x2c\x33\x33\x34\x2e\x34\x34\x32\x7a\x22\x2f\x3e\x0d\x0a\x09\x09\ +\x3c\x2f\x67\x3e\x0d\x0a\x09\x3c\x2f\x67\x3e\x0d\x0a\x3c\x2f\x67\ +\x3e\x0d\x0a\x3c\x67\x3e\x0d\x0a\x3c\x2f\x67\x3e\x0d\x0a\x3c\x67\ +\x3e\x0d\x0a\x3c\x2f\x67\x3e\x0d\x0a\x3c\x67\x3e\x0d\x0a\x3c\x2f\ +\x67\x3e\x0d\x0a\x3c\x67\x3e\x0d\x0a\x3c\x2f\x67\x3e\x0d\x0a\x3c\ +\x67\x3e\x0d\x0a\x3c\x2f\x67\x3e\x0d\x0a\x3c\x67\x3e\x0d\x0a\x3c\ +\x2f\x67\x3e\x0d\x0a\x3c\x67\x3e\x0d\x0a\x3c\x2f\x67\x3e\x0d\x0a\ +\x3c\x67\x3e\x0d\x0a\x3c\x2f\x67\x3e\x0d\x0a\x3c\x67\x3e\x0d\x0a\ +\x3c\x2f\x67\x3e\x0d\x0a\x3c\x67\x3e\x0d\x0a\x3c\x2f\x67\x3e\x0d\ +\x0a\x3c\x67\x3e\x0d\x0a\x3c\x2f\x67\x3e\x0d\x0a\x3c\x67\x3e\x0d\ +\x0a\x3c\x2f\x67\x3e\x0d\x0a\x3c\x67\x3e\x0d\x0a\x3c\x2f\x67\x3e\ +\x0d\x0a\x3c\x67\x3e\x0d\x0a\x3c\x2f\x67\x3e\x0d\x0a\x3c\x67\x3e\ +\x0d\x0a\x3c\x2f\x67\x3e\x0d\x0a\x3c\x2f\x73\x76\x67\x3e\x0d\x0a\ +\ +" + +qt_resource_name = b"\ +\x00\x08\ +\x00\x2b\x61\x2c\ +\x00\x66\ +\x00\x69\x00\x6c\x00\x65\x00\x5f\x00\x75\x00\x72\x00\x6c\ +\x00\x04\ +\x00\x07\xa7\xe3\ +\x00\x74\ +\x00\x61\x00\x67\x00\x73\ +\x00\x0c\ +\x06\x07\x35\x82\ +\x00\x64\ +\x00\x65\x00\x6c\x00\x65\x00\x74\x00\x65\x00\x5f\x00\x68\x00\x6f\x00\x76\x00\x65\x00\x72\ +\x00\x06\ +\x06\xac\x2c\xa5\ +\x00\x64\ +\x00\x65\x00\x6c\x00\x65\x00\x74\x00\x65\ +\x00\x08\ +\x05\xac\x9a\xc4\ +\x00\x69\ +\x00\x6e\x00\x74\x00\x65\x00\x72\x00\x6e\x00\x65\x00\x74\ +" + +qt_resource_struct_v1 = b"\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x02\x00\x00\x00\x01\ +\x00\x00\x00\x16\x00\x02\x00\x00\x00\x02\x00\x00\x00\x04\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x03\ +\x00\x00\x00\x54\x00\x00\x00\x00\x00\x01\x00\x00\x1a\xe7\ +\x00\x00\x00\x24\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ +\x00\x00\x00\x42\x00\x00\x00\x00\x00\x01\x00\x00\x0d\x96\ +" + +qt_resource_struct_v2 = b"\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x02\x00\x00\x00\x01\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x16\x00\x02\x00\x00\x00\x02\x00\x00\x00\x04\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x03\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x54\x00\x00\x00\x00\x00\x01\x00\x00\x1a\xe7\ +\x00\x00\x01\x76\xde\x95\xb2\x06\ +\x00\x00\x00\x24\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ +\x00\x00\x01\x76\xd8\xe5\x6d\x99\ +\x00\x00\x00\x42\x00\x00\x00\x00\x00\x01\x00\x00\x0d\x96\ +\x00\x00\x01\x76\xd8\xe2\x94\x13\ +" + +qt_version = [int(v) for v in QtCore.qVersion().split('.')] +if qt_version < [5, 8, 0]: + rcc_version = 1 + qt_resource_struct = qt_resource_struct_v1 +else: + rcc_version = 2 + qt_resource_struct = qt_resource_struct_v2 + +def qInitResources(): + QtCore.qRegisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) + +def qCleanupResources(): + QtCore.qUnregisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) + +qInitResources() \ No newline at end of file diff --git a/athnos/window/source.py b/athnos/window/source.py new file mode 100755 index 0000000..bf5f341 --- /dev/null +++ b/athnos/window/source.py @@ -0,0 +1,267 @@ +from . import _DatabaseWindow +from .. import logger +from ..database import Type +from .add import AddSource +from typing import Union +from PyQt5 import QtCore, QtGui, QtWidgets, QtSql + + +class EditSource(AddSource): + """Manipulates the `add.AddSource` class to create a edit source class""" + + def __init__(self, window): + super().__init__(window) + + self.source = None + + def after_setup_ui(self) -> None: + super().after_setup_ui() + + self.new_button.hide() + + def on_save(self) -> None: + affected_clips = len(self.source.get_all_clips()) + # if one clip or more is affected + if affected_clips > 0: + message = QtWidgets.QMessageBox() + message.setWindowTitle('Save?') + message.setText(f'Are you sure to save the edited source? This will affect {affected_clips} {"clips" if affected_clips > 1 else "clip"}') + affected = message.addButton('Show affected clips', QtWidgets.QMessageBox.ActionRole) + message.addButton(QtWidgets.QMessageBox.Yes) + message.addButton(QtWidgets.QMessageBox.No) + + # shows all affected clips + def on_affected() -> None: + ShowSource(QtWidgets.QDialog(self.window, QtCore.Qt.WindowSystemMenuHint)).show(self.source.id) + + affected.disconnect() + affected.clicked.connect(on_affected) + + # shows the message + result = message.exec() + if result == QtWidgets.QMessageBox.No: + return + elif result == QtWidgets.QMessageBox.Yes: + type = Type.Source.from_name(self.type_box.currentText()).value + # edits the source + self.database.Source.edit(self.source.id, path=self.file_or_url_edit.text(), type=type, name=self.name_edit.text(), + season=self.season_edit.text(), episode=self.episode_edit.text(), description=self.description_edit.toPlainText()) + + logger.debug(f'Edited source {self.source.id} - path: `{self.file_or_url_edit.text()}`, type: `{type}`, ' + f'season: `{self.season_edit.text()}`, episode: {self.episode_edit.text()}, description: {self.description_edit.toPlainText()}') + + self.window.close() + + def show(self, id: int) -> None: + """ + Shows the source edit + + Args: + id: The source id to show the source edit + """ + # gets the source from the id + self.source = self.database.Source.get(id) + + # sets the texts + self.file_or_url_edit.setText(self.source.path) + if self.source.type == Type.Source.AUDIO: + self.type_box.setCurrentIndex(0) + elif self.source.type == Type.Source.MOVIE: + self.type_box.setCurrentIndex(1) + elif self.source.type == Type.Source.SERIES: + self.type_box.setCurrentIndex(2) + else: + self.type_box.setCurrentIndex(3) + self.name_edit.setText(self.source.name) + self.season_edit.setText(self.source.season) + self.episode_edit.setText(self.source.episode) + self.description_edit.setText(self.source.description) + super().show() + + +class ShowSource(_DatabaseWindow): + """Class to show all sources""" + + def __init__(self, window): + self._all_ids = [] + self._source_id = None + + super().__init__(window) + + def setupUi(self, Form): + Form.setObjectName("Form") + Form.resize(814, 447) + self.horizontalLayout = QtWidgets.QHBoxLayout(Form) + self.horizontalLayout.setObjectName("horizontalLayout") + self.main_splitter = QtWidgets.QSplitter(Form) + self.main_splitter.setOrientation(QtCore.Qt.Horizontal) + self.main_splitter.setObjectName("main_splitter") + self.source_frame = QtWidgets.QFrame(self.main_splitter) + self.source_frame.setFrameShape(QtWidgets.QFrame.StyledPanel) + self.source_frame.setFrameShadow(QtWidgets.QFrame.Raised) + self.source_frame.setObjectName("source_frame") + self.verticalLayout = QtWidgets.QVBoxLayout(self.source_frame) + self.verticalLayout.setObjectName("verticalLayout") + self.source_label = QtWidgets.QLabel(self.source_frame) + self.source_label.setAlignment(QtCore.Qt.AlignCenter) + self.source_label.setObjectName("source_label") + self.verticalLayout.addWidget(self.source_label) + self.search_edit = QtWidgets.QLineEdit(self.source_frame) + self.search_edit.setObjectName("search_edit") + self.verticalLayout.addWidget(self.search_edit) + self.source_table_view = QtWidgets.QTableView(self.source_frame) + self.source_table_view.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + self.source_table_view.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + self.source_table_view.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + self.source_table_view.setObjectName("source_table_view") + self.verticalLayout.addWidget(self.source_table_view) + self.select_source_button = QtWidgets.QPushButton(self.source_frame) + self.select_source_button.setAutoDefault(False) + self.select_source_button.setDefault(False) + self.select_source_button.setFlat(False) + self.select_source_button.setObjectName("select_source_button") + self.verticalLayout.addWidget(self.select_source_button) + self.all_tags_frame = QtWidgets.QFrame(self.main_splitter) + self.all_tags_frame.setFrameShape(QtWidgets.QFrame.StyledPanel) + self.all_tags_frame.setFrameShadow(QtWidgets.QFrame.Raised) + self.all_tags_frame.setObjectName("all_tags_frame") + self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.all_tags_frame) + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.all_tags_label = QtWidgets.QLabel(self.all_tags_frame) + self.all_tags_label.setFrameShadow(QtWidgets.QFrame.Raised) + self.all_tags_label.setAlignment(QtCore.Qt.AlignCenter) + self.all_tags_label.setObjectName("all_tags_label") + self.verticalLayout_2.addWidget(self.all_tags_label) + self.all_tags_table_view = QtWidgets.QTableView(self.all_tags_frame) + self.all_tags_table_view.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + self.all_tags_table_view.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + self.all_tags_table_view.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + self.all_tags_table_view.setObjectName("all_tags_table_view") + self.verticalLayout_2.addWidget(self.all_tags_table_view) + self.horizontalLayout.addWidget(self.main_splitter) + + self.retranslateUi(Form) + QtCore.QMetaObject.connectSlotsByName(Form) + + def retranslateUi(self, Form): + _translate = QtCore.QCoreApplication.translate + Form.setWindowTitle(_translate("Form", "Form")) + self.source_label.setText(_translate("Form", "Source")) + self.search_edit.setPlaceholderText(_translate("Form", "Search for source...")) + self.select_source_button.setText(_translate("Form", "Select source")) + self.all_tags_label.setText(_translate("Form", "All tags, using this source")) + + def show(self, id: int = None) -> None: + """ + Shows the window + + Args: + id: If given, only the source with this is will be shown + """ + self.set_query(self.database.Source.get(id).query) + + # hides the 'Select' button + self.select_source_button.hide() + + if id: + # hides all stuff, which can be used to see other sources than the given + self.search_edit.hide() + + # select the entry + index = self.source_table_view.model().index(0, 0) + self.source_table_view.setCurrentIndex(index) + self.on_item_clicked(index) + super().show() + + def set_query(self, query: QtSql.QSqlQuery) -> None: + """ + Sets the source query + + Args: + query: The source query + """ + # the sql model + model = QtSql.QSqlQueryModel() + model.setQuery(query) + + # sets the model to the source table view + self.source_table_view.setModel(model) + + for i in range(model.columnCount()): + header = str(model.headerData(i, QtCore.Qt.Horizontal)).lower() + # hides columns with these headers + if header in ['id', 'name', 'season', 'episode']: + if header == 'id': + for row in range(model.rowCount()): + self._all_ids.append(int(model.index(row, i).data())) + self.source_table_view.hideColumn(i) + else: + model.setHeaderData(i, QtCore.Qt.Horizontal, header[0].upper() + header[1:]) + + # connect all button click events + self.select_source_button.clicked.connect(self.on_select_source) + self.source_table_view.clicked.connect(self.on_item_clicked) + self.source_table_view.doubleClicked.connect(self.on_select_source) + + def on_item_clicked(self, index: QtCore.QModelIndex): + """ + Gets called if a source item is clicked + + Args: + index: The item which was clicked + """ + + row = index.row() + # the source id of the item + self._source_id = self._all_ids[row - 1] + + # the model for all tags which are using the selected source + model = QtSql.QSqlTableModel() + model.setQuery(self.database.Clip.get_all_by(source_id=self._source_id).query) + model.setEditStrategy(QtSql.QSqlTableModel.OnManualSubmit) + + # sets the model + self.all_tags_table_view.setModel(model) + self.all_tags_table_view.horizontalHeader().resizeSections(QtWidgets.QHeaderView.Stretch) + self.all_tags_table_view.horizontalHeader().setStretchLastSection(True) + + # this sets the header (first letter uppercase, remove id, etc.) + for i in range(model.columnCount()): + header = str(model.headerData(i, QtCore.Qt.Horizontal)).lower() + # » hide the header with the names id or source_id + if header in ['id', 'source_id']: + if header == 'id': + for row in range(model.rowCount()): + self._all_ids.append(int(model.index(row, i).data())) + self.all_tags_table_view.hideColumn(i) + continue + elif header == 'type': + for row in range(model.rowCount()): + index = model.index(row, i) + type_name = Type.Clip(model.data(index)).name.lower() + model.setData(index, type_name[0].upper() + type_name[1:]) + model.submit() + model.setData(index, QtGui.QColor(100, 100, 100, 1), QtCore.Qt.BackgroundColorRole) + model.submit() + # » first letter of the header uppercase + model.setHeaderData(i, QtCore.Qt.Horizontal, header[0].upper() + header[1:]) + + def on_select_source(self): + """Gets called if the 'Select source' button is pressed""" + if not self._source_id: + proceed = QtWidgets.QMessageBox.question(self.window, 'No source is chosen', 'No source is chosen. Do you want to proceed?', + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) + if proceed == QtWidgets.QMessageBox.No: + return + self.window.close() + + def select(self) -> Union[int, None]: + """ + Shows the window and enable the user to select a source + + Returns: + The selected source id + """ + self.set_query(self.database.Source.get_all().query) + self.window.exec() + return self._source_id diff --git a/athnos/window/style.py b/athnos/window/style.py new file mode 100755 index 0000000..fe9771b --- /dev/null +++ b/athnos/window/style.py @@ -0,0 +1,891 @@ +# -*- coding: utf-8 -*- + +# Resource object code +# +# Created by: The Resource Compiler for PyQt5 (Qt v5.14.2) +# +# WARNING! All changes made in this file will be lost! + +from PyQt5 import QtCore + +qt_resource_data = b"\ +\x00\x00\x09\xc1\ +\x3c\ +\x3f\x78\x6d\x6c\x20\x76\x65\x72\x73\x69\x6f\x6e\x3d\x22\x31\x2e\ +\x30\x22\x20\x65\x6e\x63\x6f\x64\x69\x6e\x67\x3d\x22\x69\x73\x6f\ +\x2d\x38\x38\x35\x39\x2d\x31\x22\x3f\x3e\x0d\x0a\x3c\x21\x2d\x2d\ +\x20\x47\x65\x6e\x65\x72\x61\x74\x6f\x72\x3a\x20\x41\x64\x6f\x62\ +\x65\x20\x49\x6c\x6c\x75\x73\x74\x72\x61\x74\x6f\x72\x20\x31\x39\ +\x2e\x30\x2e\x30\x2c\x20\x53\x56\x47\x20\x45\x78\x70\x6f\x72\x74\ +\x20\x50\x6c\x75\x67\x2d\x49\x6e\x20\x2e\x20\x53\x56\x47\x20\x56\ +\x65\x72\x73\x69\x6f\x6e\x3a\x20\x36\x2e\x30\x30\x20\x42\x75\x69\ +\x6c\x64\x20\x30\x29\x20\x20\x2d\x2d\x3e\x0d\x0a\x3c\x73\x76\x67\ +\x20\x76\x65\x72\x73\x69\x6f\x6e\x3d\x22\x31\x2e\x31\x22\x20\x69\ +\x64\x3d\x22\x4c\x61\x79\x65\x72\x5f\x31\x22\x20\x78\x6d\x6c\x6e\ +\x73\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\x2e\x77\x33\ +\x2e\x6f\x72\x67\x2f\x32\x30\x30\x30\x2f\x73\x76\x67\x22\x20\x78\ +\x6d\x6c\x6e\x73\x3a\x78\x6c\x69\x6e\x6b\x3d\x22\x68\x74\x74\x70\ +\x3a\x2f\x2f\x77\x77\x77\x2e\x77\x33\x2e\x6f\x72\x67\x2f\x31\x39\ +\x39\x39\x2f\x78\x6c\x69\x6e\x6b\x22\x20\x78\x3d\x22\x30\x70\x78\ +\x22\x20\x79\x3d\x22\x30\x70\x78\x22\x0d\x0a\x09\x20\x76\x69\x65\ +\x77\x42\x6f\x78\x3d\x22\x30\x20\x30\x20\x35\x31\x32\x20\x35\x31\ +\x32\x22\x20\x73\x74\x79\x6c\x65\x3d\x22\x65\x6e\x61\x62\x6c\x65\ +\x2d\x62\x61\x63\x6b\x67\x72\x6f\x75\x6e\x64\x3a\x6e\x65\x77\x20\ +\x30\x20\x30\x20\x35\x31\x32\x20\x35\x31\x32\x3b\x22\x20\x78\x6d\ +\x6c\x3a\x73\x70\x61\x63\x65\x3d\x22\x70\x72\x65\x73\x65\x72\x76\ +\x65\x22\x3e\x0d\x0a\x3c\x67\x3e\x0d\x0a\x09\x3c\x67\x3e\x0d\x0a\ +\x09\x09\x3c\x67\x3e\x0d\x0a\x09\x09\x09\x3c\x70\x61\x74\x68\x20\ +\x64\x3d\x22\x4d\x34\x37\x32\x2e\x31\x37\x38\x2c\x33\x34\x2e\x36\ +\x32\x48\x33\x39\x2e\x38\x32\x32\x43\x31\x37\x2e\x38\x36\x34\x2c\ +\x33\x34\x2e\x36\x32\x2c\x30\x2c\x35\x32\x2e\x34\x38\x34\x2c\x30\ +\x2c\x37\x34\x2e\x34\x34\x32\x63\x30\x2c\x31\x37\x2e\x39\x35\x35\ +\x2c\x30\x2c\x33\x34\x35\x2e\x32\x33\x34\x2c\x30\x2c\x33\x36\x33\ +\x2e\x31\x31\x36\x0d\x0a\x09\x09\x09\x09\x63\x30\x2c\x32\x31\x2e\ +\x39\x35\x38\x2c\x31\x37\x2e\x38\x36\x34\x2c\x33\x39\x2e\x38\x32\ +\x32\x2c\x33\x39\x2e\x38\x32\x32\x2c\x33\x39\x2e\x38\x32\x32\x68\ +\x34\x33\x32\x2e\x33\x35\x36\x63\x32\x31\x2e\x39\x35\x38\x2c\x30\ +\x2c\x33\x39\x2e\x38\x32\x32\x2d\x31\x37\x2e\x38\x36\x34\x2c\x33\ +\x39\x2e\x38\x32\x32\x2d\x33\x39\x2e\x38\x32\x32\x63\x30\x2d\x31\ +\x37\x2e\x38\x39\x2c\x30\x2d\x33\x34\x35\x2e\x31\x36\x37\x2c\x30\ +\x2d\x33\x36\x33\x2e\x31\x31\x36\x0d\x0a\x09\x09\x09\x09\x43\x35\ +\x31\x32\x2c\x35\x32\x2e\x34\x38\x34\x2c\x34\x39\x34\x2e\x31\x33\ +\x36\x2c\x33\x34\x2e\x36\x32\x2c\x34\x37\x32\x2e\x31\x37\x38\x2c\ +\x33\x34\x2e\x36\x32\x7a\x20\x4d\x34\x37\x37\x2e\x38\x36\x37\x2c\ +\x34\x33\x37\x2e\x35\x35\x37\x63\x30\x2c\x33\x2e\x31\x33\x37\x2d\ +\x32\x2e\x35\x35\x32\x2c\x35\x2e\x36\x38\x39\x2d\x35\x2e\x36\x38\ +\x39\x2c\x35\x2e\x36\x38\x39\x48\x33\x39\x2e\x38\x32\x32\x0d\x0a\ +\x09\x09\x09\x09\x63\x2d\x33\x2e\x31\x33\x37\x2c\x30\x2d\x35\x2e\ +\x36\x38\x39\x2d\x32\x2e\x35\x35\x32\x2d\x35\x2e\x36\x38\x39\x2d\ +\x35\x2e\x36\x38\x39\x56\x31\x35\x33\x2e\x38\x33\x38\x68\x34\x34\ +\x33\x2e\x37\x33\x33\x56\x34\x33\x37\x2e\x35\x35\x37\x7a\x20\x4d\ +\x34\x37\x37\x2e\x38\x36\x37\x2c\x31\x31\x39\x2e\x37\x30\x35\x48\ +\x33\x34\x2e\x31\x33\x33\x56\x37\x34\x2e\x34\x34\x32\x63\x30\x2d\ +\x33\x2e\x31\x33\x37\x2c\x32\x2e\x35\x35\x32\x2d\x35\x2e\x36\x38\ +\x39\x2c\x35\x2e\x36\x38\x39\x2d\x35\x2e\x36\x38\x39\x0d\x0a\x09\ +\x09\x09\x09\x68\x34\x33\x32\x2e\x33\x35\x36\x63\x33\x2e\x31\x33\ +\x37\x2c\x30\x2c\x35\x2e\x36\x38\x39\x2c\x32\x2e\x35\x35\x32\x2c\ +\x35\x2e\x36\x38\x39\x2c\x35\x2e\x36\x38\x39\x56\x31\x31\x39\x2e\ +\x37\x30\x35\x7a\x22\x2f\x3e\x0d\x0a\x09\x09\x09\x3c\x63\x69\x72\ +\x63\x6c\x65\x20\x63\x78\x3d\x22\x37\x31\x2e\x38\x30\x35\x22\x20\ +\x63\x79\x3d\x22\x39\x34\x2e\x32\x33\x22\x20\x72\x3d\x22\x31\x32\ +\x2e\x38\x33\x36\x22\x2f\x3e\x0d\x0a\x09\x09\x09\x3c\x63\x69\x72\ +\x63\x6c\x65\x20\x63\x78\x3d\x22\x31\x31\x32\x2e\x39\x35\x39\x22\ +\x20\x63\x79\x3d\x22\x39\x34\x2e\x32\x33\x22\x20\x72\x3d\x22\x31\ +\x32\x2e\x38\x33\x36\x22\x2f\x3e\x0d\x0a\x09\x09\x09\x3c\x63\x69\ +\x72\x63\x6c\x65\x20\x63\x78\x3d\x22\x31\x35\x34\x2e\x31\x31\x32\ +\x22\x20\x63\x79\x3d\x22\x39\x34\x2e\x32\x33\x22\x20\x72\x3d\x22\ +\x31\x32\x2e\x38\x33\x36\x22\x2f\x3e\x0d\x0a\x09\x09\x09\x3c\x70\ +\x61\x74\x68\x20\x64\x3d\x22\x4d\x38\x31\x2e\x35\x38\x31\x2c\x33\ +\x33\x34\x2e\x34\x34\x32\x63\x34\x2e\x34\x34\x2c\x31\x33\x2e\x37\ +\x30\x31\x2c\x32\x32\x2e\x38\x37\x32\x2c\x31\x36\x2e\x31\x30\x31\ +\x2c\x33\x30\x2e\x36\x33\x36\x2c\x33\x2e\x39\x6c\x31\x30\x2e\x34\ +\x35\x34\x2d\x31\x36\x2e\x34\x33\x32\x6c\x31\x30\x2e\x34\x35\x34\ +\x2c\x31\x36\x2e\x34\x33\x32\x0d\x0a\x09\x09\x09\x09\x63\x37\x2e\ +\x37\x34\x34\x2c\x31\x32\x2e\x31\x36\x39\x2c\x32\x36\x2e\x31\x39\ +\x35\x2c\x39\x2e\x38\x30\x31\x2c\x33\x30\x2e\x36\x33\x36\x2d\x33\ +\x2e\x39\x6c\x31\x39\x2e\x38\x35\x37\x2d\x36\x31\x2e\x32\x37\x38\ +\x63\x32\x2e\x39\x30\x36\x2d\x38\x2e\x39\x36\x37\x2d\x32\x2e\x30\ +\x30\x38\x2d\x31\x38\x2e\x35\x39\x31\x2d\x31\x30\x2e\x39\x37\x35\ +\x2d\x32\x31\x2e\x34\x39\x37\x0d\x0a\x09\x09\x09\x09\x63\x2d\x38\ +\x2e\x39\x37\x31\x2d\x32\x2e\x39\x30\x36\x2d\x31\x38\x2e\x35\x39\ +\x31\x2c\x32\x2e\x30\x30\x39\x2d\x32\x31\x2e\x34\x39\x37\x2c\x31\ +\x30\x2e\x39\x37\x35\x6c\x2d\x38\x2e\x36\x38\x31\x2c\x32\x36\x2e\ +\x37\x39\x31\x6c\x2d\x35\x2e\x33\x39\x33\x2d\x38\x2e\x34\x37\x38\ +\x63\x2d\x36\x2e\x36\x39\x35\x2d\x31\x30\x2e\x35\x32\x2d\x32\x32\ +\x2e\x30\x39\x34\x2d\x31\x30\x2e\x35\x33\x38\x2d\x32\x38\x2e\x38\ +\x30\x31\x2c\x30\x6c\x2d\x35\x2e\x33\x39\x32\x2c\x38\x2e\x34\x37\ +\x38\x0d\x0a\x09\x09\x09\x09\x6c\x2d\x38\x2e\x36\x38\x31\x2d\x32\ +\x36\x2e\x37\x39\x63\x2d\x32\x2e\x39\x30\x36\x2d\x38\x2e\x39\x36\ +\x36\x2d\x31\x32\x2e\x35\x32\x36\x2d\x31\x33\x2e\x38\x38\x32\x2d\ +\x32\x31\x2e\x34\x39\x37\x2d\x31\x30\x2e\x39\x37\x35\x63\x2d\x38\ +\x2e\x39\x36\x37\x2c\x32\x2e\x39\x30\x36\x2d\x31\x33\x2e\x38\x38\ +\x2c\x31\x32\x2e\x35\x32\x39\x2d\x31\x30\x2e\x39\x37\x35\x2c\x32\ +\x31\x2e\x34\x39\x36\x4c\x38\x31\x2e\x35\x38\x31\x2c\x33\x33\x34\ +\x2e\x34\x34\x32\x7a\x22\x2f\x3e\x0d\x0a\x09\x09\x09\x3c\x70\x61\ +\x74\x68\x20\x64\x3d\x22\x4d\x32\x31\x34\x2e\x39\x31\x31\x2c\x33\ +\x33\x34\x2e\x34\x34\x32\x63\x34\x2e\x34\x34\x36\x2c\x31\x33\x2e\ +\x37\x32\x2c\x32\x32\x2e\x39\x30\x35\x2c\x31\x36\x2e\x30\x35\x32\ +\x2c\x33\x30\x2e\x36\x33\x36\x2c\x33\x2e\x39\x4c\x32\x35\x36\x2c\ +\x33\x32\x31\x2e\x39\x31\x31\x6c\x31\x30\x2e\x34\x35\x34\x2c\x31\ +\x36\x2e\x34\x33\x32\x63\x37\x2e\x37\x36\x32\x2c\x31\x32\x2e\x32\ +\x2c\x32\x36\x2e\x31\x39\x36\x2c\x39\x2e\x38\x2c\x33\x30\x2e\x36\ +\x33\x36\x2d\x33\x2e\x39\x0d\x0a\x09\x09\x09\x09\x6c\x31\x39\x2e\ +\x38\x35\x35\x2d\x36\x31\x2e\x32\x37\x38\x63\x32\x2e\x39\x30\x36\ +\x2d\x38\x2e\x39\x36\x37\x2d\x32\x2e\x30\x30\x38\x2d\x31\x38\x2e\ +\x35\x39\x2d\x31\x30\x2e\x39\x37\x35\x2d\x32\x31\x2e\x34\x39\x36\ +\x63\x2d\x38\x2e\x39\x37\x34\x2d\x32\x2e\x39\x30\x37\x2d\x31\x38\ +\x2e\x35\x39\x31\x2c\x32\x2e\x30\x30\x38\x2d\x32\x31\x2e\x34\x39\ +\x37\x2c\x31\x30\x2e\x39\x37\x35\x6c\x2d\x38\x2e\x36\x38\x31\x2c\ +\x32\x36\x2e\x37\x39\x6c\x2d\x35\x2e\x33\x39\x32\x2d\x38\x2e\x34\ +\x37\x38\x0d\x0a\x09\x09\x09\x09\x63\x2d\x36\x2e\x36\x39\x35\x2d\ +\x31\x30\x2e\x35\x32\x2d\x32\x32\x2e\x30\x39\x34\x2d\x31\x30\x2e\ +\x35\x33\x38\x2d\x32\x38\x2e\x38\x30\x31\x2c\x30\x6c\x2d\x35\x2e\ +\x33\x39\x32\x2c\x38\x2e\x34\x37\x38\x6c\x2d\x38\x2e\x36\x38\x31\ +\x2d\x32\x36\x2e\x37\x39\x63\x2d\x32\x2e\x39\x30\x35\x2d\x38\x2e\ +\x39\x36\x36\x2d\x31\x32\x2e\x35\x32\x37\x2d\x31\x33\x2e\x38\x38\ +\x32\x2d\x32\x31\x2e\x34\x39\x36\x2d\x31\x30\x2e\x39\x37\x35\x0d\ +\x0a\x09\x09\x09\x09\x63\x2d\x38\x2e\x39\x36\x37\x2c\x32\x2e\x39\ +\x30\x36\x2d\x31\x33\x2e\x38\x38\x2c\x31\x32\x2e\x35\x32\x39\x2d\ +\x31\x30\x2e\x39\x37\x35\x2c\x32\x31\x2e\x34\x39\x36\x4c\x32\x31\ +\x34\x2e\x39\x31\x31\x2c\x33\x33\x34\x2e\x34\x34\x32\x7a\x22\x2f\ +\x3e\x0d\x0a\x09\x09\x09\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x4d\ +\x33\x34\x38\x2e\x32\x34\x31\x2c\x33\x33\x34\x2e\x34\x34\x32\x63\ +\x34\x2e\x34\x34\x36\x2c\x31\x33\x2e\x37\x32\x2c\x32\x32\x2e\x39\ +\x30\x35\x2c\x31\x36\x2e\x30\x35\x31\x2c\x33\x30\x2e\x36\x33\x36\ +\x2c\x33\x2e\x39\x6c\x31\x30\x2e\x34\x35\x34\x2d\x31\x36\x2e\x34\ +\x33\x32\x6c\x31\x30\x2e\x34\x35\x34\x2c\x31\x36\x2e\x34\x33\x32\ +\x63\x37\x2e\x37\x36\x32\x2c\x31\x32\x2e\x32\x2c\x32\x36\x2e\x31\ +\x39\x35\x2c\x39\x2e\x38\x2c\x33\x30\x2e\x36\x33\x36\x2d\x33\x2e\ +\x39\x0d\x0a\x09\x09\x09\x09\x6c\x31\x39\x2e\x38\x35\x35\x2d\x36\ +\x31\x2e\x32\x37\x38\x63\x32\x2e\x39\x30\x36\x2d\x38\x2e\x39\x36\ +\x37\x2d\x32\x2e\x30\x30\x38\x2d\x31\x38\x2e\x35\x39\x2d\x31\x30\ +\x2e\x39\x37\x35\x2d\x32\x31\x2e\x34\x39\x36\x63\x2d\x38\x2e\x39\ +\x37\x33\x2d\x32\x2e\x39\x30\x37\x2d\x31\x38\x2e\x35\x39\x31\x2c\ +\x32\x2e\x30\x30\x38\x2d\x32\x31\x2e\x34\x39\x36\x2c\x31\x30\x2e\ +\x39\x37\x35\x6c\x2d\x38\x2e\x36\x38\x31\x2c\x32\x36\x2e\x37\x39\ +\x6c\x2d\x35\x2e\x33\x39\x32\x2d\x38\x2e\x34\x37\x38\x0d\x0a\x09\ +\x09\x09\x09\x63\x2d\x36\x2e\x36\x39\x35\x2d\x31\x30\x2e\x35\x32\ +\x2d\x32\x32\x2e\x30\x39\x35\x2d\x31\x30\x2e\x35\x33\x38\x2d\x32\ +\x38\x2e\x38\x30\x31\x2c\x30\x6c\x2d\x35\x2e\x33\x39\x33\x2c\x38\ +\x2e\x34\x37\x38\x6c\x2d\x38\x2e\x36\x38\x31\x2d\x32\x36\x2e\x37\ +\x39\x31\x63\x2d\x32\x2e\x39\x30\x36\x2d\x38\x2e\x39\x36\x36\x2d\ +\x31\x32\x2e\x35\x33\x31\x2d\x31\x33\x2e\x38\x38\x31\x2d\x32\x31\ +\x2e\x34\x39\x37\x2d\x31\x30\x2e\x39\x37\x35\x0d\x0a\x09\x09\x09\ +\x09\x63\x2d\x38\x2e\x39\x36\x37\x2c\x32\x2e\x39\x30\x36\x2d\x31\ +\x33\x2e\x38\x38\x2c\x31\x32\x2e\x35\x33\x2d\x31\x30\x2e\x39\x37\ +\x34\x2c\x32\x31\x2e\x34\x39\x37\x4c\x33\x34\x38\x2e\x32\x34\x31\ +\x2c\x33\x33\x34\x2e\x34\x34\x32\x7a\x22\x2f\x3e\x0d\x0a\x09\x09\ +\x3c\x2f\x67\x3e\x0d\x0a\x09\x3c\x2f\x67\x3e\x0d\x0a\x3c\x2f\x67\ +\x3e\x0d\x0a\x3c\x67\x3e\x0d\x0a\x3c\x2f\x67\x3e\x0d\x0a\x3c\x67\ +\x3e\x0d\x0a\x3c\x2f\x67\x3e\x0d\x0a\x3c\x67\x3e\x0d\x0a\x3c\x2f\ +\x67\x3e\x0d\x0a\x3c\x67\x3e\x0d\x0a\x3c\x2f\x67\x3e\x0d\x0a\x3c\ +\x67\x3e\x0d\x0a\x3c\x2f\x67\x3e\x0d\x0a\x3c\x67\x3e\x0d\x0a\x3c\ +\x2f\x67\x3e\x0d\x0a\x3c\x67\x3e\x0d\x0a\x3c\x2f\x67\x3e\x0d\x0a\ +\x3c\x67\x3e\x0d\x0a\x3c\x2f\x67\x3e\x0d\x0a\x3c\x67\x3e\x0d\x0a\ +\x3c\x2f\x67\x3e\x0d\x0a\x3c\x67\x3e\x0d\x0a\x3c\x2f\x67\x3e\x0d\ +\x0a\x3c\x67\x3e\x0d\x0a\x3c\x2f\x67\x3e\x0d\x0a\x3c\x67\x3e\x0d\ +\x0a\x3c\x2f\x67\x3e\x0d\x0a\x3c\x67\x3e\x0d\x0a\x3c\x2f\x67\x3e\ +\x0d\x0a\x3c\x67\x3e\x0d\x0a\x3c\x2f\x67\x3e\x0d\x0a\x3c\x67\x3e\ +\x0d\x0a\x3c\x2f\x67\x3e\x0d\x0a\x3c\x2f\x73\x76\x67\x3e\x0d\x0a\ +\ +\x00\x00\x03\xfb\ +\x3c\ +\x3f\x78\x6d\x6c\x20\x76\x65\x72\x73\x69\x6f\x6e\x3d\x22\x31\x2e\ +\x30\x22\x20\x65\x6e\x63\x6f\x64\x69\x6e\x67\x3d\x22\x55\x54\x46\ +\x2d\x38\x22\x3f\x3e\x0a\x3c\x73\x76\x67\x20\x77\x69\x64\x74\x68\ +\x3d\x22\x34\x34\x38\x22\x20\x68\x65\x69\x67\x68\x74\x3d\x22\x35\ +\x31\x32\x22\x20\x78\x6d\x6c\x6e\x73\x3d\x22\x68\x74\x74\x70\x3a\ +\x2f\x2f\x77\x77\x77\x2e\x77\x33\x2e\x6f\x72\x67\x2f\x32\x30\x30\ +\x30\x2f\x73\x76\x67\x22\x20\x78\x6d\x6c\x6e\x73\x3a\x73\x76\x67\ +\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\x2e\x77\x33\x2e\ +\x6f\x72\x67\x2f\x32\x30\x30\x30\x2f\x73\x76\x67\x22\x3e\x0a\x20\ +\x3c\x74\x69\x74\x6c\x65\x3e\x74\x72\x61\x73\x68\x2d\x72\x65\x64\ +\x3c\x2f\x74\x69\x74\x6c\x65\x3e\x0a\x20\x3c\x67\x3e\x0a\x20\x20\ +\x3c\x74\x69\x74\x6c\x65\x3e\x4c\x61\x79\x65\x72\x20\x31\x3c\x2f\ +\x74\x69\x74\x6c\x65\x3e\x0a\x20\x20\x3c\x70\x61\x74\x68\x20\x66\ +\x69\x6c\x6c\x3d\x22\x23\x66\x66\x30\x30\x30\x30\x22\x20\x69\x64\ +\x3d\x22\x73\x76\x67\x5f\x31\x22\x20\x64\x3d\x22\x6d\x31\x39\x32\ +\x2c\x31\x38\x38\x6c\x30\x2c\x32\x31\x36\x63\x30\x2c\x36\x2e\x36\ +\x32\x37\x20\x2d\x35\x2e\x33\x37\x33\x2c\x31\x32\x20\x2d\x31\x32\ +\x2c\x31\x32\x6c\x2d\x32\x34\x2c\x30\x63\x2d\x36\x2e\x36\x32\x37\ +\x2c\x30\x20\x2d\x31\x32\x2c\x2d\x35\x2e\x33\x37\x33\x20\x2d\x31\ +\x32\x2c\x2d\x31\x32\x6c\x30\x2c\x2d\x32\x31\x36\x63\x30\x2c\x2d\ +\x36\x2e\x36\x32\x37\x20\x35\x2e\x33\x37\x33\x2c\x2d\x31\x32\x20\ +\x31\x32\x2c\x2d\x31\x32\x6c\x32\x34\x2c\x30\x63\x36\x2e\x36\x32\ +\x37\x2c\x30\x20\x31\x32\x2c\x35\x2e\x33\x37\x33\x20\x31\x32\x2c\ +\x31\x32\x7a\x6d\x31\x30\x30\x2c\x2d\x31\x32\x6c\x2d\x32\x34\x2c\ +\x30\x63\x2d\x36\x2e\x36\x32\x37\x2c\x30\x20\x2d\x31\x32\x2c\x35\ +\x2e\x33\x37\x33\x20\x2d\x31\x32\x2c\x31\x32\x6c\x30\x2c\x32\x31\ +\x36\x63\x30\x2c\x36\x2e\x36\x32\x37\x20\x35\x2e\x33\x37\x33\x2c\ +\x31\x32\x20\x31\x32\x2c\x31\x32\x6c\x32\x34\x2c\x30\x63\x36\x2e\ +\x36\x32\x37\x2c\x30\x20\x31\x32\x2c\x2d\x35\x2e\x33\x37\x33\x20\ +\x31\x32\x2c\x2d\x31\x32\x6c\x30\x2c\x2d\x32\x31\x36\x63\x30\x2c\ +\x2d\x36\x2e\x36\x32\x37\x20\x2d\x35\x2e\x33\x37\x33\x2c\x2d\x31\ +\x32\x20\x2d\x31\x32\x2c\x2d\x31\x32\x7a\x6d\x31\x33\x32\x2c\x2d\ +\x39\x36\x63\x31\x33\x2e\x32\x35\x35\x2c\x30\x20\x32\x34\x2c\x31\ +\x30\x2e\x37\x34\x35\x20\x32\x34\x2c\x32\x34\x6c\x30\x2c\x31\x32\ +\x63\x30\x2c\x36\x2e\x36\x32\x37\x20\x2d\x35\x2e\x33\x37\x33\x2c\ +\x31\x32\x20\x2d\x31\x32\x2c\x31\x32\x6c\x2d\x32\x30\x2c\x30\x6c\ +\x30\x2c\x33\x33\x36\x63\x30\x2c\x32\x36\x2e\x35\x31\x20\x2d\x32\ +\x31\x2e\x34\x39\x2c\x34\x38\x20\x2d\x34\x38\x2c\x34\x38\x6c\x2d\ +\x32\x38\x38\x2c\x30\x63\x2d\x32\x36\x2e\x35\x31\x2c\x30\x20\x2d\ +\x34\x38\x2c\x2d\x32\x31\x2e\x34\x39\x20\x2d\x34\x38\x2c\x2d\x34\ +\x38\x6c\x30\x2c\x2d\x33\x33\x36\x6c\x2d\x32\x30\x2c\x30\x63\x2d\ +\x36\x2e\x36\x32\x37\x2c\x30\x20\x2d\x31\x32\x2c\x2d\x35\x2e\x33\ +\x37\x33\x20\x2d\x31\x32\x2c\x2d\x31\x32\x6c\x30\x2c\x2d\x31\x32\ +\x63\x30\x2c\x2d\x31\x33\x2e\x32\x35\x35\x20\x31\x30\x2e\x37\x34\ +\x35\x2c\x2d\x32\x34\x20\x32\x34\x2c\x2d\x32\x34\x6c\x37\x34\x2e\ +\x34\x31\x31\x2c\x30\x6c\x33\x34\x2e\x30\x31\x38\x2c\x2d\x35\x36\ +\x2e\x36\x39\x36\x61\x34\x38\x2c\x34\x38\x20\x30\x20\x30\x20\x31\ +\x20\x34\x31\x2e\x31\x36\x2c\x2d\x32\x33\x2e\x33\x30\x34\x6c\x31\ +\x30\x30\x2e\x38\x32\x33\x2c\x30\x61\x34\x38\x2c\x34\x38\x20\x30\ +\x20\x30\x20\x31\x20\x34\x31\x2e\x31\x36\x2c\x32\x33\x2e\x33\x30\ +\x34\x6c\x33\x34\x2e\x30\x31\x37\x2c\x35\x36\x2e\x36\x39\x36\x6c\ +\x37\x34\x2e\x34\x31\x31\x2c\x30\x7a\x6d\x2d\x32\x36\x39\x2e\x36\ +\x31\x31\x2c\x30\x6c\x31\x33\x39\x2e\x32\x32\x33\x2c\x30\x6c\x2d\ +\x31\x37\x2e\x34\x35\x32\x2c\x2d\x32\x39\x2e\x30\x38\x37\x61\x36\ +\x2c\x36\x20\x30\x20\x30\x20\x30\x20\x2d\x35\x2e\x31\x34\x35\x2c\ +\x2d\x32\x2e\x39\x31\x33\x6c\x2d\x39\x34\x2e\x30\x32\x38\x2c\x30\ +\x61\x36\x2c\x36\x20\x30\x20\x30\x20\x30\x20\x2d\x35\x2e\x31\x34\ +\x35\x2c\x32\x2e\x39\x31\x33\x6c\x2d\x31\x37\x2e\x34\x35\x33\x2c\ +\x32\x39\x2e\x30\x38\x37\x7a\x6d\x32\x31\x33\x2e\x36\x31\x31\x2c\ +\x34\x38\x6c\x2d\x32\x38\x38\x2c\x30\x6c\x30\x2c\x33\x33\x30\x61\ +\x36\x2c\x36\x20\x30\x20\x30\x20\x30\x20\x36\x2c\x36\x6c\x32\x37\ +\x36\x2c\x30\x61\x36\x2c\x36\x20\x30\x20\x30\x20\x30\x20\x36\x2c\ +\x2d\x36\x6c\x30\x2c\x2d\x33\x33\x30\x7a\x22\x2f\x3e\x0a\x20\x3c\ +\x2f\x67\x3e\x0a\x3c\x2f\x73\x76\x67\x3e\ +\x00\x00\x08\xbd\ +\x3c\ +\x73\x76\x67\x20\x68\x65\x69\x67\x68\x74\x3d\x22\x36\x38\x32\x70\ +\x74\x22\x20\x76\x69\x65\x77\x42\x6f\x78\x3d\x22\x2d\x36\x39\x20\ +\x2d\x32\x31\x20\x36\x38\x32\x20\x36\x38\x32\x2e\x36\x36\x36\x36\ +\x39\x22\x20\x77\x69\x64\x74\x68\x3d\x22\x36\x38\x32\x70\x74\x22\ +\x20\x78\x6d\x6c\x6e\x73\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\ +\x77\x77\x2e\x77\x33\x2e\x6f\x72\x67\x2f\x32\x30\x30\x30\x2f\x73\ +\x76\x67\x22\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x35\x30\ +\x37\x2e\x38\x38\x36\x37\x31\x39\x20\x34\x36\x39\x2e\x33\x33\x32\ +\x30\x33\x31\x68\x2d\x31\x35\x38\x2e\x37\x31\x38\x37\x35\x63\x2d\ +\x35\x2e\x38\x39\x34\x35\x33\x31\x20\x30\x2d\x31\x30\x2e\x36\x36\ +\x37\x39\x36\x39\x20\x34\x2e\x37\x37\x33\x34\x33\x38\x2d\x31\x30\ +\x2e\x36\x36\x37\x39\x36\x39\x20\x31\x30\x2e\x36\x36\x37\x39\x36\ +\x39\x20\x30\x20\x35\x2e\x38\x39\x30\x36\x32\x35\x20\x34\x2e\x37\ +\x37\x33\x34\x33\x38\x20\x31\x30\x2e\x36\x36\x34\x30\x36\x32\x20\ +\x31\x30\x2e\x36\x36\x37\x39\x36\x39\x20\x31\x30\x2e\x36\x36\x34\ +\x30\x36\x32\x68\x31\x35\x38\x2e\x37\x31\x38\x37\x35\x63\x31\x38\ +\x2e\x31\x33\x36\x37\x31\x39\x2e\x32\x31\x38\x37\x35\x20\x33\x33\ +\x2e\x30\x32\x37\x33\x34\x33\x2d\x31\x34\x2e\x32\x38\x35\x31\x35\ +\x36\x20\x33\x33\x2e\x32\x37\x37\x33\x34\x33\x2d\x33\x32\x2e\x34\ +\x32\x35\x37\x38\x31\x76\x2d\x33\x31\x39\x2e\x34\x36\x38\x37\x35\ +\x63\x2e\x30\x30\x33\x39\x30\x37\x2d\x32\x2e\x35\x36\x32\x35\x2d\ +\x2e\x39\x31\x34\x30\x36\x32\x2d\x35\x2e\x30\x34\x32\x39\x36\x39\ +\x2d\x32\x2e\x35\x38\x39\x38\x34\x33\x2d\x36\x2e\x39\x38\x34\x33\ +\x37\x35\x6c\x2d\x31\x31\x30\x2e\x37\x32\x32\x36\x35\x37\x2d\x31\ +\x32\x38\x2e\x30\x39\x37\x36\x35\x36\x63\x2d\x32\x2e\x30\x32\x33\ +\x34\x33\x37\x2d\x32\x2e\x33\x34\x33\x37\x35\x2d\x34\x2e\x39\x37\ +\x32\x36\x35\x36\x2d\x33\x2e\x36\x39\x31\x34\x30\x36\x32\x35\x2d\ +\x38\x2e\x30\x37\x34\x32\x31\x38\x2d\x33\x2e\x36\x38\x33\x35\x39\ +\x33\x37\x35\x68\x2d\x32\x34\x30\x63\x2d\x31\x38\x2e\x31\x33\x32\ +\x38\x31\x33\x2d\x2e\x32\x32\x32\x36\x35\x36\x32\x35\x2d\x33\x33\ +\x2e\x30\x32\x37\x33\x34\x34\x20\x31\x34\x2e\x32\x38\x35\x31\x35\ +\x35\x37\x35\x2d\x33\x33\x2e\x32\x37\x33\x34\x33\x38\x20\x33\x32\ +\x2e\x34\x32\x31\x38\x37\x34\x37\x35\x76\x32\x35\x35\x2e\x35\x37\ +\x34\x32\x31\x39\x63\x30\x20\x35\x2e\x38\x38\x36\x37\x31\x39\x20\ +\x34\x2e\x37\x37\x33\x34\x33\x38\x20\x31\x30\x2e\x36\x36\x34\x30\ +\x36\x32\x20\x31\x30\x2e\x36\x36\x30\x31\x35\x36\x20\x31\x30\x2e\ +\x36\x36\x34\x30\x36\x32\x20\x35\x2e\x38\x39\x34\x35\x33\x32\x20\ +\x30\x20\x31\x30\x2e\x36\x37\x31\x38\x37\x36\x2d\x34\x2e\x37\x37\ +\x37\x33\x34\x33\x20\x31\x30\x2e\x36\x37\x31\x38\x37\x36\x2d\x31\ +\x30\x2e\x36\x36\x34\x30\x36\x32\x76\x2d\x32\x35\x35\x2e\x35\x37\ +\x34\x32\x31\x39\x63\x2e\x32\x35\x33\x39\x30\x36\x2d\x36\x2e\x33\ +\x35\x35\x34\x36\x39\x20\x35\x2e\x35\x38\x39\x38\x34\x33\x2d\x31\ +\x31\x2e\x33\x30\x38\x35\x39\x33\x20\x31\x31\x2e\x39\x34\x31\x34\ +\x30\x36\x2d\x31\x31\x2e\x30\x38\x39\x38\x34\x33\x68\x32\x32\x31\ +\x2e\x39\x33\x33\x35\x39\x34\x6c\x2e\x37\x39\x32\x39\x36\x38\x20\ +\x31\x31\x32\x2e\x31\x37\x35\x37\x38\x31\x63\x2e\x31\x33\x36\x37\ +\x31\x39\x20\x31\x34\x2e\x36\x34\x34\x35\x33\x31\x20\x31\x32\x2e\ +\x30\x32\x33\x34\x33\x38\x20\x32\x36\x2e\x34\x34\x35\x33\x31\x32\ +\x20\x32\x36\x2e\x36\x36\x34\x30\x36\x33\x20\x32\x36\x2e\x34\x38\ +\x38\x32\x38\x31\x68\x39\x30\x2e\x36\x36\x34\x30\x36\x32\x76\x32\ +\x39\x38\x2e\x32\x33\x38\x32\x38\x31\x63\x2d\x2e\x32\x35\x33\x39\ +\x30\x36\x20\x36\x2e\x33\x35\x31\x35\x36\x33\x2d\x35\x2e\x35\x39\ +\x33\x37\x35\x20\x31\x31\x2e\x33\x30\x38\x35\x39\x34\x2d\x31\x31\ +\x2e\x39\x34\x35\x33\x31\x32\x20\x31\x31\x2e\x30\x39\x33\x37\x35\ +\x7a\x6d\x2d\x38\x34\x2e\x30\x35\x30\x37\x38\x31\x2d\x33\x33\x36\ +\x2d\x2e\x36\x39\x35\x33\x31\x33\x2d\x31\x30\x32\x2e\x35\x34\x36\ +\x38\x37\x35\x20\x39\x33\x2e\x31\x36\x37\x39\x36\x39\x20\x31\x30\ +\x37\x2e\x38\x37\x38\x39\x30\x36\x68\x2d\x38\x37\x2e\x31\x34\x30\ +\x36\x32\x35\x63\x2d\x32\x2e\x39\x34\x35\x33\x31\x33\x20\x30\x2d\ +\x35\x2e\x33\x33\x32\x30\x33\x31\x2d\x32\x2e\x33\x38\x36\x37\x31\ +\x38\x2d\x35\x2e\x33\x33\x32\x30\x33\x31\x2d\x35\x2e\x33\x33\x32\ +\x30\x33\x31\x7a\x6d\x30\x20\x30\x22\x2f\x3e\x3c\x70\x61\x74\x68\ +\x20\x64\x3d\x22\x6d\x31\x33\x30\x2e\x35\x20\x36\x34\x30\x68\x35\ +\x33\x2e\x33\x33\x35\x39\x33\x38\x63\x31\x34\x2e\x37\x32\x36\x35\ +\x36\x32\x20\x30\x20\x32\x36\x2e\x36\x36\x34\x30\x36\x32\x2d\x31\ +\x31\x2e\x39\x34\x35\x33\x31\x32\x20\x32\x36\x2e\x36\x36\x34\x30\ +\x36\x32\x2d\x32\x36\x2e\x36\x37\x31\x38\x37\x35\x76\x2d\x37\x39\ +\x2e\x39\x39\x36\x30\x39\x34\x68\x38\x30\x63\x31\x34\x2e\x37\x32\ +\x36\x35\x36\x32\x20\x30\x20\x32\x36\x2e\x36\x36\x34\x30\x36\x32\ +\x2d\x31\x31\x2e\x39\x34\x31\x34\x30\x36\x20\x32\x36\x2e\x36\x36\ +\x34\x30\x36\x32\x2d\x32\x36\x2e\x36\x36\x37\x39\x36\x39\x76\x2d\ +\x35\x33\x2e\x33\x33\x32\x30\x33\x31\x63\x30\x2d\x31\x34\x2e\x37\ +\x32\x36\x35\x36\x32\x2d\x31\x31\x2e\x39\x33\x37\x35\x2d\x32\x36\ +\x2e\x36\x36\x37\x39\x36\x39\x2d\x32\x36\x2e\x36\x36\x34\x30\x36\ +\x32\x2d\x32\x36\x2e\x36\x36\x37\x39\x36\x39\x68\x2d\x38\x30\x76\ +\x2d\x38\x30\x63\x30\x2d\x31\x34\x2e\x37\x32\x32\x36\x35\x36\x2d\ +\x31\x31\x2e\x39\x33\x37\x35\x2d\x32\x36\x2e\x36\x36\x34\x30\x36\ +\x32\x2d\x32\x36\x2e\x36\x36\x34\x30\x36\x32\x2d\x32\x36\x2e\x36\ +\x36\x34\x30\x36\x32\x68\x2d\x35\x33\x2e\x33\x33\x35\x39\x33\x38\ +\x63\x2d\x31\x34\x2e\x37\x32\x36\x35\x36\x32\x20\x30\x2d\x32\x36\ +\x2e\x36\x36\x34\x30\x36\x32\x20\x31\x31\x2e\x39\x34\x31\x34\x30\ +\x36\x2d\x32\x36\x2e\x36\x36\x34\x30\x36\x32\x20\x32\x36\x2e\x36\ +\x36\x34\x30\x36\x32\x76\x38\x30\x68\x2d\x38\x30\x63\x2d\x31\x34\ +\x2e\x37\x33\x30\x34\x36\x39\x20\x30\x2d\x32\x36\x2e\x36\x36\x37\ +\x39\x36\x39\x20\x31\x31\x2e\x39\x34\x31\x34\x30\x37\x2d\x32\x36\ +\x2e\x36\x36\x37\x39\x36\x39\x20\x32\x36\x2e\x36\x36\x37\x39\x36\ +\x39\x76\x35\x33\x2e\x33\x33\x32\x30\x33\x31\x63\x30\x20\x31\x34\ +\x2e\x37\x32\x36\x35\x36\x33\x20\x31\x31\x2e\x39\x33\x37\x35\x20\ +\x32\x36\x2e\x36\x36\x37\x39\x36\x39\x20\x32\x36\x2e\x36\x36\x37\ +\x39\x36\x39\x20\x32\x36\x2e\x36\x36\x37\x39\x36\x39\x68\x38\x30\ +\x76\x37\x39\x2e\x39\x39\x36\x30\x39\x34\x63\x30\x20\x31\x34\x2e\ +\x37\x32\x36\x35\x36\x33\x20\x31\x31\x2e\x39\x33\x37\x35\x20\x32\ +\x36\x2e\x36\x37\x31\x38\x37\x35\x20\x32\x36\x2e\x36\x36\x34\x30\ +\x36\x32\x20\x32\x36\x2e\x36\x37\x31\x38\x37\x35\x7a\x6d\x2d\x31\ +\x31\x32\x2d\x31\x33\x33\x2e\x33\x33\x35\x39\x33\x38\x76\x2d\x35\ +\x33\x2e\x33\x33\x32\x30\x33\x31\x63\x30\x2d\x32\x2e\x39\x34\x35\ +\x33\x31\x32\x20\x32\x2e\x33\x38\x36\x37\x31\x39\x2d\x35\x2e\x33\ +\x33\x32\x30\x33\x31\x20\x35\x2e\x33\x33\x35\x39\x33\x38\x2d\x35\ +\x2e\x33\x33\x32\x30\x33\x31\x68\x39\x30\x2e\x36\x36\x34\x30\x36\ +\x32\x63\x35\x2e\x38\x39\x34\x35\x33\x31\x20\x30\x20\x31\x30\x2e\ +\x36\x36\x37\x39\x36\x39\x2d\x34\x2e\x37\x37\x37\x33\x34\x34\x20\ +\x31\x30\x2e\x36\x36\x37\x39\x36\x39\x2d\x31\x30\x2e\x36\x37\x31\ +\x38\x37\x35\x76\x2d\x39\x30\x2e\x36\x36\x34\x30\x36\x33\x63\x30\ +\x2d\x32\x2e\x39\x34\x31\x34\x30\x36\x20\x32\x2e\x33\x39\x30\x36\ +\x32\x35\x2d\x35\x2e\x33\x32\x38\x31\x32\x34\x20\x35\x2e\x33\x33\ +\x32\x30\x33\x31\x2d\x35\x2e\x33\x32\x38\x31\x32\x34\x68\x35\x33\ +\x2e\x33\x33\x35\x39\x33\x38\x63\x32\x2e\x39\x34\x35\x33\x31\x32\ +\x20\x30\x20\x35\x2e\x33\x33\x32\x30\x33\x31\x20\x32\x2e\x33\x38\ +\x36\x37\x31\x38\x20\x35\x2e\x33\x33\x32\x30\x33\x31\x20\x35\x2e\ +\x33\x32\x38\x31\x32\x34\x76\x39\x30\x2e\x36\x36\x34\x30\x36\x33\ +\x63\x30\x20\x35\x2e\x38\x39\x34\x35\x33\x31\x20\x34\x2e\x37\x37\ +\x33\x34\x33\x37\x20\x31\x30\x2e\x36\x37\x31\x38\x37\x35\x20\x31\ +\x30\x2e\x36\x36\x34\x30\x36\x32\x20\x31\x30\x2e\x36\x37\x31\x38\ +\x37\x35\x68\x39\x30\x2e\x36\x36\x37\x39\x36\x39\x63\x32\x2e\x39\ +\x34\x35\x33\x31\x32\x20\x30\x20\x35\x2e\x33\x33\x32\x30\x33\x31\ +\x20\x32\x2e\x33\x38\x36\x37\x31\x39\x20\x35\x2e\x33\x33\x32\x30\ +\x33\x31\x20\x35\x2e\x33\x33\x32\x30\x33\x31\x76\x35\x33\x2e\x33\ +\x33\x32\x30\x33\x31\x63\x30\x20\x32\x2e\x39\x34\x35\x33\x31\x33\ +\x2d\x32\x2e\x33\x38\x36\x37\x31\x39\x20\x35\x2e\x33\x33\x32\x30\ +\x33\x32\x2d\x35\x2e\x33\x33\x32\x30\x33\x31\x20\x35\x2e\x33\x33\ +\x32\x30\x33\x32\x68\x2d\x39\x30\x2e\x36\x36\x37\x39\x36\x39\x63\ +\x2d\x35\x2e\x38\x39\x30\x36\x32\x35\x20\x30\x2d\x31\x30\x2e\x36\ +\x36\x34\x30\x36\x32\x20\x34\x2e\x37\x37\x37\x33\x34\x34\x2d\x31\ +\x30\x2e\x36\x36\x34\x30\x36\x32\x20\x31\x30\x2e\x36\x37\x31\x38\ +\x37\x35\x76\x39\x30\x2e\x36\x36\x30\x31\x35\x36\x63\x30\x20\x32\ +\x2e\x39\x34\x35\x33\x31\x33\x2d\x32\x2e\x33\x38\x36\x37\x31\x39\ +\x20\x35\x2e\x33\x33\x32\x30\x33\x31\x2d\x35\x2e\x33\x33\x32\x30\ +\x33\x31\x20\x35\x2e\x33\x33\x32\x30\x33\x31\x68\x2d\x35\x33\x2e\ +\x33\x33\x35\x39\x33\x38\x63\x2d\x32\x2e\x39\x34\x31\x34\x30\x36\ +\x20\x30\x2d\x35\x2e\x33\x33\x32\x30\x33\x31\x2d\x32\x2e\x33\x38\ +\x36\x37\x31\x38\x2d\x35\x2e\x33\x33\x32\x30\x33\x31\x2d\x35\x2e\ +\x33\x33\x32\x30\x33\x31\x76\x2d\x39\x30\x2e\x36\x36\x30\x31\x35\ +\x36\x63\x30\x2d\x35\x2e\x38\x39\x34\x35\x33\x31\x2d\x34\x2e\x37\ +\x37\x33\x34\x33\x38\x2d\x31\x30\x2e\x36\x37\x31\x38\x37\x35\x2d\ +\x31\x30\x2e\x36\x36\x37\x39\x36\x39\x2d\x31\x30\x2e\x36\x37\x31\ +\x38\x37\x35\x68\x2d\x39\x30\x2e\x36\x36\x34\x30\x36\x32\x63\x2d\ +\x32\x2e\x39\x34\x39\x32\x31\x39\x20\x30\x2d\x35\x2e\x33\x33\x35\ +\x39\x33\x38\x2d\x32\x2e\x33\x38\x36\x37\x31\x39\x2d\x35\x2e\x33\ +\x33\x35\x39\x33\x38\x2d\x35\x2e\x33\x33\x32\x30\x33\x32\x7a\x6d\ +\x30\x20\x30\x22\x2f\x3e\x3c\x2f\x73\x76\x67\x3e\ +\x00\x00\x0d\x92\ +\x3c\ +\x3f\x78\x6d\x6c\x20\x76\x65\x72\x73\x69\x6f\x6e\x3d\x22\x31\x2e\ +\x30\x22\x20\x65\x6e\x63\x6f\x64\x69\x6e\x67\x3d\x22\x55\x54\x46\ +\x2d\x38\x22\x20\x73\x74\x61\x6e\x64\x61\x6c\x6f\x6e\x65\x3d\x22\ +\x6e\x6f\x22\x3f\x3e\x0a\x3c\x73\x76\x67\x0a\x20\x20\x20\x78\x6d\ +\x6c\x6e\x73\x3a\x64\x63\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x70\ +\x75\x72\x6c\x2e\x6f\x72\x67\x2f\x64\x63\x2f\x65\x6c\x65\x6d\x65\ +\x6e\x74\x73\x2f\x31\x2e\x31\x2f\x22\x0a\x20\x20\x20\x78\x6d\x6c\ +\x6e\x73\x3a\x63\x63\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x63\x72\ +\x65\x61\x74\x69\x76\x65\x63\x6f\x6d\x6d\x6f\x6e\x73\x2e\x6f\x72\ +\x67\x2f\x6e\x73\x23\x22\x0a\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\ +\x72\x64\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\x2e\ +\x77\x33\x2e\x6f\x72\x67\x2f\x31\x39\x39\x39\x2f\x30\x32\x2f\x32\ +\x32\x2d\x72\x64\x66\x2d\x73\x79\x6e\x74\x61\x78\x2d\x6e\x73\x23\ +\x22\x0a\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x73\x76\x67\x3d\x22\ +\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\x2e\x77\x33\x2e\x6f\x72\ +\x67\x2f\x32\x30\x30\x30\x2f\x73\x76\x67\x22\x0a\x20\x20\x20\x78\ +\x6d\x6c\x6e\x73\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\ +\x2e\x77\x33\x2e\x6f\x72\x67\x2f\x32\x30\x30\x30\x2f\x73\x76\x67\ +\x22\x0a\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x73\x6f\x64\x69\x70\ +\x6f\x64\x69\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x73\x6f\x64\x69\ +\x70\x6f\x64\x69\x2e\x73\x6f\x75\x72\x63\x65\x66\x6f\x72\x67\x65\ +\x2e\x6e\x65\x74\x2f\x44\x54\x44\x2f\x73\x6f\x64\x69\x70\x6f\x64\ +\x69\x2d\x30\x2e\x64\x74\x64\x22\x0a\x20\x20\x20\x78\x6d\x6c\x6e\ +\x73\x3a\x69\x6e\x6b\x73\x63\x61\x70\x65\x3d\x22\x68\x74\x74\x70\ +\x3a\x2f\x2f\x77\x77\x77\x2e\x69\x6e\x6b\x73\x63\x61\x70\x65\x2e\ +\x6f\x72\x67\x2f\x6e\x61\x6d\x65\x73\x70\x61\x63\x65\x73\x2f\x69\ +\x6e\x6b\x73\x63\x61\x70\x65\x22\x0a\x20\x20\x20\x76\x65\x72\x73\ +\x69\x6f\x6e\x3d\x22\x31\x2e\x30\x22\x0a\x20\x20\x20\x77\x69\x64\ +\x74\x68\x3d\x22\x39\x30\x2e\x30\x30\x30\x30\x30\x30\x70\x74\x22\ +\x0a\x20\x20\x20\x68\x65\x69\x67\x68\x74\x3d\x22\x39\x30\x2e\x30\ +\x30\x30\x30\x30\x30\x70\x74\x22\x0a\x20\x20\x20\x76\x69\x65\x77\ +\x42\x6f\x78\x3d\x22\x30\x20\x30\x20\x39\x30\x2e\x30\x30\x30\x30\ +\x30\x30\x20\x39\x30\x2e\x30\x30\x30\x30\x30\x30\x22\x0a\x20\x20\ +\x20\x70\x72\x65\x73\x65\x72\x76\x65\x41\x73\x70\x65\x63\x74\x52\ +\x61\x74\x69\x6f\x3d\x22\x78\x4d\x69\x64\x59\x4d\x69\x64\x20\x6d\ +\x65\x65\x74\x22\x0a\x20\x20\x20\x69\x64\x3d\x22\x73\x76\x67\x36\ +\x22\x0a\x20\x20\x20\x73\x6f\x64\x69\x70\x6f\x64\x69\x3a\x64\x6f\ +\x63\x6e\x61\x6d\x65\x3d\x22\x68\x6f\x76\x65\x72\x2e\x73\x76\x67\ +\x22\x0a\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x76\x65\ +\x72\x73\x69\x6f\x6e\x3d\x22\x31\x2e\x30\x2e\x31\x20\x28\x31\x2e\ +\x30\x2e\x31\x2b\x72\x37\x34\x29\x22\x3e\x0a\x20\x20\x3c\x6d\x65\ +\x74\x61\x64\x61\x74\x61\x0a\x20\x20\x20\x20\x20\x69\x64\x3d\x22\ +\x6d\x65\x74\x61\x64\x61\x74\x61\x31\x32\x22\x3e\x0a\x20\x20\x20\ +\x20\x3c\x72\x64\x66\x3a\x52\x44\x46\x3e\x0a\x20\x20\x20\x20\x20\ +\x20\x3c\x63\x63\x3a\x57\x6f\x72\x6b\x0a\x20\x20\x20\x20\x20\x20\ +\x20\x20\x20\x72\x64\x66\x3a\x61\x62\x6f\x75\x74\x3d\x22\x22\x3e\ +\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x3c\x64\x63\x3a\x66\x6f\x72\ +\x6d\x61\x74\x3e\x69\x6d\x61\x67\x65\x2f\x73\x76\x67\x2b\x78\x6d\ +\x6c\x3c\x2f\x64\x63\x3a\x66\x6f\x72\x6d\x61\x74\x3e\x0a\x20\x20\ +\x20\x20\x20\x20\x20\x20\x3c\x64\x63\x3a\x74\x79\x70\x65\x0a\x20\ +\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x72\x64\x66\x3a\x72\x65\ +\x73\x6f\x75\x72\x63\x65\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x70\ +\x75\x72\x6c\x2e\x6f\x72\x67\x2f\x64\x63\x2f\x64\x63\x6d\x69\x74\ +\x79\x70\x65\x2f\x53\x74\x69\x6c\x6c\x49\x6d\x61\x67\x65\x22\x20\ +\x2f\x3e\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x3c\x64\x63\x3a\x74\ +\x69\x74\x6c\x65\x3e\x3c\x2f\x64\x63\x3a\x74\x69\x74\x6c\x65\x3e\ +\x0a\x20\x20\x20\x20\x20\x20\x3c\x2f\x63\x63\x3a\x57\x6f\x72\x6b\ +\x3e\x0a\x20\x20\x20\x20\x3c\x2f\x72\x64\x66\x3a\x52\x44\x46\x3e\ +\x0a\x20\x20\x3c\x2f\x6d\x65\x74\x61\x64\x61\x74\x61\x3e\x0a\x20\ +\x20\x3c\x64\x65\x66\x73\x0a\x20\x20\x20\x20\x20\x69\x64\x3d\x22\ +\x64\x65\x66\x73\x31\x30\x22\x20\x2f\x3e\x0a\x20\x20\x3c\x73\x6f\ +\x64\x69\x70\x6f\x64\x69\x3a\x6e\x61\x6d\x65\x64\x76\x69\x65\x77\ +\x0a\x20\x20\x20\x20\x20\x70\x61\x67\x65\x63\x6f\x6c\x6f\x72\x3d\ +\x22\x23\x66\x66\x66\x66\x66\x66\x22\x0a\x20\x20\x20\x20\x20\x62\ +\x6f\x72\x64\x65\x72\x63\x6f\x6c\x6f\x72\x3d\x22\x23\x36\x36\x36\ +\x36\x36\x36\x22\x0a\x20\x20\x20\x20\x20\x62\x6f\x72\x64\x65\x72\ +\x6f\x70\x61\x63\x69\x74\x79\x3d\x22\x31\x22\x0a\x20\x20\x20\x20\ +\x20\x6f\x62\x6a\x65\x63\x74\x74\x6f\x6c\x65\x72\x61\x6e\x63\x65\ +\x3d\x22\x31\x30\x22\x0a\x20\x20\x20\x20\x20\x67\x72\x69\x64\x74\ +\x6f\x6c\x65\x72\x61\x6e\x63\x65\x3d\x22\x31\x30\x22\x0a\x20\x20\ +\x20\x20\x20\x67\x75\x69\x64\x65\x74\x6f\x6c\x65\x72\x61\x6e\x63\ +\x65\x3d\x22\x31\x30\x22\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\ +\x63\x61\x70\x65\x3a\x70\x61\x67\x65\x6f\x70\x61\x63\x69\x74\x79\ +\x3d\x22\x30\x22\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\ +\x70\x65\x3a\x70\x61\x67\x65\x73\x68\x61\x64\x6f\x77\x3d\x22\x32\ +\x22\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\ +\x77\x69\x6e\x64\x6f\x77\x2d\x77\x69\x64\x74\x68\x3d\x22\x31\x39\ +\x32\x30\x22\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\ +\x65\x3a\x77\x69\x6e\x64\x6f\x77\x2d\x68\x65\x69\x67\x68\x74\x3d\ +\x22\x31\x30\x31\x35\x22\x0a\x20\x20\x20\x20\x20\x69\x64\x3d\x22\ +\x6e\x61\x6d\x65\x64\x76\x69\x65\x77\x38\x22\x0a\x20\x20\x20\x20\ +\x20\x73\x68\x6f\x77\x67\x72\x69\x64\x3d\x22\x66\x61\x6c\x73\x65\ +\x22\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\ +\x7a\x6f\x6f\x6d\x3d\x22\x31\x2e\x30\x35\x33\x32\x39\x34\x35\x22\ +\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x63\ +\x78\x3d\x22\x35\x30\x33\x2e\x38\x30\x36\x30\x33\x22\x0a\x20\x20\ +\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x63\x79\x3d\x22\ +\x37\x33\x2e\x33\x38\x31\x38\x31\x37\x22\x0a\x20\x20\x20\x20\x20\ +\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x77\x69\x6e\x64\x6f\x77\x2d\ +\x78\x3d\x22\x30\x22\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\ +\x61\x70\x65\x3a\x77\x69\x6e\x64\x6f\x77\x2d\x79\x3d\x22\x30\x22\ +\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x77\ +\x69\x6e\x64\x6f\x77\x2d\x6d\x61\x78\x69\x6d\x69\x7a\x65\x64\x3d\ +\x22\x31\x22\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\ +\x65\x3a\x63\x75\x72\x72\x65\x6e\x74\x2d\x6c\x61\x79\x65\x72\x3d\ +\x22\x73\x76\x67\x36\x22\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\ +\x63\x61\x70\x65\x3a\x64\x6f\x63\x75\x6d\x65\x6e\x74\x2d\x72\x6f\ +\x74\x61\x74\x69\x6f\x6e\x3d\x22\x30\x22\x20\x2f\x3e\x0a\x20\x20\ +\x3c\x67\x0a\x20\x20\x20\x20\x20\x74\x72\x61\x6e\x73\x66\x6f\x72\ +\x6d\x3d\x22\x74\x72\x61\x6e\x73\x6c\x61\x74\x65\x28\x30\x2e\x30\ +\x30\x30\x30\x30\x30\x2c\x39\x30\x2e\x30\x30\x30\x30\x30\x30\x29\ +\x20\x73\x63\x61\x6c\x65\x28\x30\x2e\x31\x30\x30\x30\x30\x30\x2c\ +\x2d\x30\x2e\x31\x30\x30\x30\x30\x30\x29\x22\x0a\x20\x20\x20\x20\ +\x20\x66\x69\x6c\x6c\x3d\x22\x23\x65\x37\x34\x63\x33\x63\x22\x0a\ +\x20\x20\x20\x20\x20\x73\x74\x72\x6f\x6b\x65\x3d\x22\x6e\x6f\x6e\ +\x65\x22\x0a\x20\x20\x20\x20\x20\x69\x64\x3d\x22\x67\x34\x22\x0a\ +\x20\x20\x20\x20\x20\x73\x74\x79\x6c\x65\x3d\x22\x66\x69\x6c\x6c\ +\x3a\x23\x66\x66\x30\x30\x30\x30\x22\x3e\x0a\x20\x20\x20\x20\x3c\ +\x70\x61\x74\x68\x0a\x20\x20\x20\x20\x20\x20\x20\x64\x3d\x22\x4d\ +\x33\x38\x31\x20\x38\x30\x30\x20\x63\x2d\x31\x34\x34\x20\x2d\x33\ +\x30\x20\x2d\x32\x35\x34\x20\x2d\x31\x34\x35\x20\x2d\x32\x38\x32\ +\x20\x2d\x32\x39\x33\x20\x2d\x34\x32\x20\x2d\x32\x33\x32\x20\x31\ +\x37\x36\x20\x2d\x34\x35\x30\x20\x34\x30\x38\x20\x2d\x34\x30\x38\ +\x20\x32\x33\x30\x20\x34\x33\x20\x33\x36\x31\x20\x32\x38\x31\x20\ +\x32\x37\x30\x20\x34\x39\x33\x20\x2d\x36\x34\x20\x31\x34\x38\x20\ +\x2d\x32\x33\x38\x20\x32\x34\x30\x20\x2d\x33\x39\x36\x20\x32\x30\ +\x38\x7a\x20\x6d\x31\x36\x20\x2d\x32\x35\x32\x20\x6c\x35\x33\x20\ +\x2d\x35\x32\x20\x35\x34\x20\x35\x33\x20\x63\x35\x35\x20\x35\x34\ +\x20\x38\x37\x20\x36\x34\x20\x39\x34\x20\x32\x37\x20\x33\x20\x2d\ +\x31\x32\x20\x2d\x31\x35\x20\x2d\x33\x38\x20\x2d\x34\x39\x20\x2d\ +\x37\x32\x20\x6c\x2d\x35\x33\x20\x2d\x35\x34\x20\x35\x33\x20\x2d\ +\x35\x34\x20\x63\x35\x34\x20\x2d\x35\x35\x20\x36\x34\x20\x2d\x38\ +\x37\x20\x32\x37\x20\x2d\x39\x34\x20\x2d\x31\x32\x20\x2d\x33\x20\ +\x2d\x33\x38\x20\x31\x35\x20\x2d\x37\x32\x20\x34\x39\x20\x6c\x2d\ +\x35\x34\x20\x35\x33\x20\x2d\x35\x34\x20\x2d\x35\x33\x20\x63\x2d\ +\x33\x34\x20\x2d\x33\x34\x20\x2d\x36\x30\x20\x2d\x35\x32\x20\x2d\ +\x37\x32\x20\x2d\x34\x39\x20\x2d\x33\x37\x20\x37\x20\x2d\x32\x37\ +\x20\x33\x39\x20\x32\x37\x20\x39\x34\x20\x6c\x35\x33\x20\x35\x34\ +\x20\x2d\x35\x32\x20\x35\x33\x20\x63\x2d\x34\x39\x20\x34\x39\x20\ +\x2d\x36\x31\x20\x37\x34\x20\x2d\x34\x35\x20\x39\x30\x20\x31\x36\ +\x20\x31\x36\x20\x34\x31\x20\x34\x20\x39\x30\x20\x2d\x34\x35\x7a\ +\x22\x0a\x20\x20\x20\x20\x20\x20\x20\x69\x64\x3d\x22\x70\x61\x74\ +\x68\x32\x22\x0a\x20\x20\x20\x20\x20\x20\x20\x73\x74\x79\x6c\x65\ +\x3d\x22\x66\x69\x6c\x6c\x3a\x23\x66\x66\x30\x30\x30\x30\x22\x20\ +\x2f\x3e\x0a\x20\x20\x3c\x2f\x67\x3e\x0a\x20\x20\x3c\x70\x61\x74\ +\x68\x0a\x20\x20\x20\x20\x20\x73\x74\x79\x6c\x65\x3d\x22\x66\x69\ +\x6c\x6c\x3a\x23\x66\x66\x66\x66\x66\x66\x3b\x73\x74\x72\x6f\x6b\ +\x65\x2d\x77\x69\x64\x74\x68\x3a\x30\x2e\x31\x31\x38\x36\x37\x35\ +\x22\x0a\x20\x20\x20\x20\x20\x64\x3d\x22\x6d\x20\x34\x32\x2e\x36\ +\x32\x32\x36\x31\x2c\x37\x39\x2e\x35\x34\x31\x38\x39\x33\x20\x63\ +\x20\x2d\x33\x2e\x31\x31\x36\x31\x36\x35\x2c\x2d\x31\x2e\x30\x32\ +\x39\x36\x31\x32\x20\x2d\x33\x2e\x30\x35\x38\x38\x2c\x2d\x33\x2e\ +\x33\x36\x36\x38\x30\x38\x20\x30\x2e\x31\x38\x38\x32\x35\x34\x2c\ +\x2d\x37\x2e\x36\x36\x39\x39\x38\x39\x20\x31\x2e\x33\x39\x36\x34\ +\x36\x39\x2c\x2d\x31\x2e\x38\x35\x30\x36\x38\x32\x20\x32\x2e\x37\ +\x31\x36\x37\x39\x39\x2c\x2d\x33\x2e\x32\x38\x37\x33\x38\x37\x20\ +\x37\x2e\x32\x38\x31\x38\x35\x33\x2c\x2d\x37\x2e\x39\x32\x33\x36\ +\x38\x36\x20\x6c\x20\x33\x2e\x38\x38\x34\x39\x33\x33\x2c\x2d\x33\ +\x2e\x39\x34\x35\x35\x36\x32\x20\x2d\x34\x2e\x35\x36\x34\x32\x34\ +\x31\x2c\x2d\x34\x2e\x36\x35\x38\x33\x39\x34\x20\x63\x20\x2d\x36\ +\x2e\x30\x38\x35\x35\x35\x2c\x2d\x36\x2e\x32\x31\x31\x30\x38\x35\ +\x20\x2d\x37\x2e\x34\x33\x39\x37\x30\x39\x2c\x2d\x37\x2e\x37\x37\ +\x35\x39\x33\x31\x20\x2d\x38\x2e\x35\x31\x32\x35\x35\x39\x2c\x2d\ +\x39\x2e\x38\x33\x36\x39\x38\x36\x20\x2d\x31\x2e\x31\x31\x34\x30\ +\x39\x31\x2c\x2d\x32\x2e\x31\x34\x30\x32\x38\x32\x20\x2d\x31\x2e\ +\x30\x33\x35\x30\x35\x33\x2c\x2d\x33\x2e\x35\x35\x33\x39\x36\x34\ +\x20\x30\x2e\x32\x36\x30\x37\x30\x38\x2c\x2d\x34\x2e\x36\x36\x33\ +\x30\x38\x38\x20\x30\x2e\x37\x30\x38\x37\x38\x34\x2c\x2d\x30\x2e\ +\x36\x30\x36\x36\x39\x32\x20\x31\x2e\x35\x38\x35\x35\x37\x34\x2c\ +\x2d\x30\x2e\x38\x34\x37\x31\x20\x32\x2e\x34\x35\x33\x36\x34\x37\ +\x2c\x2d\x30\x2e\x36\x37\x32\x37\x36\x36\x20\x31\x2e\x32\x38\x32\ +\x35\x34\x32\x2c\x30\x2e\x32\x35\x37\x35\x37\x32\x20\x32\x2e\x38\ +\x39\x33\x39\x38\x39\x2c\x31\x2e\x31\x37\x37\x35\x34\x36\x20\x34\ +\x2e\x38\x36\x39\x36\x32\x31\x2c\x32\x2e\x37\x38\x30\x30\x36\x35\ +\x20\x31\x2e\x34\x32\x38\x36\x35\x32\x2c\x31\x2e\x31\x35\x38\x38\ +\x34\x20\x31\x2e\x37\x35\x38\x39\x37\x34\x2c\x31\x2e\x34\x36\x39\ +\x39\x39\x39\x20\x36\x2e\x39\x32\x34\x37\x30\x31\x2c\x36\x2e\x35\ +\x32\x32\x39\x38\x32\x20\x6c\x20\x34\x2e\x35\x39\x38\x33\x32\x33\ +\x2c\x34\x2e\x34\x39\x37\x39\x36\x34\x20\x33\x2e\x39\x34\x36\x32\ +\x39\x36\x2c\x2d\x33\x2e\x38\x38\x35\x36\x35\x35\x20\x63\x20\x35\ +\x2e\x36\x38\x37\x34\x38\x38\x2c\x2d\x35\x2e\x36\x30\x30\x30\x39\ +\x20\x37\x2e\x35\x30\x35\x32\x36\x2c\x2d\x37\x2e\x31\x38\x38\x37\ +\x36\x31\x20\x39\x2e\x38\x33\x34\x37\x30\x31\x2c\x2d\x38\x2e\x35\ +\x39\x35\x31\x39\x37\x20\x30\x2e\x39\x39\x35\x37\x32\x37\x2c\x2d\ +\x30\x2e\x36\x30\x31\x31\x38\x35\x20\x32\x2e\x35\x36\x34\x37\x37\ +\x35\x2c\x2d\x31\x2e\x31\x39\x30\x33\x38\x32\x20\x33\x2e\x31\x36\ +\x36\x37\x30\x39\x2c\x2d\x31\x2e\x31\x38\x39\x31\x33\x38\x20\x31\ +\x2e\x33\x32\x39\x35\x38\x39\x2c\x30\x2e\x30\x30\x32\x37\x20\x32\ +\x2e\x32\x35\x38\x34\x37\x31\x2c\x30\x2e\x39\x32\x38\x31\x32\x36\ +\x20\x32\x2e\x36\x33\x36\x30\x30\x38\x2c\x32\x2e\x36\x32\x36\x30\ +\x36\x33\x20\x30\x2e\x31\x38\x34\x34\x32\x34\x2c\x30\x2e\x38\x32\ +\x39\x34\x33\x34\x20\x30\x2e\x31\x31\x30\x32\x39\x2c\x31\x2e\x32\ +\x34\x30\x37\x37\x36\x20\x2d\x30\x2e\x34\x32\x34\x38\x38\x36\x2c\ +\x32\x2e\x33\x35\x37\x34\x39\x39\x20\x2d\x31\x2e\x31\x30\x34\x36\ +\x39\x2c\x32\x2e\x33\x30\x35\x31\x20\x2d\x32\x2e\x38\x35\x31\x38\ +\x31\x34\x2c\x34\x2e\x33\x30\x32\x35\x34\x34\x20\x2d\x31\x30\x2e\ +\x36\x36\x35\x38\x37\x36\x2c\x31\x32\x2e\x31\x39\x34\x30\x33\x33\ +\x20\x6c\x20\x2d\x32\x2e\x34\x39\x37\x31\x31\x38\x2c\x32\x2e\x35\ +\x32\x31\x38\x36\x31\x20\x32\x2e\x35\x35\x36\x32\x37\x36\x2c\x32\ +\x2e\x35\x38\x31\x31\x37\x35\x20\x63\x20\x37\x2e\x34\x37\x38\x39\ +\x35\x2c\x37\x2e\x35\x35\x31\x37\x39\x39\x20\x39\x2e\x31\x38\x30\ +\x34\x34\x37\x2c\x39\x2e\x35\x33\x33\x37\x30\x39\x20\x31\x30\x2e\ +\x34\x34\x32\x30\x32\x36\x2c\x31\x32\x2e\x31\x36\x32\x39\x31\x32\ +\x20\x31\x2e\x31\x31\x38\x30\x38\x33\x2c\x32\x2e\x33\x33\x30\x31\ +\x34\x37\x20\x30\x2e\x38\x32\x34\x33\x34\x33\x2c\x33\x2e\x37\x33\ +\x32\x36\x33\x31\x20\x2d\x30\x2e\x39\x34\x38\x33\x39\x35\x2c\x34\ +\x2e\x35\x32\x38\x31\x39\x20\x2d\x31\x2e\x34\x36\x38\x33\x31\x37\ +\x2c\x30\x2e\x36\x35\x38\x39\x34\x31\x20\x2d\x32\x2e\x34\x36\x33\ +\x37\x33\x2c\x30\x2e\x35\x31\x37\x39\x35\x38\x20\x2d\x34\x2e\x32\ +\x34\x36\x37\x39\x31\x2c\x2d\x30\x2e\x36\x30\x31\x34\x38\x32\x20\ +\x2d\x32\x2e\x32\x33\x30\x38\x34\x33\x2c\x2d\x31\x2e\x34\x30\x30\ +\x35\x36\x36\x20\x2d\x33\x2e\x31\x39\x35\x34\x35\x2c\x2d\x32\x2e\ +\x32\x35\x33\x34\x37\x20\x2d\x39\x2e\x32\x30\x30\x36\x38\x33\x2c\ +\x2d\x38\x2e\x31\x33\x35\x32\x32\x34\x20\x6c\x20\x2d\x34\x2e\x35\ +\x39\x39\x37\x35\x33\x2c\x2d\x34\x2e\x35\x30\x35\x31\x37\x33\x20\ +\x2d\x34\x2e\x35\x33\x38\x32\x34\x33\x2c\x34\x2e\x34\x33\x37\x39\ +\x32\x31\x20\x63\x20\x2d\x35\x2e\x31\x32\x35\x35\x30\x39\x2c\x35\ +\x2e\x30\x31\x32\x32\x30\x35\x20\x2d\x35\x2e\x34\x33\x36\x38\x35\ +\x34\x2c\x35\x2e\x33\x30\x35\x31\x37\x31\x20\x2d\x36\x2e\x38\x39\ +\x36\x37\x33\x32\x2c\x36\x2e\x34\x38\x39\x36\x31\x39\x20\x2d\x32\ +\x2e\x37\x39\x33\x39\x38\x2c\x32\x2e\x32\x36\x36\x38\x35\x32\x20\ +\x2d\x34\x2e\x35\x36\x39\x38\x35\x35\x2c\x33\x2e\x30\x33\x37\x36\ +\x36\x37\x20\x2d\x35\x2e\x39\x34\x38\x37\x37\x39\x2c\x32\x2e\x35\ +\x38\x32\x30\x35\x36\x20\x7a\x22\x0a\x20\x20\x20\x20\x20\x69\x64\ +\x3d\x22\x70\x61\x74\x68\x31\x36\x22\x0a\x20\x20\x20\x20\x20\x74\ +\x72\x61\x6e\x73\x66\x6f\x72\x6d\x3d\x22\x73\x63\x61\x6c\x65\x28\ +\x30\x2e\x37\x35\x29\x22\x20\x2f\x3e\x0a\x3c\x2f\x73\x76\x67\x3e\ +\x0a\ +\x00\x00\x0d\x4d\ +\x3c\ +\x3f\x78\x6d\x6c\x20\x76\x65\x72\x73\x69\x6f\x6e\x3d\x22\x31\x2e\ +\x30\x22\x20\x65\x6e\x63\x6f\x64\x69\x6e\x67\x3d\x22\x55\x54\x46\ +\x2d\x38\x22\x20\x73\x74\x61\x6e\x64\x61\x6c\x6f\x6e\x65\x3d\x22\ +\x6e\x6f\x22\x3f\x3e\x0a\x3c\x73\x76\x67\x0a\x20\x20\x20\x78\x6d\ +\x6c\x6e\x73\x3a\x64\x63\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x70\ +\x75\x72\x6c\x2e\x6f\x72\x67\x2f\x64\x63\x2f\x65\x6c\x65\x6d\x65\ +\x6e\x74\x73\x2f\x31\x2e\x31\x2f\x22\x0a\x20\x20\x20\x78\x6d\x6c\ +\x6e\x73\x3a\x63\x63\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x63\x72\ +\x65\x61\x74\x69\x76\x65\x63\x6f\x6d\x6d\x6f\x6e\x73\x2e\x6f\x72\ +\x67\x2f\x6e\x73\x23\x22\x0a\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\ +\x72\x64\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\x2e\ +\x77\x33\x2e\x6f\x72\x67\x2f\x31\x39\x39\x39\x2f\x30\x32\x2f\x32\ +\x32\x2d\x72\x64\x66\x2d\x73\x79\x6e\x74\x61\x78\x2d\x6e\x73\x23\ +\x22\x0a\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x73\x76\x67\x3d\x22\ +\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\x2e\x77\x33\x2e\x6f\x72\ +\x67\x2f\x32\x30\x30\x30\x2f\x73\x76\x67\x22\x0a\x20\x20\x20\x78\ +\x6d\x6c\x6e\x73\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\ +\x2e\x77\x33\x2e\x6f\x72\x67\x2f\x32\x30\x30\x30\x2f\x73\x76\x67\ +\x22\x0a\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x73\x6f\x64\x69\x70\ +\x6f\x64\x69\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x73\x6f\x64\x69\ +\x70\x6f\x64\x69\x2e\x73\x6f\x75\x72\x63\x65\x66\x6f\x72\x67\x65\ +\x2e\x6e\x65\x74\x2f\x44\x54\x44\x2f\x73\x6f\x64\x69\x70\x6f\x64\ +\x69\x2d\x30\x2e\x64\x74\x64\x22\x0a\x20\x20\x20\x78\x6d\x6c\x6e\ +\x73\x3a\x69\x6e\x6b\x73\x63\x61\x70\x65\x3d\x22\x68\x74\x74\x70\ +\x3a\x2f\x2f\x77\x77\x77\x2e\x69\x6e\x6b\x73\x63\x61\x70\x65\x2e\ +\x6f\x72\x67\x2f\x6e\x61\x6d\x65\x73\x70\x61\x63\x65\x73\x2f\x69\ +\x6e\x6b\x73\x63\x61\x70\x65\x22\x0a\x20\x20\x20\x76\x65\x72\x73\ +\x69\x6f\x6e\x3d\x22\x31\x2e\x30\x22\x0a\x20\x20\x20\x77\x69\x64\ +\x74\x68\x3d\x22\x39\x30\x2e\x30\x30\x30\x30\x30\x30\x70\x74\x22\ +\x0a\x20\x20\x20\x68\x65\x69\x67\x68\x74\x3d\x22\x39\x30\x2e\x30\ +\x30\x30\x30\x30\x30\x70\x74\x22\x0a\x20\x20\x20\x76\x69\x65\x77\ +\x42\x6f\x78\x3d\x22\x30\x20\x30\x20\x39\x30\x2e\x30\x30\x30\x30\ +\x30\x30\x20\x39\x30\x2e\x30\x30\x30\x30\x30\x30\x22\x0a\x20\x20\ +\x20\x70\x72\x65\x73\x65\x72\x76\x65\x41\x73\x70\x65\x63\x74\x52\ +\x61\x74\x69\x6f\x3d\x22\x78\x4d\x69\x64\x59\x4d\x69\x64\x20\x6d\ +\x65\x65\x74\x22\x0a\x20\x20\x20\x69\x64\x3d\x22\x73\x76\x67\x36\ +\x22\x0a\x20\x20\x20\x73\x6f\x64\x69\x70\x6f\x64\x69\x3a\x64\x6f\ +\x63\x6e\x61\x6d\x65\x3d\x22\x69\x63\x6f\x6e\x73\x38\x2d\x6d\x61\ +\x63\x6f\x73\x2d\x73\x63\x68\x6c\x69\x65\xc3\x9f\x65\x6e\x2d\x39\ +\x30\x5f\x31\x5f\x2e\x73\x76\x67\x22\x0a\x20\x20\x20\x69\x6e\x6b\ +\x73\x63\x61\x70\x65\x3a\x76\x65\x72\x73\x69\x6f\x6e\x3d\x22\x31\ +\x2e\x30\x2e\x31\x20\x28\x31\x2e\x30\x2e\x31\x2b\x72\x37\x34\x29\ +\x22\x3e\x0a\x20\x20\x3c\x6d\x65\x74\x61\x64\x61\x74\x61\x0a\x20\ +\x20\x20\x20\x20\x69\x64\x3d\x22\x6d\x65\x74\x61\x64\x61\x74\x61\ +\x31\x32\x22\x3e\x0a\x20\x20\x20\x20\x3c\x72\x64\x66\x3a\x52\x44\ +\x46\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c\x63\x63\x3a\x57\x6f\x72\ +\x6b\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x72\x64\x66\x3a\x61\ +\x62\x6f\x75\x74\x3d\x22\x22\x3e\x0a\x20\x20\x20\x20\x20\x20\x20\ +\x20\x3c\x64\x63\x3a\x66\x6f\x72\x6d\x61\x74\x3e\x69\x6d\x61\x67\ +\x65\x2f\x73\x76\x67\x2b\x78\x6d\x6c\x3c\x2f\x64\x63\x3a\x66\x6f\ +\x72\x6d\x61\x74\x3e\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x3c\x64\ +\x63\x3a\x74\x79\x70\x65\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\ +\x20\x20\x72\x64\x66\x3a\x72\x65\x73\x6f\x75\x72\x63\x65\x3d\x22\ +\x68\x74\x74\x70\x3a\x2f\x2f\x70\x75\x72\x6c\x2e\x6f\x72\x67\x2f\ +\x64\x63\x2f\x64\x63\x6d\x69\x74\x79\x70\x65\x2f\x53\x74\x69\x6c\ +\x6c\x49\x6d\x61\x67\x65\x22\x20\x2f\x3e\x0a\x20\x20\x20\x20\x20\ +\x20\x3c\x2f\x63\x63\x3a\x57\x6f\x72\x6b\x3e\x0a\x20\x20\x20\x20\ +\x3c\x2f\x72\x64\x66\x3a\x52\x44\x46\x3e\x0a\x20\x20\x3c\x2f\x6d\ +\x65\x74\x61\x64\x61\x74\x61\x3e\x0a\x20\x20\x3c\x64\x65\x66\x73\ +\x0a\x20\x20\x20\x20\x20\x69\x64\x3d\x22\x64\x65\x66\x73\x31\x30\ +\x22\x20\x2f\x3e\x0a\x20\x20\x3c\x73\x6f\x64\x69\x70\x6f\x64\x69\ +\x3a\x6e\x61\x6d\x65\x64\x76\x69\x65\x77\x0a\x20\x20\x20\x20\x20\ +\x70\x61\x67\x65\x63\x6f\x6c\x6f\x72\x3d\x22\x23\x66\x66\x66\x66\ +\x66\x66\x22\x0a\x20\x20\x20\x20\x20\x62\x6f\x72\x64\x65\x72\x63\ +\x6f\x6c\x6f\x72\x3d\x22\x23\x36\x36\x36\x36\x36\x36\x22\x0a\x20\ +\x20\x20\x20\x20\x62\x6f\x72\x64\x65\x72\x6f\x70\x61\x63\x69\x74\ +\x79\x3d\x22\x31\x22\x0a\x20\x20\x20\x20\x20\x6f\x62\x6a\x65\x63\ +\x74\x74\x6f\x6c\x65\x72\x61\x6e\x63\x65\x3d\x22\x31\x30\x22\x0a\ +\x20\x20\x20\x20\x20\x67\x72\x69\x64\x74\x6f\x6c\x65\x72\x61\x6e\ +\x63\x65\x3d\x22\x31\x30\x22\x0a\x20\x20\x20\x20\x20\x67\x75\x69\ +\x64\x65\x74\x6f\x6c\x65\x72\x61\x6e\x63\x65\x3d\x22\x31\x30\x22\ +\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x70\ +\x61\x67\x65\x6f\x70\x61\x63\x69\x74\x79\x3d\x22\x30\x22\x0a\x20\ +\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x70\x61\x67\ +\x65\x73\x68\x61\x64\x6f\x77\x3d\x22\x32\x22\x0a\x20\x20\x20\x20\ +\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x77\x69\x6e\x64\x6f\x77\ +\x2d\x77\x69\x64\x74\x68\x3d\x22\x31\x39\x32\x30\x22\x0a\x20\x20\ +\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x77\x69\x6e\x64\ +\x6f\x77\x2d\x68\x65\x69\x67\x68\x74\x3d\x22\x31\x30\x31\x35\x22\ +\x0a\x20\x20\x20\x20\x20\x69\x64\x3d\x22\x6e\x61\x6d\x65\x64\x76\ +\x69\x65\x77\x38\x22\x0a\x20\x20\x20\x20\x20\x73\x68\x6f\x77\x67\ +\x72\x69\x64\x3d\x22\x66\x61\x6c\x73\x65\x22\x0a\x20\x20\x20\x20\ +\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x7a\x6f\x6f\x6d\x3d\x22\ +\x35\x2e\x39\x35\x38\x33\x33\x33\x33\x22\x0a\x20\x20\x20\x20\x20\ +\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x63\x78\x3d\x22\x35\x31\x2e\ +\x35\x32\x39\x39\x35\x22\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\ +\x63\x61\x70\x65\x3a\x63\x79\x3d\x22\x35\x33\x2e\x31\x36\x38\x33\ +\x32\x38\x22\x0a\x20\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\ +\x65\x3a\x77\x69\x6e\x64\x6f\x77\x2d\x78\x3d\x22\x30\x22\x0a\x20\ +\x20\x20\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x77\x69\x6e\ +\x64\x6f\x77\x2d\x79\x3d\x22\x30\x22\x0a\x20\x20\x20\x20\x20\x69\ +\x6e\x6b\x73\x63\x61\x70\x65\x3a\x77\x69\x6e\x64\x6f\x77\x2d\x6d\ +\x61\x78\x69\x6d\x69\x7a\x65\x64\x3d\x22\x31\x22\x0a\x20\x20\x20\ +\x20\x20\x69\x6e\x6b\x73\x63\x61\x70\x65\x3a\x63\x75\x72\x72\x65\ +\x6e\x74\x2d\x6c\x61\x79\x65\x72\x3d\x22\x73\x76\x67\x36\x22\x20\ +\x2f\x3e\x0a\x20\x20\x3c\x67\x0a\x20\x20\x20\x20\x20\x74\x72\x61\ +\x6e\x73\x66\x6f\x72\x6d\x3d\x22\x74\x72\x61\x6e\x73\x6c\x61\x74\ +\x65\x28\x30\x2e\x30\x30\x30\x30\x30\x30\x2c\x39\x30\x2e\x30\x30\ +\x30\x30\x30\x30\x29\x20\x73\x63\x61\x6c\x65\x28\x30\x2e\x31\x30\ +\x30\x30\x30\x30\x2c\x2d\x30\x2e\x31\x30\x30\x30\x30\x30\x29\x22\ +\x0a\x20\x20\x20\x20\x20\x66\x69\x6c\x6c\x3d\x22\x23\x65\x37\x34\ +\x63\x33\x63\x22\x0a\x20\x20\x20\x20\x20\x73\x74\x72\x6f\x6b\x65\ +\x3d\x22\x6e\x6f\x6e\x65\x22\x0a\x20\x20\x20\x20\x20\x69\x64\x3d\ +\x22\x67\x34\x22\x3e\x0a\x20\x20\x20\x20\x3c\x70\x61\x74\x68\x0a\ +\x20\x20\x20\x20\x20\x20\x20\x64\x3d\x22\x4d\x33\x38\x31\x20\x38\ +\x30\x30\x20\x63\x2d\x31\x34\x34\x20\x2d\x33\x30\x20\x2d\x32\x35\ +\x34\x20\x2d\x31\x34\x35\x20\x2d\x32\x38\x32\x20\x2d\x32\x39\x33\ +\x20\x2d\x34\x32\x20\x2d\x32\x33\x32\x20\x31\x37\x36\x20\x2d\x34\ +\x35\x30\x20\x34\x30\x38\x20\x2d\x34\x30\x38\x20\x32\x33\x30\x20\ +\x34\x33\x20\x33\x36\x31\x20\x32\x38\x31\x20\x32\x37\x30\x20\x34\ +\x39\x33\x20\x2d\x36\x34\x20\x31\x34\x38\x20\x2d\x32\x33\x38\x20\ +\x32\x34\x30\x20\x2d\x33\x39\x36\x20\x32\x30\x38\x7a\x20\x6d\x31\ +\x36\x20\x2d\x32\x35\x32\x20\x6c\x35\x33\x20\x2d\x35\x32\x20\x35\ +\x34\x20\x35\x33\x20\x63\x35\x35\x20\x35\x34\x20\x38\x37\x20\x36\ +\x34\x20\x39\x34\x20\x32\x37\x20\x33\x20\x2d\x31\x32\x20\x2d\x31\ +\x35\x20\x2d\x33\x38\x20\x2d\x34\x39\x20\x2d\x37\x32\x20\x6c\x2d\ +\x35\x33\x20\x2d\x35\x34\x20\x35\x33\x20\x2d\x35\x34\x20\x63\x35\ +\x34\x20\x2d\x35\x35\x20\x36\x34\x20\x2d\x38\x37\x20\x32\x37\x20\ +\x2d\x39\x34\x20\x2d\x31\x32\x20\x2d\x33\x20\x2d\x33\x38\x20\x31\ +\x35\x20\x2d\x37\x32\x20\x34\x39\x20\x6c\x2d\x35\x34\x20\x35\x33\ +\x20\x2d\x35\x34\x20\x2d\x35\x33\x20\x63\x2d\x33\x34\x20\x2d\x33\ +\x34\x20\x2d\x36\x30\x20\x2d\x35\x32\x20\x2d\x37\x32\x20\x2d\x34\ +\x39\x20\x2d\x33\x37\x20\x37\x20\x2d\x32\x37\x20\x33\x39\x20\x32\ +\x37\x20\x39\x34\x20\x6c\x35\x33\x20\x35\x34\x20\x2d\x35\x32\x20\ +\x35\x33\x20\x63\x2d\x34\x39\x20\x34\x39\x20\x2d\x36\x31\x20\x37\ +\x34\x20\x2d\x34\x35\x20\x39\x30\x20\x31\x36\x20\x31\x36\x20\x34\ +\x31\x20\x34\x20\x39\x30\x20\x2d\x34\x35\x7a\x22\x0a\x20\x20\x20\ +\x20\x20\x20\x20\x69\x64\x3d\x22\x70\x61\x74\x68\x32\x22\x0a\x20\ +\x20\x20\x20\x20\x20\x20\x73\x74\x79\x6c\x65\x3d\x22\x66\x69\x6c\ +\x6c\x3a\x23\x66\x66\x66\x66\x66\x66\x22\x20\x2f\x3e\x0a\x20\x20\ +\x3c\x2f\x67\x3e\x0a\x20\x20\x3c\x70\x61\x74\x68\x0a\x20\x20\x20\ +\x20\x20\x73\x74\x79\x6c\x65\x3d\x22\x66\x69\x6c\x6c\x3a\x23\x38\ +\x30\x38\x30\x38\x30\x3b\x73\x74\x72\x6f\x6b\x65\x2d\x77\x69\x64\ +\x74\x68\x3a\x30\x2e\x31\x31\x38\x36\x37\x35\x22\x0a\x20\x20\x20\ +\x20\x20\x64\x3d\x22\x6d\x20\x34\x32\x2e\x36\x32\x32\x36\x31\x2c\ +\x37\x39\x2e\x35\x34\x31\x38\x39\x33\x20\x63\x20\x2d\x33\x2e\x31\ +\x31\x36\x31\x36\x35\x2c\x2d\x31\x2e\x30\x32\x39\x36\x31\x32\x20\ +\x2d\x33\x2e\x30\x35\x38\x38\x2c\x2d\x33\x2e\x33\x36\x36\x38\x30\ +\x38\x20\x30\x2e\x31\x38\x38\x32\x35\x34\x2c\x2d\x37\x2e\x36\x36\ +\x39\x39\x38\x39\x20\x31\x2e\x33\x39\x36\x34\x36\x39\x2c\x2d\x31\ +\x2e\x38\x35\x30\x36\x38\x32\x20\x32\x2e\x37\x31\x36\x37\x39\x39\ +\x2c\x2d\x33\x2e\x32\x38\x37\x33\x38\x37\x20\x37\x2e\x32\x38\x31\ +\x38\x35\x33\x2c\x2d\x37\x2e\x39\x32\x33\x36\x38\x36\x20\x6c\x20\ +\x33\x2e\x38\x38\x34\x39\x33\x33\x2c\x2d\x33\x2e\x39\x34\x35\x35\ +\x36\x32\x20\x2d\x34\x2e\x35\x36\x34\x32\x34\x31\x2c\x2d\x34\x2e\ +\x36\x35\x38\x33\x39\x34\x20\x63\x20\x2d\x36\x2e\x30\x38\x35\x35\ +\x35\x2c\x2d\x36\x2e\x32\x31\x31\x30\x38\x35\x20\x2d\x37\x2e\x34\ +\x33\x39\x37\x30\x39\x2c\x2d\x37\x2e\x37\x37\x35\x39\x33\x31\x20\ +\x2d\x38\x2e\x35\x31\x32\x35\x35\x39\x2c\x2d\x39\x2e\x38\x33\x36\ +\x39\x38\x36\x20\x2d\x31\x2e\x31\x31\x34\x30\x39\x31\x2c\x2d\x32\ +\x2e\x31\x34\x30\x32\x38\x32\x20\x2d\x31\x2e\x30\x33\x35\x30\x35\ +\x33\x2c\x2d\x33\x2e\x35\x35\x33\x39\x36\x34\x20\x30\x2e\x32\x36\ +\x30\x37\x30\x38\x2c\x2d\x34\x2e\x36\x36\x33\x30\x38\x38\x20\x30\ +\x2e\x37\x30\x38\x37\x38\x34\x2c\x2d\x30\x2e\x36\x30\x36\x36\x39\ +\x32\x20\x31\x2e\x35\x38\x35\x35\x37\x34\x2c\x2d\x30\x2e\x38\x34\ +\x37\x31\x20\x32\x2e\x34\x35\x33\x36\x34\x37\x2c\x2d\x30\x2e\x36\ +\x37\x32\x37\x36\x36\x20\x31\x2e\x32\x38\x32\x35\x34\x32\x2c\x30\ +\x2e\x32\x35\x37\x35\x37\x32\x20\x32\x2e\x38\x39\x33\x39\x38\x39\ +\x2c\x31\x2e\x31\x37\x37\x35\x34\x36\x20\x34\x2e\x38\x36\x39\x36\ +\x32\x31\x2c\x32\x2e\x37\x38\x30\x30\x36\x35\x20\x31\x2e\x34\x32\ +\x38\x36\x35\x32\x2c\x31\x2e\x31\x35\x38\x38\x34\x20\x31\x2e\x37\ +\x35\x38\x39\x37\x34\x2c\x31\x2e\x34\x36\x39\x39\x39\x39\x20\x36\ +\x2e\x39\x32\x34\x37\x30\x31\x2c\x36\x2e\x35\x32\x32\x39\x38\x32\ +\x20\x6c\x20\x34\x2e\x35\x39\x38\x33\x32\x33\x2c\x34\x2e\x34\x39\ +\x37\x39\x36\x34\x20\x33\x2e\x39\x34\x36\x32\x39\x36\x2c\x2d\x33\ +\x2e\x38\x38\x35\x36\x35\x35\x20\x63\x20\x35\x2e\x36\x38\x37\x34\ +\x38\x38\x2c\x2d\x35\x2e\x36\x30\x30\x30\x39\x20\x37\x2e\x35\x30\ +\x35\x32\x36\x2c\x2d\x37\x2e\x31\x38\x38\x37\x36\x31\x20\x39\x2e\ +\x38\x33\x34\x37\x30\x31\x2c\x2d\x38\x2e\x35\x39\x35\x31\x39\x37\ +\x20\x30\x2e\x39\x39\x35\x37\x32\x37\x2c\x2d\x30\x2e\x36\x30\x31\ +\x31\x38\x35\x20\x32\x2e\x35\x36\x34\x37\x37\x35\x2c\x2d\x31\x2e\ +\x31\x39\x30\x33\x38\x32\x20\x33\x2e\x31\x36\x36\x37\x30\x39\x2c\ +\x2d\x31\x2e\x31\x38\x39\x31\x33\x38\x20\x31\x2e\x33\x32\x39\x35\ +\x38\x39\x2c\x30\x2e\x30\x30\x32\x37\x20\x32\x2e\x32\x35\x38\x34\ +\x37\x31\x2c\x30\x2e\x39\x32\x38\x31\x32\x36\x20\x32\x2e\x36\x33\ +\x36\x30\x30\x38\x2c\x32\x2e\x36\x32\x36\x30\x36\x33\x20\x30\x2e\ +\x31\x38\x34\x34\x32\x34\x2c\x30\x2e\x38\x32\x39\x34\x33\x34\x20\ +\x30\x2e\x31\x31\x30\x32\x39\x2c\x31\x2e\x32\x34\x30\x37\x37\x36\ +\x20\x2d\x30\x2e\x34\x32\x34\x38\x38\x36\x2c\x32\x2e\x33\x35\x37\ +\x34\x39\x39\x20\x2d\x31\x2e\x31\x30\x34\x36\x39\x2c\x32\x2e\x33\ +\x30\x35\x31\x20\x2d\x32\x2e\x38\x35\x31\x38\x31\x34\x2c\x34\x2e\ +\x33\x30\x32\x35\x34\x34\x20\x2d\x31\x30\x2e\x36\x36\x35\x38\x37\ +\x36\x2c\x31\x32\x2e\x31\x39\x34\x30\x33\x33\x20\x6c\x20\x2d\x32\ +\x2e\x34\x39\x37\x31\x31\x38\x2c\x32\x2e\x35\x32\x31\x38\x36\x31\ +\x20\x32\x2e\x35\x35\x36\x32\x37\x36\x2c\x32\x2e\x35\x38\x31\x31\ +\x37\x35\x20\x63\x20\x37\x2e\x34\x37\x38\x39\x35\x2c\x37\x2e\x35\ +\x35\x31\x37\x39\x39\x20\x39\x2e\x31\x38\x30\x34\x34\x37\x2c\x39\ +\x2e\x35\x33\x33\x37\x30\x39\x20\x31\x30\x2e\x34\x34\x32\x30\x32\ +\x36\x2c\x31\x32\x2e\x31\x36\x32\x39\x31\x32\x20\x31\x2e\x31\x31\ +\x38\x30\x38\x33\x2c\x32\x2e\x33\x33\x30\x31\x34\x37\x20\x30\x2e\ +\x38\x32\x34\x33\x34\x33\x2c\x33\x2e\x37\x33\x32\x36\x33\x31\x20\ +\x2d\x30\x2e\x39\x34\x38\x33\x39\x35\x2c\x34\x2e\x35\x32\x38\x31\ +\x39\x20\x2d\x31\x2e\x34\x36\x38\x33\x31\x37\x2c\x30\x2e\x36\x35\ +\x38\x39\x34\x31\x20\x2d\x32\x2e\x34\x36\x33\x37\x33\x2c\x30\x2e\ +\x35\x31\x37\x39\x35\x38\x20\x2d\x34\x2e\x32\x34\x36\x37\x39\x31\ +\x2c\x2d\x30\x2e\x36\x30\x31\x34\x38\x32\x20\x2d\x32\x2e\x32\x33\ +\x30\x38\x34\x33\x2c\x2d\x31\x2e\x34\x30\x30\x35\x36\x36\x20\x2d\ +\x33\x2e\x31\x39\x35\x34\x35\x2c\x2d\x32\x2e\x32\x35\x33\x34\x37\ +\x20\x2d\x39\x2e\x32\x30\x30\x36\x38\x33\x2c\x2d\x38\x2e\x31\x33\ +\x35\x32\x32\x34\x20\x6c\x20\x2d\x34\x2e\x35\x39\x39\x37\x35\x33\ +\x2c\x2d\x34\x2e\x35\x30\x35\x31\x37\x33\x20\x2d\x34\x2e\x35\x33\ +\x38\x32\x34\x33\x2c\x34\x2e\x34\x33\x37\x39\x32\x31\x20\x63\x20\ +\x2d\x35\x2e\x31\x32\x35\x35\x30\x39\x2c\x35\x2e\x30\x31\x32\x32\ +\x30\x35\x20\x2d\x35\x2e\x34\x33\x36\x38\x35\x34\x2c\x35\x2e\x33\ +\x30\x35\x31\x37\x31\x20\x2d\x36\x2e\x38\x39\x36\x37\x33\x32\x2c\ +\x36\x2e\x34\x38\x39\x36\x31\x39\x20\x2d\x32\x2e\x37\x39\x33\x39\ +\x38\x2c\x32\x2e\x32\x36\x36\x38\x35\x32\x20\x2d\x34\x2e\x35\x36\ +\x39\x38\x35\x35\x2c\x33\x2e\x30\x33\x37\x36\x36\x37\x20\x2d\x35\ +\x2e\x39\x34\x38\x37\x37\x39\x2c\x32\x2e\x35\x38\x32\x30\x35\x36\ +\x20\x7a\x22\x0a\x20\x20\x20\x20\x20\x69\x64\x3d\x22\x70\x61\x74\ +\x68\x31\x36\x22\x0a\x20\x20\x20\x20\x20\x74\x72\x61\x6e\x73\x66\ +\x6f\x72\x6d\x3d\x22\x73\x63\x61\x6c\x65\x28\x30\x2e\x37\x35\x29\ +\x22\x20\x2f\x3e\x0a\x3c\x2f\x73\x76\x67\x3e\x0a\ +" + +qt_resource_name = b"\ +\x00\x04\ +\x00\x07\xa7\xe3\ +\x00\x74\ +\x00\x61\x00\x67\x00\x73\ +\x00\x08\ +\x00\x2b\x61\x2c\ +\x00\x66\ +\x00\x69\x00\x6c\x00\x65\x00\x5f\x00\x75\x00\x72\x00\x6c\ +\x00\x08\ +\x05\xac\x9a\xc4\ +\x00\x69\ +\x00\x6e\x00\x74\x00\x65\x00\x72\x00\x6e\x00\x65\x00\x74\ +\x00\x05\ +\x00\x7b\x88\x98\ +\x00\x74\ +\x00\x72\x00\x61\x00\x73\x00\x68\ +\x00\x08\ +\x0a\xa5\xc4\xc5\ +\x00\x61\ +\x00\x64\x00\x64\x00\x5f\x00\x66\x00\x69\x00\x6c\x00\x65\ +\x00\x0c\ +\x06\x07\x35\x82\ +\x00\x64\ +\x00\x65\x00\x6c\x00\x65\x00\x74\x00\x65\x00\x5f\x00\x68\x00\x6f\x00\x76\x00\x65\x00\x72\ +\x00\x06\ +\x06\xac\x2c\xa5\ +\x00\x64\ +\x00\x65\x00\x6c\x00\x65\x00\x74\x00\x65\ +" + +qt_resource_struct_v1 = b"\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x02\x00\x00\x00\x01\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x02\x00\x00\x00\x06\ +\x00\x00\x00\x0e\x00\x02\x00\x00\x00\x03\x00\x00\x00\x03\ +\x00\x00\x00\x3a\x00\x00\x00\x00\x00\x01\x00\x00\x09\xc5\ +\x00\x00\x00\x24\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ +\x00\x00\x00\x4a\x00\x00\x00\x00\x00\x01\x00\x00\x0d\xc4\ +\x00\x00\x00\x60\x00\x00\x00\x00\x00\x01\x00\x00\x16\x85\ +\x00\x00\x00\x7e\x00\x00\x00\x00\x00\x01\x00\x00\x24\x1b\ +" + +qt_resource_struct_v2 = b"\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x02\x00\x00\x00\x01\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x02\x00\x00\x00\x06\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x0e\x00\x02\x00\x00\x00\x03\x00\x00\x00\x03\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x3a\x00\x00\x00\x00\x00\x01\x00\x00\x09\xc5\ +\x00\x00\x01\x76\xa5\x2b\x29\x70\ +\x00\x00\x00\x24\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ +\x00\x00\x01\x76\xde\x95\xb2\x06\ +\x00\x00\x00\x4a\x00\x00\x00\x00\x00\x01\x00\x00\x0d\xc4\ +\x00\x00\x01\x76\xf6\x20\x78\x70\ +\x00\x00\x00\x60\x00\x00\x00\x00\x00\x01\x00\x00\x16\x85\ +\x00\x00\x01\x76\xd8\xe5\x6d\x99\ +\x00\x00\x00\x7e\x00\x00\x00\x00\x00\x01\x00\x00\x24\x1b\ +\x00\x00\x01\x76\xd8\xe2\x94\x13\ +" + +qt_version = [int(v) for v in QtCore.qVersion().split('.')] +if qt_version < [5, 8, 0]: + rcc_version = 1 + qt_resource_struct = qt_resource_struct_v1 +else: + rcc_version = 2 + qt_resource_struct = qt_resource_struct_v2 + +def qInitResources(): + QtCore.qRegisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) + +def qCleanupResources(): + QtCore.qUnregisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) + +qInitResources() diff --git a/athnos/window/window.py b/athnos/window/window.py new file mode 100755 index 0000000..873b60b --- /dev/null +++ b/athnos/window/window.py @@ -0,0 +1,1066 @@ +from PyQt5 import QtWidgets, QtSql, QtCore, QtGui +from ..database import Clip, Database, Type +from pathlib import Path +from zipfile import ZipFile, ZIP_DEFLATED +import shutil +import os +from .. import utils as utils +from .. import config, logger +from .source import EditSource, ShowSource +from .add import AddClip, AddSource +from .connect import Connect +from . import _Window +import json +from .QCustomObject import QTagEdit, QTableSortFilterProxyModel +import re +from . import resources + +# the database, global available +_database = None + + +class Window(_Window): + """The main window class""" + + default_driver = "QSQLITE" + default_name = str(Path().absolute().joinpath('athnos.sqlite3')) + default_user = "root" + default_password = "" + default_ip = "127.0.0.1" + default_port = 3306 + + def __init__(self, application: QtWidgets.QApplication, main_window: QtWidgets.QMainWindow = None, + driver=default_driver, name=default_name, user=default_user, password=default_password, + ip=default_ip, port=default_port): + """ + Setup the main window and database connection + + Args: + main_window: main window + driver: The sql driver. See `connect.Connect.drivers` for all supported drivers + name: Name of the database, or path to the database if it's stored in a file + user: SQL username + password: SQL password + ip: IP of the database + port: Port of the database + """ + global _database + + self.application = application + + if not main_window: + main_window = QtWidgets.QMainWindow() + + # setup all internal variables + self._id = 0 + self._clip: Clip = None + self._row_number = -1 + self._all_ids = [] + self._columns = {} + + # passing all given arguments to class wide arguments + self.db = QtSql.QSqlDatabase(driver) + if name: + self.db.setDatabaseName(name) + if user: + self.db.setUserName(user) + if password: + self.db.setPassword(password) + if ip: + self.db.setHostName(ip) + if port: + self.db.setPort(port) + + # tries to open a database connection + if not self.db.open(): + raise IOError(self.db.lastError().text()) + + # setup all tables + _database = Database(self.db) + self.database = _database + + # the default search filter rules (Tag, Name, Description, Type) + self.filter_rules = [True, True, False, True] + + # all filter items + self.filter_items_lower = ('tag', 'name', 'description', 'type') + + # the model where all the data will be stored in + self.model: QTableSortFilterProxyModel = None + + super().__init__(main_window) + + # on startup + self.load_model() + self.on_filter_box_change(None) + + def setupUi(self, MainWindow): + MainWindow.setObjectName("MainWindow") + MainWindow.resize(923, 684) + MainWindow.setTabShape(QtWidgets.QTabWidget.Rounded) + self.centralwidget = QtWidgets.QWidget(MainWindow) + self.centralwidget.setObjectName("centralwidget") + self.verticalLayout = QtWidgets.QVBoxLayout(self.centralwidget) + self.verticalLayout.setObjectName("verticalLayout") + self.main_splitter = QtWidgets.QSplitter(self.centralwidget) + self.main_splitter.setEnabled(True) + self.main_splitter.setOrientation(QtCore.Qt.Horizontal) + self.main_splitter.setOpaqueResize(True) + self.main_splitter.setObjectName("main_splitter") + self.verticalLayoutWidget = QtWidgets.QWidget(self.main_splitter) + self.verticalLayoutWidget.setObjectName("verticalLayoutWidget") + self.search_vlayout = QtWidgets.QVBoxLayout(self.verticalLayoutWidget) + self.search_vlayout.setContentsMargins(0, 0, 0, 0) + self.search_vlayout.setObjectName("search_vlayout") + self.clip_hlayout = QtWidgets.QGroupBox(self.verticalLayoutWidget) + self.clip_hlayout.setStyleSheet("QGroupBox {border: 1px solid grey; margin-top: 0.5em; font: 12px consolas;} QGroupBox::title {top: -6px; left: 10px}") + self.clip_hlayout.setObjectName("clip_hlayout") + self.verticalLayout_6 = QtWidgets.QVBoxLayout(self.clip_hlayout) + self.verticalLayout_6.setObjectName("verticalLayout_6") + spacerItem = QtWidgets.QSpacerItem(20, 5, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) + self.verticalLayout_6.addItem(spacerItem) + self.search_box_hlayout = QtWidgets.QHBoxLayout() + self.search_box_hlayout.setObjectName("search_box_hlayout") + self.search_input = QtWidgets.QLineEdit(self.clip_hlayout) + self.search_input.setPlaceholderText("") + self.search_input.setClearButtonEnabled(True) + self.search_input.setObjectName("search_input") + self.search_box_hlayout.addWidget(self.search_input) + self.filter_box = QtWidgets.QComboBox(self.clip_hlayout) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.filter_box.sizePolicy().hasHeightForWidth()) + self.filter_box.setSizePolicy(sizePolicy) + self.filter_box.setCurrentText("") + self.filter_box.setObjectName("filter_box") + self.search_box_hlayout.addWidget(self.filter_box) + self.verticalLayout_6.addLayout(self.search_box_hlayout) + self.clip_view = QtWidgets.QTableView(self.clip_hlayout) + self.clip_view.setContextMenuPolicy(QtCore.Qt.DefaultContextMenu) + self.clip_view.setFrameShape(QtWidgets.QFrame.StyledPanel) + self.clip_view.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustToContents) + self.clip_view.setAutoScroll(True) + self.clip_view.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + self.clip_view.setDragEnabled(False) + self.clip_view.setAlternatingRowColors(True) + self.clip_view.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + self.clip_view.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + self.clip_view.setSortingEnabled(True) + self.clip_view.setObjectName("clip_view") + self.clip_view.horizontalHeader().setSortIndicatorShown(True) + self.verticalLayout_6.addWidget(self.clip_view) + self.search_vlayout.addWidget(self.clip_hlayout) + self.clip_source_splitter = QtWidgets.QSplitter(self.main_splitter) + self.clip_source_splitter.setEnabled(True) + self.clip_source_splitter.setOrientation(QtCore.Qt.Vertical) + self.clip_source_splitter.setObjectName("clip_source_splitter") + self.clip_group_box = QtWidgets.QGroupBox(self.clip_source_splitter) + self.clip_group_box.setEnabled(True) + self.clip_group_box.setStyleSheet("QGroupBox {border: 1px solid grey; margin-top: 0.5em; font: 12px consolas;} QGroupBox::title {top: -6px; left: 10px}") + self.clip_group_box.setObjectName("clip_group_box") + self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.clip_group_box) + self.verticalLayout_3.setObjectName("verticalLayout_3") + spacerItem1 = QtWidgets.QSpacerItem(20, 5, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) + self.verticalLayout_3.addItem(spacerItem1) + self.file_url_hlayout = QtWidgets.QHBoxLayout() + self.file_url_hlayout.setObjectName("file_url_hlayout") + self.file_url_label = QtWidgets.QLabel(self.clip_group_box) + self.file_url_label.setMinimumSize(QtCore.QSize(70, 0)) + self.file_url_label.setObjectName("file_url_label") + self.file_url_hlayout.addWidget(self.file_url_label) + self.file_url_edit = QtWidgets.QLineEdit(self.clip_group_box) + self.file_url_edit.setObjectName("file_url_edit") + self.file_url_hlayout.addWidget(self.file_url_edit) + self.verticalLayout_3.addLayout(self.file_url_hlayout) + self.extended_view_clip_line = QtWidgets.QFrame(self.clip_group_box) + self.extended_view_clip_line.setFrameShape(QtWidgets.QFrame.HLine) + self.extended_view_clip_line.setFrameShadow(QtWidgets.QFrame.Sunken) + self.extended_view_clip_line.setObjectName("extended_view_clip_line") + self.verticalLayout_3.addWidget(self.extended_view_clip_line) + self.name_hlayout = QtWidgets.QHBoxLayout() + self.name_hlayout.setSizeConstraint(QtWidgets.QLayout.SetDefaultConstraint) + self.name_hlayout.setObjectName("name_hlayout") + self.name_label = QtWidgets.QLabel(self.clip_group_box) + self.name_label.setEnabled(True) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Maximum) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.name_label.sizePolicy().hasHeightForWidth()) + self.name_label.setSizePolicy(sizePolicy) + self.name_label.setMinimumSize(QtCore.QSize(70, 0)) + self.name_label.setObjectName("name_label") + self.name_hlayout.addWidget(self.name_label) + self.name_edit = QtWidgets.QLineEdit(self.clip_group_box) + self.name_edit.setEnabled(True) + self.name_edit.setReadOnly(False) + self.name_edit.setObjectName("name_edit") + self.name_hlayout.addWidget(self.name_edit) + self.verticalLayout_3.addLayout(self.name_hlayout) + self.description_hlayout = QtWidgets.QHBoxLayout() + self.description_hlayout.setObjectName("description_hlayout") + self.description_label = QtWidgets.QLabel(self.clip_group_box) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.description_label.sizePolicy().hasHeightForWidth()) + self.description_label.setSizePolicy(sizePolicy) + self.description_label.setMinimumSize(QtCore.QSize(70, 0)) + self.description_label.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) + self.description_label.setWordWrap(False) + self.description_label.setOpenExternalLinks(False) + self.description_label.setObjectName("description_label") + self.description_hlayout.addWidget(self.description_label) + self.description_edit = QtWidgets.QPlainTextEdit(self.clip_group_box) + self.description_edit.setEnabled(True) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.description_edit.sizePolicy().hasHeightForWidth()) + self.description_edit.setSizePolicy(sizePolicy) + self.description_edit.setReadOnly(False) + self.description_edit.setObjectName("description_edit") + self.description_hlayout.addWidget(self.description_edit) + self.verticalLayout_3.addLayout(self.description_hlayout) + self.line = QtWidgets.QFrame(self.clip_group_box) + self.line.setFrameShape(QtWidgets.QFrame.HLine) + self.line.setFrameShadow(QtWidgets.QFrame.Sunken) + self.line.setObjectName("line") + self.verticalLayout_3.addWidget(self.line) + self.type_hlayout = QtWidgets.QHBoxLayout() + self.type_hlayout.setSizeConstraint(QtWidgets.QLayout.SetDefaultConstraint) + self.type_hlayout.setObjectName("type_hlayout") + self.type_label = QtWidgets.QLabel(self.clip_group_box) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.type_label.sizePolicy().hasHeightForWidth()) + self.type_label.setSizePolicy(sizePolicy) + self.type_label.setMinimumSize(QtCore.QSize(70, 0)) + self.type_label.setObjectName("type_label") + self.type_hlayout.addWidget(self.type_label) + self.type_box = QtWidgets.QComboBox(self.clip_group_box) + self.type_box.setEnabled(True) + self.type_box.setEditable(False) + self.type_box.setDuplicatesEnabled(False) + self.type_box.setProperty("placeholderText", "") + self.type_box.setObjectName("type_box") + self.type_box.addItem("") + self.type_box.addItem("") + self.type_box.addItem("") + self.type_box.addItem("") + self.type_hlayout.addWidget(self.type_box) + self.verticalLayout_3.addLayout(self.type_hlayout) + self.tags_hlayout = QtWidgets.QHBoxLayout() + self.tags_hlayout.setObjectName("tags_hlayout") + self.tags_label = QtWidgets.QLabel(self.clip_group_box) + self.tags_label.setMinimumSize(QtCore.QSize(70, 0)) + self.tags_label.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) + self.tags_label.setObjectName("tags_label") + self.tags_hlayout.addWidget(self.tags_label) + self.tags_edit = QtWidgets.QScrollArea(self.clip_group_box) + self.tags_edit.setEnabled(True) + self.tags_edit.setWidgetResizable(True) + self.tags_edit.setObjectName("tags_edit") + self.scrollAreaWidgetContents = QtWidgets.QWidget() + self.scrollAreaWidgetContents.setGeometry(QtCore.QRect(0, 0, 483, 83)) + self.scrollAreaWidgetContents.setObjectName("scrollAreaWidgetContents") + self.tags_edit.setWidget(self.scrollAreaWidgetContents) + self.tags_hlayout.addWidget(self.tags_edit) + self.verticalLayout_3.addLayout(self.tags_hlayout) + self.editing_hlayout = QtWidgets.QHBoxLayout() + self.editing_hlayout.setObjectName("editing_hlayout") + self.save_button = QtWidgets.QPushButton(self.clip_group_box) + self.save_button.setEnabled(True) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.save_button.sizePolicy().hasHeightForWidth()) + self.save_button.setSizePolicy(sizePolicy) + self.save_button.setObjectName("save_button") + self.editing_hlayout.addWidget(self.save_button) + spacerItem2 = QtWidgets.QSpacerItem(15, 20, QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Minimum) + self.editing_hlayout.addItem(spacerItem2) + self.enable_editing_check_box = QtWidgets.QCheckBox(self.clip_group_box) + self.enable_editing_check_box.setEnabled(True) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.enable_editing_check_box.sizePolicy().hasHeightForWidth()) + self.enable_editing_check_box.setSizePolicy(sizePolicy) + self.enable_editing_check_box.setObjectName("enable_editing_check_box") + self.editing_hlayout.addWidget(self.enable_editing_check_box) + spacerItem3 = QtWidgets.QSpacerItem(15, 20, QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Minimum) + self.editing_hlayout.addItem(spacerItem3) + self.delete_button = QtWidgets.QPushButton(self.clip_group_box) + self.delete_button.setEnabled(True) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.delete_button.sizePolicy().hasHeightForWidth()) + self.delete_button.setSizePolicy(sizePolicy) + self.delete_button.setCheckable(False) + self.delete_button.setChecked(False) + self.delete_button.setObjectName("delete_button") + self.editing_hlayout.addWidget(self.delete_button) + self.verticalLayout_3.addLayout(self.editing_hlayout) + self.source_group_box = QtWidgets.QGroupBox(self.clip_source_splitter) + self.source_group_box.setStyleSheet("QGroupBox {border: 1px solid grey; margin-top: 0.5em; font: 12px consolas;} QGroupBox::title {top: -6px; left: 10px}") + self.source_group_box.setObjectName("source_group_box") + self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.source_group_box) + self.verticalLayout_2.setObjectName("verticalLayout_2") + spacerItem4 = QtWidgets.QSpacerItem(20, 5, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) + self.verticalLayout_2.addItem(spacerItem4) + self.from_hlayout = QtWidgets.QHBoxLayout() + self.from_hlayout.setObjectName("from_hlayout") + self.from_label = QtWidgets.QLabel(self.source_group_box) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Maximum) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.from_label.sizePolicy().hasHeightForWidth()) + self.from_label.setSizePolicy(sizePolicy) + self.from_label.setObjectName("from_label") + self.from_hlayout.addWidget(self.from_label) + self.source_name = QtWidgets.QLineEdit(self.source_group_box) + self.source_name.setEnabled(True) + self.source_name.setText("") + self.source_name.setReadOnly(False) + self.source_name.setObjectName("source_name") + self.from_hlayout.addWidget(self.source_name) + spacerItem5 = QtWidgets.QSpacerItem(7, 20, QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Minimum) + self.from_hlayout.addItem(spacerItem5) + self.season_label = QtWidgets.QLabel(self.source_group_box) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Maximum) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.season_label.sizePolicy().hasHeightForWidth()) + self.season_label.setSizePolicy(sizePolicy) + self.season_label.setObjectName("season_label") + self.from_hlayout.addWidget(self.season_label) + self.source_season = QtWidgets.QLineEdit(self.source_group_box) + self.source_season.setEnabled(True) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.source_season.sizePolicy().hasHeightForWidth()) + self.source_season.setSizePolicy(sizePolicy) + self.source_season.setMaximumSize(QtCore.QSize(50, 16777215)) + self.source_season.setBaseSize(QtCore.QSize(0, 0)) + self.source_season.setReadOnly(False) + self.source_season.setObjectName("source_season") + self.from_hlayout.addWidget(self.source_season) + spacerItem6 = QtWidgets.QSpacerItem(7, 20, QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Minimum) + self.from_hlayout.addItem(spacerItem6) + self.episode_label = QtWidgets.QLabel(self.source_group_box) + self.episode_label.setObjectName("episode_label") + self.from_hlayout.addWidget(self.episode_label) + self.source_episode = QtWidgets.QLineEdit(self.source_group_box) + self.source_episode.setEnabled(True) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.source_episode.sizePolicy().hasHeightForWidth()) + self.source_episode.setSizePolicy(sizePolicy) + self.source_episode.setMaximumSize(QtCore.QSize(50, 16777215)) + self.source_episode.setBaseSize(QtCore.QSize(0, 0)) + self.source_episode.setReadOnly(False) + self.source_episode.setObjectName("source_episode") + self.from_hlayout.addWidget(self.source_episode) + self.verticalLayout_2.addLayout(self.from_hlayout) + self.source_descriptio_hlayout = QtWidgets.QHBoxLayout() + self.source_descriptio_hlayout.setObjectName("source_descriptio_hlayout") + self.source_description_label = QtWidgets.QLabel(self.source_group_box) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.source_description_label.sizePolicy().hasHeightForWidth()) + self.source_description_label.setSizePolicy(sizePolicy) + self.source_description_label.setMinimumSize(QtCore.QSize(70, 0)) + self.source_description_label.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) + self.source_description_label.setObjectName("source_description_label") + self.source_descriptio_hlayout.addWidget(self.source_description_label) + self.source_description_edit = QtWidgets.QPlainTextEdit(self.source_group_box) + self.source_description_edit.setEnabled(True) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.source_description_edit.sizePolicy().hasHeightForWidth()) + self.source_description_edit.setSizePolicy(sizePolicy) + self.source_description_edit.setObjectName("source_description_edit") + self.source_descriptio_hlayout.addWidget(self.source_description_edit) + self.verticalLayout_2.addLayout(self.source_descriptio_hlayout) + self.source_type_hlayout = QtWidgets.QHBoxLayout() + self.source_type_hlayout.setObjectName("source_type_hlayout") + self.source_type_label = QtWidgets.QLabel(self.source_group_box) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.source_type_label.sizePolicy().hasHeightForWidth()) + self.source_type_label.setSizePolicy(sizePolicy) + self.source_type_label.setMinimumSize(QtCore.QSize(70, 0)) + self.source_type_label.setObjectName("source_type_label") + self.source_type_hlayout.addWidget(self.source_type_label) + self.source_type_box = QtWidgets.QComboBox(self.source_group_box) + self.source_type_box.setObjectName("source_type_box") + self.source_type_box.addItem("") + self.source_type_box.addItem("") + self.source_type_box.addItem("") + self.source_type_box.addItem("") + self.source_type_hlayout.addWidget(self.source_type_box) + self.verticalLayout_2.addLayout(self.source_type_hlayout) + self.edit_change_hlayout = QtWidgets.QHBoxLayout() + self.edit_change_hlayout.setObjectName("edit_change_hlayout") + self.edit_source_button = QtWidgets.QPushButton(self.source_group_box) + self.edit_source_button.setEnabled(True) + self.edit_source_button.setObjectName("edit_source_button") + self.edit_change_hlayout.addWidget(self.edit_source_button) + self.change_source_button = QtWidgets.QPushButton(self.source_group_box) + self.change_source_button.setEnabled(True) + self.change_source_button.setObjectName("change_source_button") + self.edit_change_hlayout.addWidget(self.change_source_button) + self.verticalLayout_2.addLayout(self.edit_change_hlayout) + self.verticalLayout.addWidget(self.main_splitter) + MainWindow.setCentralWidget(self.centralwidget) + self.menubar = QtWidgets.QMenuBar(MainWindow) + self.menubar.setGeometry(QtCore.QRect(0, 0, 923, 30)) + self.menubar.setObjectName("menubar") + self.settings_menu = QtWidgets.QMenu(self.menubar) + self.settings_menu.setObjectName("settings_menu") + self.style_menu = QtWidgets.QMenu(self.settings_menu) + self.style_menu.setObjectName("style_menu") + self.update_menu = QtWidgets.QMenu(self.settings_menu) + self.update_menu.setObjectName("update_menu") + self.update_message_menu = QtWidgets.QMenu(self.update_menu) + self.update_message_menu.setObjectName("update_message_menu") + self.menuHelp = QtWidgets.QMenu(self.menubar) + self.menuHelp.setObjectName("menuHelp") + self.file_menu = QtWidgets.QMenu(self.menubar) + self.file_menu.setObjectName("file_menu") + MainWindow.setMenuBar(self.menubar) + self.connect_to_database = QtWidgets.QAction(MainWindow) + self.connect_to_database.setObjectName("connect_to_database") + self.check_for_updates_on_startup = QtWidgets.QAction(MainWindow) + self.check_for_updates_on_startup.setCheckable(True) + self.check_for_updates_on_startup.setChecked(True) + self.check_for_updates_on_startup.setObjectName("check_for_updates_on_startup") + self.check_for_updates = QtWidgets.QAction(MainWindow) + self.check_for_updates.setObjectName("check_for_updates") + self.on_major_update = QtWidgets.QAction(MainWindow) + self.on_major_update.setCheckable(True) + self.on_major_update.setObjectName("on_major_update") + self.on_minor_update = QtWidgets.QAction(MainWindow) + self.on_minor_update.setCheckable(True) + self.on_minor_update.setChecked(True) + self.on_minor_update.setObjectName("on_minor_update") + self.on_patch = QtWidgets.QAction(MainWindow) + self.on_patch.setCheckable(True) + self.on_patch.setObjectName("on_patch") + self.about = QtWidgets.QAction(MainWindow) + self.about.setObjectName("about") + self.file_import = QtWidgets.QAction(MainWindow) + self.file_import.setObjectName("file_import") + self.file_export = QtWidgets.QAction(MainWindow) + self.file_export.setObjectName("file_export") + self.close_window = QtWidgets.QAction(MainWindow) + self.close_window.setObjectName("close_window") + self.add_clip = QtWidgets.QAction(MainWindow) + self.add_clip.setObjectName("add_clip") + self.add_source = QtWidgets.QAction(MainWindow) + self.add_source.setObjectName("add_source") + self.update_message_menu.addAction(self.on_major_update) + self.update_message_menu.addAction(self.on_minor_update) + self.update_message_menu.addAction(self.on_patch) + self.update_menu.addAction(self.check_for_updates_on_startup) + self.update_menu.addSeparator() + self.update_menu.addAction(self.update_message_menu.menuAction()) + self.update_menu.addAction(self.check_for_updates) + self.settings_menu.addAction(self.connect_to_database) + self.settings_menu.addSeparator() + self.settings_menu.addAction(self.style_menu.menuAction()) + self.settings_menu.addAction(self.update_menu.menuAction()) + self.menuHelp.addAction(self.about) + self.file_menu.addAction(self.file_import) + self.file_menu.addAction(self.file_export) + self.file_menu.addSeparator() + self.file_menu.addAction(self.add_clip) + self.file_menu.addAction(self.add_source) + self.file_menu.addSeparator() + self.file_menu.addAction(self.close_window) + self.menubar.addAction(self.file_menu.menuAction()) + self.menubar.addAction(self.settings_menu.menuAction()) + self.menubar.addAction(self.menuHelp.menuAction()) + + self.retranslateUi(MainWindow) + self.type_box.setCurrentIndex(3) + self.source_type_box.setCurrentIndex(3) + QtCore.QMetaObject.connectSlotsByName(MainWindow) + + def retranslateUi(self, MainWindow): + _translate = QtCore.QCoreApplication.translate + MainWindow.setWindowTitle(_translate("MainWindow", "Athnos")) + self.clip_hlayout.setTitle(_translate("MainWindow", "Search")) + self.clip_group_box.setTitle(_translate("MainWindow", "Clip")) + self.file_url_label.setText(_translate("MainWindow", "File / URL")) + self.name_label.setText(_translate("MainWindow", "Name")) + self.description_label.setText(_translate("MainWindow", "Description")) + self.type_label.setText(_translate("MainWindow", "Type")) + self.type_box.setCurrentText(_translate("MainWindow", "Other")) + self.type_box.setItemText(0, _translate("MainWindow", "Audio")) + self.type_box.setItemText(1, _translate("MainWindow", "Image")) + self.type_box.setItemText(2, _translate("MainWindow", "Video")) + self.type_box.setItemText(3, _translate("MainWindow", "Other")) + self.tags_label.setText(_translate("MainWindow", "Tags")) + self.save_button.setText(_translate("MainWindow", "Save")) + self.enable_editing_check_box.setText(_translate("MainWindow", "Enable editing")) + self.delete_button.setText(_translate("MainWindow", "Delete")) + self.source_group_box.setTitle(_translate("MainWindow", "Source")) + self.from_label.setText(_translate("MainWindow", "From")) + self.season_label.setText(_translate("MainWindow", "Season")) + self.episode_label.setText(_translate("MainWindow", "Episode")) + self.source_description_label.setText(_translate("MainWindow", "Description")) + self.source_type_label.setText(_translate("MainWindow", "Type")) + self.source_type_box.setItemText(0, _translate("MainWindow", "Audio")) + self.source_type_box.setItemText(1, _translate("MainWindow", "Movie")) + self.source_type_box.setItemText(2, _translate("MainWindow", "Series")) + self.source_type_box.setItemText(3, _translate("MainWindow", "Other")) + self.edit_source_button.setText(_translate("MainWindow", "Edit source")) + self.change_source_button.setText(_translate("MainWindow", "Change source")) + self.settings_menu.setTitle(_translate("MainWindow", "Settings")) + self.style_menu.setTitle(_translate("MainWindow", "Style")) + self.update_menu.setTitle(_translate("MainWindow", "Updates")) + self.update_message_menu.setTitle(_translate("MainWindow", "Update message")) + self.menuHelp.setTitle(_translate("MainWindow", "Help")) + self.file_menu.setTitle(_translate("MainWindow", "File")) + self.connect_to_database.setText(_translate("MainWindow", "Connect to database...")) + self.check_for_updates_on_startup.setText(_translate("MainWindow", "Check for updates on startup")) + self.check_for_updates.setText(_translate("MainWindow", "Check for updates...")) + self.on_major_update.setText(_translate("MainWindow", "On major update")) + self.on_minor_update.setText(_translate("MainWindow", "On minor update")) + self.on_patch.setText(_translate("MainWindow", "On patch")) + self.about.setText(_translate("MainWindow", "About")) + self.file_import.setText(_translate("MainWindow", "Import...")) + self.file_export.setText(_translate("MainWindow", "Export...")) + self.close_window.setText(_translate("MainWindow", "Close")) + self.add_clip.setText(_translate("MainWindow", "Add clip...")) + self.add_source.setText(_translate("MainWindow", "Add source...")) + + def after_setup_ui(self): + # --- add menu --- + + # » add clip + self.add_clip.triggered.connect(lambda: AddClip(QtWidgets.QDialog(self.window, QtCore.Qt.WindowSystemMenuHint)).show()) + # » add source + self.add_source.triggered.connect(lambda: AddSource(QtWidgets.QDialog(self.window, QtCore.Qt.WindowSystemMenuHint)).show()) + # » import + from .importexport import Import + self.file_import.triggered.connect(lambda: Import(self)) + + # » close + self.close_window.triggered.connect(self.close) + + # --- settings menu --- + + # » connect to database + self.connect_to_database.triggered.connect(lambda: self.set_database(Connect(QtWidgets.QDialog(self.window, QtCore.Qt.WindowSystemMenuHint)).show())) + # » set style + for i, style in enumerate(QtWidgets.QStyleFactory.keys()): + style_item = QtWidgets.QAction(style, self.window) + style_item.setCheckable(True) + if style.lower() == QtWidgets.QApplication.style().objectName().lower(): + style_item.setChecked(True) + style_item.triggered.connect(lambda checked, style=style_item: self.on_new_style(checked, style)) + self.style_menu.addAction(style_item) + + # --- search input --- + + # » connects `self.on_search_input_change` if the text changes + self.search_input.textChanged.connect(self.on_search_input_change) + + # --- filter box --- + + # » create the filter box model + self.filter_box_model = QtGui.QStandardItemModel() + # » loop through the `filter_box_items` and add them to the model + for i, item_name in enumerate(self.filter_items_lower): + item_name = item_name[0].upper() + item_name[1:] + item = QtGui.QStandardItem(item_name) + # »» make the item checkable + item.setFlags(QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled) + item.setData(QtCore.Qt.Unchecked, QtCore.Qt.CheckStateRole) + if self.filter_rules[i]: + item.setCheckState(QtCore.Qt.Checked) + else: + item.setCheckState(QtCore.Qt.Unchecked) + # »» add the item to the model + self.filter_box_model.setItem(i, 0, item) + + # » apply the filter box model to the box and do some style stuff + self.filter_box.setModel(self.filter_box_model) + self.filter_box.setStyleSheet('QAbstractItemView {min-width: 120px;}') + + # » everytime a item is selected or unselected `self.on_filter_box_change` gets called + self.filter_box_model.dataChanged.connect(self.on_filter_box_change) + + # » set the line edit (text) of the filter box. + # » normally it's the current selected item text, but i want it to show 'Filter...' + filter_box_edit = QtWidgets.QLineEdit('Filter...') + filter_box_edit.setReadOnly(True) + # » if the 'Apply changes' button is pressed, the filter box text will be still 'Filter...' + filter_box_edit.textChanged.connect(lambda a0: filter_box_edit.setText('Filter...')) + # » if the text edit and not the dropdown arrow is pressed, the dropdown menu will get show anyway + filter_box_edit.mouseReleaseEvent = lambda a0: self.filter_box.showPopup() + self.filter_box.setLineEdit(filter_box_edit) + + # --- item / clip view --- + self.clip_view.clicked.connect(self.on_item_clicked) + + # --- clip / source --- + action = QtWidgets.QAction() + action.eventFilter = self.open_file_or_link_event_filter + action.setIcon(QtGui.QIcon(':/file_url/internet')) + + self.file_url_edit.addAction(action, QtWidgets.QLineEdit.TrailingPosition) + + self.enable_editing_check_box.clicked.connect(lambda value: self.set_extended_clip_view(value)) + self.save_button.clicked.connect(self.on_save_edited_clip) + + self.type_box.setCurrentIndex(-1) + + # deletes the old tags edit scroll area + self.tags_edit.deleteLater() + # overwrites `self.tags_edit` and use a `QCustomObject.QTagEdit` instead + self.tags_edit = QTagEdit() + # configures the tage edit + self.tags_edit.setEnabled(False) + self.tags_edit.enableTagSuggestions(True) + self.tags_hlayout.addWidget(self.tags_edit) + + self.edit_source_button.clicked.connect(self.on_edit_source) + self.change_source_button.clicked.connect(self.on_change_source) + + # search input + self.search_input.textChanged.connect(self.on_search_input_change) + + # before startup + self.set_extended_clip_view() + self.set_extended_source_view() + self.clip_source_splitter.setEnabled(False) + + def load_model(self) -> None: + """Creates / loads the `self.model` for the clip view""" + # all currently available clips, tags, clip names, clip descriptions and clip types + self.all_clips = self.database.Clip.get_all() + self.all_tag_names = [tag.name for tag in self.database.Tag.get_all()] + self.all_clip_names = [] + self.all_description_texts = [] + for clip in self.all_clips: + self.all_clip_names.append(clip.name) + self.all_description_texts.append(clip.description) + self.all_type_names = [type.name for type in Type.Clip] + + self.all_names = [self.all_tag_names, self.all_clip_names, self.all_description_texts, self.all_type_names] + + self.tags_edit.setTagSuggestions(self.all_tag_names) + + # creates the sql model and configures it + sql_model = QtSql.QSqlTableModel() + sql_model.setQuery(self.all_clips.query) + # sets the models query + sql_model.setEditStrategy(QtSql.QSqlTableModel.OnManualSubmit) + + # creates a `QCustomObject.QTableSortFilterProxyModel` out of the `sql_model` to filter via the `self.line_input` + model = QTableSortFilterProxyModel(True) + model.setSourceModel(sql_model) + + # sets the clip view model and configures the headers + self.clip_view.setModel(model) + self.clip_view.verticalHeader().setHidden(True) + self.clip_view.horizontalHeader().resizeSections(QtWidgets.QHeaderView.Stretch) + self.clip_view.horizontalHeader().setStretchLastSection(True) + + type_column = -1 + + # this sets the header (first letter uppercase, remove id, etc.) + for i in range(model.columnCount()): + header = str(model.headerData(i, QtCore.Qt.Horizontal)).lower() + self._columns[header] = i + # hides the column if its in source_id, id or path + if header in ['source_id', 'id', 'path']: + if header == 'id': + for row in range(model.rowCount()): + self._all_ids.append(int(model.index(row, i).data())) + self.clip_view.hideColumn(i) + continue + elif header == 'type': + type_column = i + # makes the first letter of the header text uppercase + model.setHeaderData(i, QtCore.Qt.Horizontal, header[0].upper() + header[1:]) + + # creates a new column for the tags + tags_column = model.columnCount() + model.insertColumn(tags_column) + model.setHeaderData(tags_column, QtCore.Qt.Horizontal, 'tags') + # hides the column + self.clip_view.hideColumn(tags_column) + + for row in range(model.rowCount()): + # convert the type numbers (type is stored as a number in the sql table) to text + type_index = model.index(row, type_column) + type_name = Type.Clip(model.data(type_index)).name.lower() + model.setData(type_index, type_name[0].upper() + type_name[1:]) + + # adds the corresponding tags to the index + tags_index = model.index(row, tags_column) + model.setData(tags_index, ', '.join(tag.name for tag in self.all_clips[row].get_tags())) + + # makes the model available for the entire class + self.model = model + + # self.clip_view.setShowGrid(False) + + def set_database(self, database: Database) -> None: + """Sets the local and global database""" + if database is not None: + # global database + _database = database + # local database + self.database = database + self.load_model() + + # --- add menu --- + def on_zip_add(self) -> None: + file = QtWidgets.QFileDialog.getOpenFileName(self.window, directory=str(Path.home()), filter='zip packed clips(*.zip);;All files(*.*)')[0] + if file: + with ZipFile(file, 'r', compression=ZIP_DEFLATED) as archive: + found = False + for member in archive.namelist(): + if member.startswith('clips/'): + found = True + break + if found: + free_size = shutil.disk_usage(os.path.abspath(os.sep))[2] + uncompressed_size = sum([info.file_size for info in archive.filelist]) + if uncompressed_size > free_size: + zip_too_big_error = QtWidgets.QErrorMessage(self.window) + zip_too_big_error.showMessage( + f'Cannot add clips from \'{file}\': The uncompressed size ({uncompressed_size * 6000000}MB) ' + f'is bigger than the available space on the disk ({free_size * 6000000}MB)') + else: + clips_path = utils.AthnosPath.athnos_path() + for file in archive.namelist(): + if file.startswith('clips/'): + final_file = os.path.join(clips_path, file) + if os.path.exists(final_file): + logger.warning(f'Skipping file {final_file}: The file already exists') + else: + archive.extract(file, clips_path) + + def on_json_add(self) -> None: + file = QtWidgets.QFileDialog.getOpenFileName(self.window, directory=str(Path.home()), filter='json(*.json);;All files(*.*)')[0] + if file: + try: + clips = json.load(open(file, 'r')) + except json.decoder.JSONDecodeError: + logger.warning(f'{file} is not a json file or has a bad syntax') + json_error = QtWidgets.QErrorMessage(self.window) + json_error.showMessage(f'{file} is not a json file or is corrupt / has a bad syntax') + return + try: + to_add = [] + duplicated_files = 0 + files_not_found = 0 + source_not_found = 0 + + root_dir = clips['root_dir'] + for clip_info in clips['clips']: + path = clip_info['path'] + source = clip_info['source'] + type_ = clip_info['type'] + name = clip_info['name'] + description = clip_info['description'] + if not os.path.isfile(path): + files_not_found += 1 + # logger.warning(f'Skipping file {file}: The file does not exist') + elif utils.AthnosPath.clips_path().joinpath(utils.remove_prefix(path, root_dir)).is_file(): + duplicated_files += 1 + # logger.warning(f'Skipping file {file}: The file already exist in the destination directory') + else: + if isinstance(source, int): + if not self.database.Source.has_id(source): + clip_info['source'] = 0 + source_not_found += 1 + elif ''.join(str(value) for value in source.values()) == '': + clip_info['source'] = 0 + else: + source_id = self.database.Source.get_by(path=source['path'], type=source['type'], name=source['name'], description=source['description'], + season=source['season'], episode=source['episode']) + if source_id: + clip_info['source'] = source_id + to_add.append(clip_info) + + except TypeError as e: + error_message = QtWidgets.QMessageBox(self.window) + error_message.warning(self.window, 'Syntax error', f'Syntax error in {file}\n{str(e)}') + # logger.warning(f'Syntax error in {file}') + return + + warning_string = '' + if duplicated_files: + warning_string += f'Duplicated files: {duplicated_files}\n' + if files_not_found: + warning_string += f'Files not found: {files_not_found}\n' + if source_not_found: + warning_string += f'Source not found: {source_not_found}\n' + + if warning_string: + warning_string += '\nDo you want to proceed?' + warning_message = QtWidgets.QMessageBox() + reply = warning_message.warning(self.window, + f'{duplicated_files + files_not_found + source_not_found} warnings', + warning_string, + buttons=QtWidgets.QMessageBox.No | QtWidgets.QMessageBox.Yes, + defaultButton=QtWidgets.QMessageBox.Yes) + if reply != QtWidgets.QMessageBox.Yes: + return + + # iterate two times through the (nearly) same list is pretty inefficient,but for the warning message above it's necessary + for clip_info in to_add: + if isinstance(clip_info['source'], int): + source_id = clip_info['source'] + else: + source = clip_info['source'] + source_id = self.database.Source.add_source(source['path'], source['type'], source['name'], source['description'], source['season'], source['episode']) + self.database.Clip.add_clip(clip_info['path'], source_id, clip_info['name'], clip_info['type'], clip_info['description']) + logger.debug(f'Added {len(to_add)} clips from json to database') + + # --- settings menu --- + def on_new_style(self, checked: bool, action: QtWidgets.QAction) -> None: + """Gets called if a new style is chosen""" + if checked: + for menu_action in self.style_menu.actions(): + if menu_action.isChecked() and menu_action != action: + menu_action.setChecked(False) + break + + # changes the style in the config + config['style'] = action.text() + # changes the style on the ui + self.application.setStyle(action.text()) + else: + action.setChecked(True) + + # --- table view specific --- + def on_search_input_change(self, text: str) -> None: + """This gets called if the search input text has changed""" + if not self.model: + self.load_model() + + for i in range(self.model.columnCount()): + # get the column header + header = str(self.model.headerData(i, QtCore.Qt.Horizontal)).lower() + # checks if the header is in the filter items (which are shown in the filter box) + # and if so, it will check if it's marked / checked + if header in self.filter_items_lower and self.filter_rules[self.filter_items_lower.index(header)]: + # »» sets a new regex filter + self.model.setRegExpFilter(self._columns[header], QtCore.QRegExp(f'^{text}', QtCore.Qt.CaseInsensitive)) + # refresh the model + self.model.invalidate() + + def on_filter_box_change(self, index: QtCore.QModelIndex) -> None: + # the texts for the search input completer + completer_texts = [] + placeholder_text = [] + + # applies the check states of every item in the filter box to `self.filter_rules` + for i in range(self.filter_box_model.rowCount()): + item = self.filter_box_model.item(i, 0) + checked = item.checkState() == QtCore.Qt.Checked + self.filter_rules[i] = checked + if item.flags() == QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled and checked: + placeholder_text.append(item.data(QtCore.Qt.DisplayRole)) + completer_texts.extend(self.all_names[i]) + + if completer_texts: + # sets the input edit completer + search_input_completer = QtWidgets.QCompleter(completer_texts) + search_input_completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) + self.search_input.setCompleter(search_input_completer) + else: + self.search_input.setCompleter(None) + + # sets the placeholder text, matching to the filter items + if placeholder_text: + self.search_input.setPlaceholderText(', '.join(placeholder_text) + ', ...') + + # re-sets the clip view to apply the filter box changes + self.on_search_input_change(self.search_input.text()) + + def on_item_clicked(self, index: QtCore.QModelIndex) -> None: + """ + Gets called if a clip / item is from the clip view was clicked + + Args: + index: The clicked item + """ + if not self.clip_source_splitter.isEnabled(): + self.clip_source_splitter.setEnabled(True) + self.set_extended_clip_view() + self.set_extended_source_view() + + self._row_number = index.row() + self.model = index.model() + + # gets the clip (and sets it for global usage), source and tags of the clicked item + self._clip = self.database.Clip.get(self._all_ids[self._row_number]) + source = self._clip.get_source() + tags = self._clip.get_tags() + + # sets the tag edit stuff + self.file_url_edit.setText(self._clip.path) + self.name_edit.setText(self._clip.name) + self.description_edit.setPlainText(self._clip.description) + if self._clip.type == Type.Clip.AUDIO: + self.type_box.setCurrentIndex(0) + elif self._clip.type == Type.Clip.IMAGE: + self.type_box.setCurrentIndex(1) + elif self._clip.type == Type.Clip.VIDEO: + self.type_box.setCurrentIndex(2) + else: + self.type_box.setCurrentIndex(3) + + self.tags_edit.setTags([tag.name for tag in tags]) + + # source edit + self.source_name.setText(source.name) + self.source_season.setText(source.season) + self.source_episode.setText(source.episode) + self.source_description_edit.setPlainText(source.description) + + # --- clip / source view --- + def open_file_or_link_event_filter(self, a0: QtWidgets.QWidget, a1: QtCore.QEvent): + if a1.type() == QtCore.QEvent.MouseButtonRelease and a1.button() == QtCore.Qt.LeftButton: + regex = re.compile(r'^www\.|' # www. + r'(?:http|ftp)s?://' # http:// or https:// + r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain... + r'localhost|' # localhost... + r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip + r'(?::\d+)?' # optional port + r'(?:/?|[/?]\S+)$', re.IGNORECASE) + if re.match(regex, self.file_url_edit.text()): + url = QtCore.QUrl(self.file_url_edit.text()) + else: + url = QtCore.QUrl.fromLocalFile(self.file_url_edit.text()) + QtGui.QDesktopServices.openUrl(url) + + def set_extended_clip_view(self, enable=False, clear=False) -> None: + """ + Sets the extended clip view to default + + Args: + enable: If True, it enables the extended clip view, False if not + clear: If True, all current texts, clips etc. in the edits will be cleared + """ + # clears everything + if clear: + self.file_url_edit.clear() + self.name_edit.clear() + self.description_edit.clear() + self.tags_edit.clear() + self.type_box.setCurrentIndex(3) + + # enable / disable everything + self.file_url_edit.setReadOnly(not enable) + self.name_edit.setReadOnly(not enable) + self.description_edit.setReadOnly(not enable) + self.type_box.setEnabled(enable) + self.tags_edit.setEnabled(enable) + self.tags_edit.setEnabled(enable) + self.save_button.setEnabled(enable) + self.delete_button.setEnabled(enable) + + self.enable_editing_check_box.setChecked(enable) + + def on_save_edited_clip(self) -> None: + """Saves an edited clip""" + # the clip type + type = Type.Clip.from_name(self.type_box.currentText()) + + # the tags (names) from the not edited clip + old_tags = self._clip.get_tags() + old_tag_names = [tag.name for tag in old_tags] + + new_tags = [] + for tag in self.tags_edit.tags(): + # if a tag doesn't exist, it will be new created + if tag not in self.all_tag_names: + # creates the new tag and connect it via an item with the clip + self.database.Item.add_item(self._clip.id, self.database.Tag.add_tag(tag).id) + + new_tags.append(tag) + elif tag not in old_tag_names: + # adds a new clip-tag connection via an item + self.database.Item.add_item(self._clip.id, self.database.Tag.get_by(name=tag).id) + else: + # removes the clip from the old (not edited) clip tags + del old_tags[old_tag_names.index(tag)] + + if new_tags: + self.all_tag_names.extend(new_tags) + self.tags_edit.setTagSuggestions(self.all_tag_names) + + for tag in old_tags: + self._clip.remove_tag(tag.id) + + # edits the clip itself + self.database.Clip.edit(self._clip.id, name=self.name_edit.text(), description=self.description_edit.toPlainText(), type=type.value) + + logger.debug(f'Edited clip - file / url: `{self.file_url_edit.text()}`, type: `{type.name}`, name: `{self.name_edit.text()}`, ' + f'description: `{self.description_edit.toPlainText()}`, tags: `{", ".join(self.tags_edit.tags())}`') + # update the clip view model with the new clip data + self.model.setData(self.model.index(self._row_number, self._columns['name']), self.name_edit.text()) + self.model.setData(self.model.index(self._row_number, self._columns['description']), self.description_edit.toPlainText()) + self.model.submit() + + def set_extended_source_view(self, enable=False, clear=False) -> None: + """ + Sets the extended source view to default + + Args: + enable: If True, it enables the extended source view, False if not + clear: If True, all current texts etc. in the edits will be cleared + """ + # clears everything + if clear: + self.source_name.clear() + self.source_season.clear() + self.source_episode.clear() + self.source_type_box.setCurrentIndex(3) + + # enable / disable everything + self.source_name.setReadOnly(not enable) + self.source_season.setReadOnly(not enable) + self.source_episode.setReadOnly(not enable) + self.source_description_edit.setReadOnly(not enable) + self.source_type_box.setEnabled(enable) + + def on_edit_source(self) -> None: + """Gets called if the 'Edit source' button is clicked""" + # opens the edit source dialog + EditSource(QtWidgets.QDialog(self.window, QtCore.Qt.WindowSystemMenuHint)).show(self._clip.source_id) + # re-sets the source data for the item + self.on_item_clicked(self.clip_view.selectedIndexes()[0]) + + def on_change_source(self) -> None: + """Gets called if the 'Change source' button is clicked""" + # opens the change source dialog + source_id = ShowSource(QtWidgets.QDialog(self.window, QtCore.Qt.WindowSystemMenuHint)).select() + if source_id is not None: + # changes the old to the new source id + self.database.Clip.edit(self._clip.id, source_id=source_id) + # re-sets the source data for the item + self.on_item_clicked(self.clip_view.selectedIndexes()[0]) + + def close(self) -> None: + # closes the database and the window + self.database.close() + super().close() diff --git a/database structure b/database structure new file mode 100755 index 0000000..7825685 --- /dev/null +++ b/database structure @@ -0,0 +1,32 @@ +- tag + - id + - name (ignore case) + +- source + - id + - path + - type + - audio + - series + - movie + - other + - name + - description + - season + - episode + +- clip + - id + - .source_id + - path + - type + - audio + - video + - image + - name + - description + +- item + - id + - .clip_id + - .tag_id \ No newline at end of file diff --git a/main.py b/main.py new file mode 100755 index 0000000..b0891f6 --- /dev/null +++ b/main.py @@ -0,0 +1,43 @@ +#!/usr/bin/python3 + +__author = 'ByteDream' +__version__ = '1.0.0' + +import sys +from PyQt5 import QtWidgets + + +if __name__ == '__main__': + app = QtWidgets.QApplication(sys.argv) + + from athnos.window.window import Window + from athnos.utils import check_version + from athnos import config, logger + + # sets the window style + if config['style'].lower() in (style.lower() for style in QtWidgets.QStyleFactory.keys()): + app.setStyle(config['style']) + logger.debug(f'Using style {config["style"]}') + else: + logger.warning(f'Tried to set style {config["style"]} but is not supported. Using {QtWidgets.QApplication.style().objectName()}') + + database = config['database'] + + ui = Window(app, driver=database['driver'], name=database['name'], user=database['user'], password=database['password'], ip=database['ip'], port=database['port']) + ui.show() + + version = check_version(__version__) + if version: + message = QtWidgets.QMessageBox(QtWidgets.QMessageBox.Question, 'New release', + f'A new version has been released
' + f'Version: {version["name"]}
') + do_not_show_again = QtWidgets.QCheckBox('Do not show again') + message.setCheckBox(do_not_show_again) + + result = message.exec() + + if do_not_show_again.isChecked(): + config['updates_ignore'].append(version['name']) + config.write() + + sys.exit(app.exec()) diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..beec7d5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +PyQt5>=5.11.2 +dreamutils>=0.1.1 +filetype>=1.0.7 diff --git a/resources/abcd.py b/resources/abcd.py new file mode 100755 index 0000000..5556dc7 --- /dev/null +++ b/resources/abcd.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'add_clip.ui' +# +# Created by: PyQt5 UI code generator 5.15.1 +# +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt5 import QtCore, QtGui, QtWidgets + + +class Ui_Form(object): + def setupUi(self, Form): + Form.setObjectName("Form") + Form.resize(400, 313) + self.verticalLayout = QtWidgets.QVBoxLayout(Form) + self.verticalLayout.setObjectName("verticalLayout") + self.add_clip = QtWidgets.QLabel(Form) + self.add_clip.setAlignment(QtCore.Qt.AlignCenter) + self.add_clip.setObjectName("add_clip") + self.verticalLayout.addWidget(self.add_clip) + self.line = QtWidgets.QFrame(Form) + self.line.setFrameShape(QtWidgets.QFrame.HLine) + self.line.setFrameShadow(QtWidgets.QFrame.Sunken) + self.line.setObjectName("line") + self.verticalLayout.addWidget(self.line) + self.file_hlayout = QtWidgets.QHBoxLayout() + self.file_hlayout.setObjectName("file_hlayout") + self.clip_chooser = QtWidgets.QPushButton(Form) + self.clip_chooser.setObjectName("clip_chooser") + self.file_hlayout.addWidget(self.clip_chooser) + self.file_name_label = QtWidgets.QLabel(Form) + self.file_name_label.setObjectName("file_name_label") + self.file_hlayout.addWidget(self.file_name_label) + self.label = QtWidgets.QLabel(Form) + self.label.setMaximumSize(QtCore.QSize(16, 18)) + self.label.setText("") + self.label.setPixmap(QtGui.QPixmap("../../trash-red.svg")) + self.label.setScaledContents(True) + self.label.setAlignment(QtCore.Qt.AlignCenter) + self.label.setObjectName("label") + self.file_hlayout.addWidget(self.label) + self.verticalLayout.addLayout(self.file_hlayout) + self.name_hlayout = QtWidgets.QHBoxLayout() + self.name_hlayout.setObjectName("name_hlayout") + self.name_label = QtWidgets.QLabel(Form) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.name_label.sizePolicy().hasHeightForWidth()) + self.name_label.setSizePolicy(sizePolicy) + self.name_label.setMinimumSize(QtCore.QSize(70, 0)) + self.name_label.setBaseSize(QtCore.QSize(0, 0)) + self.name_label.setObjectName("name_label") + self.name_hlayout.addWidget(self.name_label) + self.name_edit = QtWidgets.QLineEdit(Form) + self.name_edit.setObjectName("name_edit") + self.name_hlayout.addWidget(self.name_edit) + self.verticalLayout.addLayout(self.name_hlayout) + self.description_hlayout = QtWidgets.QHBoxLayout() + self.description_hlayout.setObjectName("description_hlayout") + self.description_label = QtWidgets.QLabel(Form) + self.description_label.setMinimumSize(QtCore.QSize(70, 0)) + self.description_label.setBaseSize(QtCore.QSize(0, 0)) + self.description_label.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) + self.description_label.setObjectName("description_label") + self.description_hlayout.addWidget(self.description_label) + self.description_edit = QtWidgets.QTextEdit(Form) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.description_edit.sizePolicy().hasHeightForWidth()) + self.description_edit.setSizePolicy(sizePolicy) + self.description_edit.setObjectName("description_edit") + self.description_hlayout.addWidget(self.description_edit) + self.verticalLayout.addLayout(self.description_hlayout) + self.type_hlayout = QtWidgets.QHBoxLayout() + self.type_hlayout.setObjectName("type_hlayout") + self.type_label = QtWidgets.QLabel(Form) + self.type_label.setMinimumSize(QtCore.QSize(70, 0)) + self.type_label.setBaseSize(QtCore.QSize(0, 0)) + self.type_label.setObjectName("type_label") + self.type_hlayout.addWidget(self.type_label) + self.type_box = QtWidgets.QComboBox(Form) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.type_box.sizePolicy().hasHeightForWidth()) + self.type_box.setSizePolicy(sizePolicy) + self.type_box.setFocusPolicy(QtCore.Qt.WheelFocus) + self.type_box.setObjectName("type_box") + self.type_hlayout.addWidget(self.type_box) + self.verticalLayout.addLayout(self.type_hlayout) + self.save_new_hlayout = QtWidgets.QHBoxLayout() + self.save_new_hlayout.setObjectName("save_new_hlayout") + self.save_button = QtWidgets.QPushButton(Form) + self.save_button.setObjectName("save_button") + self.save_new_hlayout.addWidget(self.save_button) + self.new_button = QtWidgets.QPushButton(Form) + self.new_button.setObjectName("new_button") + self.save_new_hlayout.addWidget(self.new_button) + self.verticalLayout.addLayout(self.save_new_hlayout) + + self.retranslateUi(Form) + QtCore.QMetaObject.connectSlotsByName(Form) + + def retranslateUi(self, Form): + _translate = QtCore.QCoreApplication.translate + Form.setWindowTitle(_translate("Form", "Form")) + self.add_clip.setText(_translate("Form", "Add clip")) + self.clip_chooser.setText(_translate("Form", "Choose file")) + self.file_name_label.setText(_translate("Form", "No file selected")) + self.name_label.setText(_translate("Form", "Name")) + self.description_label.setText(_translate("Form", "Description")) + self.type_label.setText(_translate("Form", "Type")) + self.save_button.setText(_translate("Form", "Save")) + self.new_button.setText(_translate("Form", "New")) diff --git a/resources/add_clip.ui b/resources/add_clip.ui new file mode 100755 index 0000000..2c13c44 --- /dev/null +++ b/resources/add_clip.ui @@ -0,0 +1,361 @@ + + + Form + + + + 0 + 0 + 400 + 469 + + + + Add clip + + + + + + Add clip + + + Qt::AlignCenter + + + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + + + + + File or url... + + + false + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + Choose file + + + false + + + + + + + + + Qt::Horizontal + + + + + + + + + + 0 + 0 + + + + + 70 + 0 + + + + + 0 + 0 + + + + Name + + + + + + + + + + true + + + false + + + + + + + + + + + + 70 + 0 + + + + + 0 + 0 + + + + Description + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + true + + + + 0 + 0 + + + + true + + + + + + + + + + + + 70 + 0 + + + + + 0 + 0 + + + + Type + + + + + + + false + + + + 0 + 0 + + + + Qt::WheelFocus + + + 3 + + + + Audio + + + + + Image + + + + + Video + + + + + Other + + + + + + + + + + Qt::Horizontal + + + + + + + + + + 70 + 0 + + + + Tags + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + + 0 + 0 + + + + true + + + + + 0 + 0 + 290 + 83 + + + + + + + + + + + Qt::Horizontal + + + + + + + + + false + + + + 100 + 16777215 + + + + Select source + + + + + + + false + + + No source selected + + + + + + + + + + + + + + false + + + Save + + + Ctrl+S + + + false + + + + + + + New + + + false + + + false + + + false + + + false + + + + + + + + + + diff --git a/resources/add_source.ui b/resources/add_source.ui new file mode 100755 index 0000000..fbffb32 --- /dev/null +++ b/resources/add_source.ui @@ -0,0 +1,257 @@ + + + Form + + + + 0 + 0 + 452 + 400 + + + + Add source + + + + + + Add source + + + Qt::AlignCenter + + + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + + + + + File or url... + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + Choose file + + + Ctrl+O + + + + + + + + + Qt::Horizontal + + + + + + + + + + 70 + 0 + + + + Type + + + + + + + false + + + + 0 + 0 + + + + 2 + + + + Audio + + + + + Movie + + + + + Series + + + + + Other + + + + + + + + + + + + + 70 + 0 + + + + Name + + + + + + + true + + + + + + + Season + + + + + + + + 50 + 16777215 + + + + true + + + + + + + Episode + + + + + + + + 50 + 16777215 + + + + true + + + + + + + + + + + + 70 + 0 + + + + Description + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + true + + + + + + + + + + + + + + false + + + Save + + + Ctrl+S + + + + + + + New + + + Ctrl+N + + + + + + + + + + diff --git a/resources/clipbase.sql b/resources/clipbase.sql new file mode 100755 index 0000000..58be6fd --- /dev/null +++ b/resources/clipbase.sql @@ -0,0 +1,125 @@ +-- MariaDB dump 10.18 Distrib 10.5.8-MariaDB, for debian-linux-gnu (x86_64) +-- +-- Host: localhost Database: athnos +-- ------------------------------------------------------ +-- Server version 10.5.8-MariaDB-3 + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!40101 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `clip` +-- + +DROP TABLE IF EXISTS `clip`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `clip` ( + `id` int(10) unsigned DEFAULT NULL, + `source_id` int(10) unsigned DEFAULT NULL, + `path` text DEFAULT NULL, + `type` smallint(1) DEFAULT NULL, + `name` tinytext DEFAULT NULL, + `description` text DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `clip` +-- + +LOCK TABLES `clip` WRITE; +/*!40000 ALTER TABLE `clip` DISABLE KEYS */; +/*!40000 ALTER TABLE `clip` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `item` +-- + +DROP TABLE IF EXISTS `item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `item` ( + `id` int(10) unsigned DEFAULT NULL, + `clip_id` int(10) unsigned DEFAULT NULL, + `tag_id` int(10) unsigned DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `item` +-- + +LOCK TABLES `item` WRITE; +/*!40000 ALTER TABLE `item` DISABLE KEYS */; +/*!40000 ALTER TABLE `item` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `source` +-- + +DROP TABLE IF EXISTS `source`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `source` ( + `id` int(10) unsigned DEFAULT NULL, + `path` text DEFAULT NULL, + `type` smallint(1) unsigned DEFAULT NULL, + `name` tinytext DEFAULT NULL, + `description` text DEFAULT NULL, + `season` tinytext DEFAULT NULL, + `episode` tinytext DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `source` +-- + +LOCK TABLES `source` WRITE; +/*!40000 ALTER TABLE `source` DISABLE KEYS */; +/*!40000 ALTER TABLE `source` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `tag` +-- + +DROP TABLE IF EXISTS `tag`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `tag` ( + `id` int(10) unsigned DEFAULT NULL, + `name` varchar(255) DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `tag` +-- + +LOCK TABLES `tag` WRITE; +/*!40000 ALTER TABLE `tag` DISABLE KEYS */; +/*!40000 ALTER TABLE `tag` ENABLE KEYS */; +UNLOCK TABLES; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2020-12-21 20:34:41 diff --git a/resources/clips.json b/resources/clips.json new file mode 100755 index 0000000..2d4c652 --- /dev/null +++ b/resources/clips.json @@ -0,0 +1,147 @@ +{ + "move": false, + "copy": false, + "root_dir": "/home/bytedream/backup/athnos/resources", + "clips": [ + { + "source": { + "type": "", + "path": "", + "name": "", + "description": "", + "season": "", + "episode": "" + }, + "path": "/home/bytedream/backup/athnos/resources/athnos.qrc", + "type": 0, + "name": "athnos", + "description": "a" + }, + { + "source": { + "type": "", + "path": "", + "name": "", + "description": "", + "season": "", + "episode": "" + }, + "path": "/home/bytedream/backup/athnos/resources/show_source.ui", + "type": 0, + "name": "show_source", + "description": "a" + }, + { + "source": { + "type": "", + "path": "", + "name": "", + "description": "", + "season": "", + "episode": "" + }, + "path": "/home/bytedream/backup/athnos/resources/add_clip.ui", + "type": 0, + "name": "add_clip", + "description": "a" + }, + { + "source": { + "type": "", + "path": "", + "name": "", + "description": "", + "season": "", + "episode": "" + }, + "path": "/home/bytedream/backup/athnos/resources/add_source.ui", + "type": 0, + "name": "add_source", + "description": "a" + }, + { + "source": { + "type": "", + "path": "", + "name": "", + "description": "", + "season": "", + "episode": "" + }, + "path": "/home/bytedream/backup/athnos/resources/main_window.ui", + "type": 0, + "name": "main_window", + "description": "a" + }, + { + "source": { + "type": "", + "path": "", + "name": "", + "description": "", + "season": "", + "episode": "" + }, + "path": "/home/bytedream/backup/athnos/resources/connect_to_database.ui", + "type": 0, + "name": "connect_to_database", + "description": "a" + }, + { + "source": { + "type": "", + "path": "", + "name": "", + "description": "", + "season": "", + "episode": "" + }, + "path": "/home/bytedream/backup/athnos/resources/athnos.sql", + "type": 0, + "name": "athnos", + "description": "a" + }, + { + "source": { + "type": "", + "path": "", + "name": "", + "description": "", + "season": "", + "episode": "" + }, + "path": "/home/bytedream/backup/athnos/resources/abcd.py", + "type": 0, + "name": "abcd", + "description": "a" + }, + { + "source": { + "type": "", + "path": "", + "name": "", + "description": "", + "season": "", + "episode": "" + }, + "path": "/home/bytedream/backup/athnos/resources/__pycache__/abcd.cpython-39.pyc", + "type": 0, + "name": "abcd.cpython-39", + "description": "a" + }, + { + "source": { + "type": "", + "path": "", + "name": "", + "description": "", + "season": "", + "episode": "" + }, + "path": "/home/bytedream/backup/athnos/resources/images/trash-red.svg", + "type": 0, + "name": "trash-red", + "description": "a" + } + ] +} \ No newline at end of file diff --git a/resources/connect_to_database.ui b/resources/connect_to_database.ui new file mode 100755 index 0000000..ec5b160 --- /dev/null +++ b/resources/connect_to_database.ui @@ -0,0 +1,381 @@ + + + Form + + + + 0 + 0 + 400 + 490 + + + + + 0 + 0 + + + + Connect to database + + + + QLayout::SetDefaultConstraint + + + + + + + Database + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 13 + 20 + + + + + + + + + 0 + 0 + + + + + + + + + + Qt::Horizontal + + + + + + + + + + 118 + 0 + + + + + 130 + 16777215 + + + + Database file + + + + + + + + 0 + 0 + + + + No file selected + + + + + + + 0 + + + + + Qt::Vertical + + + + + + + + 0 + 0 + + + + + + + + + + + :/file_url/trash:/file_url/trash + + + + 18 + 18 + + + + false + + + false + + + true + + + + + + + + 0 + 0 + + + + + + + + + + + :/file_url/add_file:/file_url/add_file + + + + 18 + 18 + + + + true + + + + + + + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + + + 100 + 0 + + + + Database IP + + + + + + + 127.0.0.1 + + + + + + + + + + + + 100 + 0 + + + + User + + + + + + + root + + + + + + + + + + + + 100 + 0 + + + + Password + + + + + + + Qt::ImhHiddenText|Qt::ImhNoAutoUppercase|Qt::ImhNoPredictiveText|Qt::ImhSensitiveData + + + QLineEdit::Password + + + + + + + + + + + + 100 + 0 + + + + Port + + + + + + + 3306 + + + true + + + false + + + + + + + + + + + + 100 + 0 + + + + Database name + + + + + + + athnos + + + + + + + + + + + + Show available names + + + + + + + Qt::ImhNone + + + QAbstractItemView::NoEditTriggers + + + 0 + + + + + + + + + + + + + + + + + + + + + Test connection + + + + + + + Save + + + + + + + + + + + + diff --git a/resources/export.ui b/resources/export.ui new file mode 100755 index 0000000..d451157 --- /dev/null +++ b/resources/export.ui @@ -0,0 +1,32 @@ + + + Form + + + + 0 + 0 + 400 + 300 + + + + Export + + + + + 110 + 180 + 88 + 22 + + + + Inclue files + + + + + + diff --git a/resources/images/add-file.svg b/resources/images/add-file.svg new file mode 100755 index 0000000..b221349 --- /dev/null +++ b/resources/images/add-file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/images/hover.svg b/resources/images/hover.svg new file mode 100755 index 0000000..86432ea --- /dev/null +++ b/resources/images/hover.svg @@ -0,0 +1,69 @@ + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/resources/images/internet.svg b/resources/images/internet.svg new file mode 100755 index 0000000..3f4a022 --- /dev/null +++ b/resources/images/internet.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/images/normal.svg b/resources/images/normal.svg new file mode 100755 index 0000000..07cc392 --- /dev/null +++ b/resources/images/normal.svg @@ -0,0 +1,66 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/resources/images/trash-red.svg b/resources/images/trash-red.svg new file mode 100755 index 0000000..3a8ac73 --- /dev/null +++ b/resources/images/trash-red.svg @@ -0,0 +1,8 @@ + + + trash-red + + Layer 1 + + + \ No newline at end of file diff --git a/resources/main_window.ui b/resources/main_window.ui new file mode 100755 index 0000000..4c03d9d --- /dev/null +++ b/resources/main_window.ui @@ -0,0 +1,908 @@ + + + MainWindow + + + + 0 + 0 + 923 + 684 + + + + Athnos + + + QTabWidget::Rounded + + + + + + + true + + + Qt::Horizontal + + + true + + + + + + + QGroupBox {border: 1px solid grey; margin-top: 0.5em; font: 12px consolas;} QGroupBox::title {top: -6px; left: 10px} + + + Search + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 5 + + + + + + + + + + + + + true + + + + + + + + 0 + 0 + + + + + + + + + + + + + Qt::DefaultContextMenu + + + QFrame::StyledPanel + + + QAbstractScrollArea::AdjustToContents + + + true + + + QAbstractItemView::NoEditTriggers + + + false + + + true + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + true + + + true + + + + + + + + + + + true + + + Qt::Vertical + + + + true + + + QGroupBox {border: 1px solid grey; margin-top: 0.5em; font: 12px consolas;} QGroupBox::title {top: -6px; left: 10px} + + + Clip + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 5 + + + + + + + + + + + 70 + 0 + + + + File / URL + + + + + + + + + + + + Qt::Horizontal + + + + + + + QLayout::SetDefaultConstraint + + + + + true + + + + 0 + 0 + + + + + 70 + 0 + + + + Name + + + + + + + true + + + false + + + + + + + + + + + + 0 + 0 + + + + + 70 + 0 + + + + Description + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + false + + + false + + + + + + + true + + + + 0 + 0 + + + + false + + + + + + + + + Qt::Horizontal + + + + + + + QLayout::SetDefaultConstraint + + + + + + 0 + 0 + + + + + 70 + 0 + + + + Type + + + + + + + true + + + false + + + Other + + + 3 + + + false + + + + + + + Audio + + + + + Image + + + + + Video + + + + + Other + + + + + + + + + + + + + 70 + 0 + + + + Tags + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + true + + + true + + + + + 0 + 0 + 483 + 83 + + + + + + + + + + + + + true + + + + 0 + 0 + + + + Save + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 15 + 20 + + + + + + + + true + + + + 0 + 0 + + + + Enable editing + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 15 + 20 + + + + + + + + true + + + + 0 + 0 + + + + Delete + + + false + + + false + + + + + + + + + + QGroupBox {border: 1px solid grey; margin-top: 0.5em; font: 12px consolas;} QGroupBox::title {top: -6px; left: 10px} + + + Source + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 5 + + + + + + + + + + + 0 + 0 + + + + From + + + + + + + true + + + + + + false + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 7 + 20 + + + + + + + + + 0 + 0 + + + + Season + + + + + + + true + + + + 0 + 0 + + + + + 50 + 16777215 + + + + + 0 + 0 + + + + false + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 7 + 20 + + + + + + + + Episode + + + + + + + true + + + + 0 + 0 + + + + + 50 + 16777215 + + + + + 0 + 0 + + + + false + + + + + + + + + + + + 0 + 0 + + + + + 70 + 0 + + + + Description + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + true + + + + 0 + 0 + + + + + + + + + + + + + 0 + 0 + + + + + 70 + 0 + + + + Type + + + + + + + 3 + + + + Audio + + + + + Movie + + + + + Series + + + + + Other + + + + + + + + + + + + true + + + Edit source + + + + + + + true + + + Change source + + + + + + + + + + + + + + + + 0 + 0 + 923 + 30 + + + + + Settings + + + + Style + + + + + Updates + + + + Update message + + + + + + + + + + + + + + + + + + Help + + + + + + File + + + + + + + + + + + + + + + + Connect to database... + + + + + true + + + true + + + Check for updates on startup + + + + + Check for updates... + + + + + true + + + On major update + + + + + true + + + true + + + On minor update + + + + + true + + + On patch + + + + + About + + + + + Import... + + + + + Export... + + + + + Close + + + + + Add clip... + + + + + Add source... + + + + + + diff --git a/resources/resources.qrc b/resources/resources.qrc new file mode 100755 index 0000000..2868062 --- /dev/null +++ b/resources/resources.qrc @@ -0,0 +1,11 @@ + + + images/hover.svg + images/normal.svg + + + images/internet.svg + images/trash-red.svg + images/add-file.svg + + diff --git a/resources/show_source.ui b/resources/show_source.ui new file mode 100755 index 0000000..a66f534 --- /dev/null +++ b/resources/show_source.ui @@ -0,0 +1,120 @@ + + + Form + + + + 0 + 0 + 815 + 530 + + + + Show source + + + + + + Qt::Horizontal + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + Source + + + Qt::AlignCenter + + + + + + + Search for source... + + + + + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + + + + + Select source + + + false + + + false + + + false + + + + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + QFrame::Raised + + + All tags using this source + + + Qt::AlignCenter + + + + + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + + + + + + + + + + diff --git a/test.py b/test.py new file mode 100755 index 0000000..ad93fdd --- /dev/null +++ b/test.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python +# -*- coding:utf-8 -*- + +from PyQt5 import QtCore, QtGui, QtWidgets +import pandas as pd + + +class PandasModel(QtCore.QAbstractTableModel): + def __init__(self, df=pd.DataFrame(), parent=None): + QtCore.QAbstractTableModel.__init__(self, parent=parent) + self._df = df.copy() + self.bolds = dict() + + def toDataFrame(self): + return self._df.copy() + + def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole): + if orientation == QtCore.Qt.Horizontal: + if role == QtCore.Qt.DisplayRole: + try: + return self._df.columns.tolist()[section] + except (IndexError,): + return QtCore.QVariant() + elif role == QtCore.Qt.FontRole: + return self.bolds.get(section, QtCore.QVariant()) + elif orientation == QtCore.Qt.Vertical: + if role == QtCore.Qt.DisplayRole: + try: + # return self.df.index.tolist() + return self._df.index.tolist()[section] + except (IndexError,): + return QtCore.QVariant() + return QtCore.QVariant() + + def setFont(self, section, font): + self.bolds[section] = font + self.headerDataChanged.emit(QtCore.Qt.Horizontal, 0, self.columnCount()) + + def data(self, index, role=QtCore.Qt.DisplayRole): + if role != QtCore.Qt.DisplayRole: + return QtCore.QVariant() + + if not index.isValid(): + return QtCore.QVariant() + + return QtCore.QVariant(str(self._df.iloc[index.row(), index.column()])) + + def setData(self, index, value, role): + row = self._df.index[index.row()] + col = self._df.columns[index.column()] + if hasattr(value, "toPyObject"): + # PyQt4 gets a QVariant + value = value.toPyObject() + else: + # PySide gets an unicode + dtype = self._df[col].dtype + if dtype != object: + value = None if value == "" else dtype.type(value) + self._df.set_value(row, col, value) + return True + + def rowCount(self, parent=QtCore.QModelIndex()): + return len(self._df.index) + + def columnCount(self, parent=QtCore.QModelIndex()): + return len(self._df.columns) + + def sort(self, column, order): + colname = self._df.columns.tolist()[column] + self.layoutAboutToBeChanged.emit() + self._df.sort_values( + colname, ascending=order == QtCore.Qt.AscendingOrder, inplace=True + ) + self._df.reset_index(inplace=True, drop=True) + self.layoutChanged.emit() + + +class CheckablePandasModel(PandasModel): + def __init__(self, df=pd.DataFrame(), parent=None): + super().__init__(df, parent) + self.checkable_values = set() + self._checkable_column = -1 + + @property + def checkable_column(self): + return self._checkable_column + + @checkable_column.setter + def checkable_column(self, column): + if self.checkable_column == column: + return + last_column = self.checkable_column + self._checkable_column = column + + if last_column == -1: + self.beginInsertColumns( + QtCore.QModelIndex(), self.checkable_column, self.checkable_column + ) + self.endInsertColumns() + + elif self.checkable_column == -1: + self.beginRemoveColumns(QtCore.QModelIndex(), last_column, last_column) + self.endRemoveColumns() + for c in (last_column, column): + if c > 0: + self.dataChanged.emit( + self.index(0, c), self.index(self.columnCount() - 1, c) + ) + + def columnCount(self, parent=QtCore.QModelIndex()): + return super().columnCount(parent) + (1 if self.checkable_column != -1 else 0) + + def data(self, index, role=QtCore.Qt.DisplayRole): + if self.checkable_column != -1: + row, col = index.row(), index.column() + if col == self.checkable_column: + if role == QtCore.Qt.CheckStateRole: + return ( + QtCore.Qt.Checked + if row in self.checkable_values + else QtCore.Qt.Unchecked + ) + return QtCore.QVariant() + if col > self.checkable_column: + index = index.sibling(index.row(), col - 1) + return super().data(index, role) + + def setData(self, index, value, role): + if self.checkable_column != -1: + row, col = index.row(), index.column() + if col == self.checkable_column: + if role == QtCore.Qt.CheckStateRole: + if row in self.checkable_values: + self.checkable_values.discard(row) + else: + self.checkable_values.add(row) + self.dataChanged.emit(index, index, (role,)) + return True + return False + if col > self.checkable_column: + index = index.sibling(index.row(), col - 1) + return super().setData(index, value, role) + + def flags(self, index): + if self.checkable_column != -1: + col = index.column() + if col == self.checkable_column: + return QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled + if col > self.checkable_column: + index = index.sibling(index.row(), col - 1) + return super().flags(index) + + +class CustomProxyModel(QtCore.QSortFilterProxyModel): + def __init__(self, parent=None): + super().__init__(parent) + self._filters = dict() + + @property + def filters(self): + return self._filters + + def setFilter(self, expresion, column): + if expresion: + self.filters[column] = expresion + elif column in self.filters: + del self.filters[column] + self.invalidateFilter() + + def filterAcceptsRow(self, source_row, source_parent): + for column, expresion in self.filters.items(): + text = self.sourceModel().index(source_row, column, source_parent).data() + regex = QtCore.QRegExp( + expresion, QtCore.Qt.CaseInsensitive, QtCore.QRegExp.RegExp + ) + if regex.indexIn(text) == -1: + return False + return True + + +class myWindow(QtWidgets.QMainWindow): + def __init__(self, parent=None): + super(myWindow, self).__init__(parent) + self.centralwidget = QtWidgets.QWidget() + self.lineEdit = QtWidgets.QLineEdit() + self.view = QtWidgets.QTableView() + self.comboBox = QtWidgets.QComboBox() + self.label = QtWidgets.QLabel() + + self.gridLayout = QtWidgets.QGridLayout(self.centralwidget) + self.gridLayout.addWidget(self.lineEdit, 0, 1, 1, 1) + self.gridLayout.addWidget(self.view, 1, 0, 1, 3) + self.gridLayout.addWidget(self.comboBox, 0, 2, 1, 1) + self.gridLayout.addWidget(self.label, 0, 0, 1, 1) + + self.setCentralWidget(self.centralwidget) + self.label.setText("Regex Filter") + + self.load_sites() + self.comboBox.addItems(["{0}".format(col) for col in self.model._df.columns]) + + self.lineEdit.textChanged.connect(self.on_lineEdit_textChanged) + + self.horizontalHeader = self.view.horizontalHeader() + self.horizontalHeader.sectionClicked.connect( + self.on_view_horizontalHeader_sectionClicked + ) + + def load_sites(self): + df = pd.DataFrame( + { + "site_codes": ["01", "02", "03", "04"], + "status": ["open", "open", "open", "closed"], + "Location": ["east", "north", "south", "east"], + "data_quality": ["poor", "moderate", "high", "high"], + } + ) + + self.model = CheckablePandasModel(df) + self.model.checkable_column = 0 + self.proxy = CustomProxyModel(self) + self.proxy.setSourceModel(self.model) + self.view.setModel(self.proxy) + self.view.resizeColumnsToContents() + + @QtCore.pyqtSlot(int) + def on_view_horizontalHeader_sectionClicked(self, logicalIndex): + if logicalIndex == self.model.checkable_column: + return + + self.menuValues = QtWidgets.QMenu(self) + self.comboBox.blockSignals(True) + self.comboBox.setCurrentIndex( + logicalIndex - 1 + if logicalIndex > self.model.checkable_column + else logicalIndex + ) + self.comboBox.blockSignals(True) + + valuesUnique = set( + self.proxy.index(i, logicalIndex).data() + for i in range(self.proxy.rowCount()) + ) + + actionAll = QtWidgets.QAction("All", self) + self.menuValues.addAction(actionAll) + self.menuValues.addSeparator() + for i, name in enumerate(valuesUnique): + action = QtWidgets.QAction(name, self) + action.setData(i) + self.menuValues.addAction(action) + + headerPos = self.view.mapToGlobal(self.horizontalHeader.pos()) + pos = headerPos + QtCore.QPoint( + self.horizontalHeader.sectionPosition(logicalIndex), + self.horizontalHeader.height(), + ) + action = self.menuValues.exec_(pos) + if action is not None: + font = QtGui.QFont() + if action.data() is None: # all + self.proxy.setFilter("", logicalIndex) + else: + font.setBold(True) + self.proxy.setFilter(action.text(), logicalIndex) + self.model.setFont(logicalIndex - 1, font) + + @QtCore.pyqtSlot(str) + def on_lineEdit_textChanged(self, text): + self.proxy.setFilter(text, self.comboBox.currentIndex() + 1) + + +if __name__ == "__main__": + import sys + + app = QtWidgets.QApplication(sys.argv) + main = myWindow() + main.show() + main.resize(2000, 800) + sys.exit(app.exec_()) \ No newline at end of file diff --git a/test.sqlite3 b/test.sqlite3 new file mode 100755 index 0000000..48d6dc4 Binary files /dev/null and b/test.sqlite3 differ